Django JSONStore

Typed Django model fields in one JSON column

Add a model field.
Skip the migration.

Django JSONStore keeps model field values in one JSON column. You declare typed fields on the model as usual. Each field reads and writes a key inside a single JSONField. Forms, the admin, queries and ordering use them in the same way as concrete fields.

import jsonstore

class Employee(models.Model):
    data = models.JSONField(default=dict)

    full_name = jsonstore.CharField(max_length=250)
    hire_date = jsonstore.DateField()
    salary    = jsonstore.DecimalField(max_digits=10, decimal_places=2)

# One column holds all three values. makemigrations produces nothing.
employee = Employee(full_name="Ann Lee", salary=Decimal("100.00"))
employee.data
# {"full_name": "Ann Lee", "salary": "100.00"}

What changes

A new field becomes a code change, not a migration.

The model needs one models.JSONField, and that column takes one migration. Every virtual field after it lives in the document. There is no ALTER TABLE, no lock on a large table, and no migration to review. A tenant, a form builder or a settings model can hold a different set of fields for each subclass, in one table.

The tradeoff is at the database level: these fields have no constraint and no index. Read the limitations before you move a hot query into a document.


Comparison

What a plain JSONField does not give you

A plain JSONField gives you a dictionary. There is no type, no validation, no form widget and no admin column, and every query needs the data__ prefix. JSONStore gives each key a real Django field.

Typed values in both directions

A DecimalField returns a Decimal, and a DateField returns a date. The field validates the value on the way in. A raw document returns the string.

Forms and the admin work

Name the field in Meta.fields, in ModelAdmin.fields, or in list_display. Each field builds its own widget, so no custom form code is necessary.

Queries use the field name

filter(full_name="Ann Lee") and order_by("hire_date") compile to a key transform on the JSON column. The data__ prefix stays out of your code.

The exit is a two-step change

To give a field its own column, remove the jsonstore. prefix and run makemigrations. Then copy the values out of the document in a data migration.

Field types

Twenty-five field types, and the relations

Each field takes the same arguments as the equivalent field in django.db.models. This includes max_length, default, null, blank, choices, verbose_name and help_text. The field converts the value when it reads the document.

Stored as a string

Stored as a number

Stored as ISO 8601 text

Stored as a JSON boolean

Stored without loss

Relations, as primary keys

Sub-documents


Keys and layout

You select the column and the key

A field uses its own name as the key, in a JSON column with the name data. json_field_name selects the column, and one model can keep its virtual fields in more than one column. Put the public values in one column and the private values in another.

json_key selects the key. Give a string for a different key, or a list for a path. The package makes the intermediate dictionaries, and more than one field can share a parent key. The key applies to every operation: reads, writes, filter(), filter(city__isnull=True) and order_by().

class Person(models.Model):
    data = models.JSONField(default=dict)

    full_name = jsonstore.CharField(
        max_length=250,
        json_key="name")
    city = jsonstore.CharField(
        max_length=100,
        json_key=("address", "city"))
    zip_code = jsonstore.CharField(
        max_length=10,
        json_key=("address", "zip"))

Person(full_name="Ann", city="Paris",
       zip_code="75001").data
# {"name": "Ann",
#  "address": {"city": "Paris",
#              "zip": "75001"}}

Relations

A foreign key in the document

jsonstore.ForeignKey keeps the primary key of the related object under the key <name>_id. The package reads the object at first access, then keeps it in a cache. A lazy "app.Model" target and any primary key type are permitted, and formfield() returns a ModelChoiceField. OneToOneField operates in the same way in the forward direction.

jsonstore.ManyToManyField keeps a list of primary keys, so there is no join table. The accessor is a manager with all, add, remove, set, clear and count.

The value is in a document, and thus the database has no foreign key constraint. on_delete has no effect, the target model gets no reverse accessor, and a join is not available. Query the JSON key instead.

class Book(models.Model):
    data = models.JSONField(default=dict)
    title = jsonstore.CharField(max_length=250)
    author = jsonstore.ForeignKey(
        Author, null=True)

book = Book(title="Pale Fire", author=nabokov)
book.data     # {"title": …, "author_id": 1}
book.author   # <Author: Nabokov>

post.tags.set([python, django])
post.tags.count()      # 2
post.save()

# A join is not available. Use the key.
Book.objects.filter(
    data__author_id=nabokov.pk)

Embedded documents

A schema for a sub-document

jsonstore.EmbeddedModel declares typed fields and has no table. EmbeddedField puts one instance in a model, and EmbeddedListField holds a list of them. Both accept every field type above, and an embedded model can contain a second one.

The accessor is attached to the stored sub-document. Thus an in-place change such as product.price.amount = 120 becomes permanent at the next save(). The list accessor permits an index, a slice, iteration, len, append, insert and del.

class Money(jsonstore.EmbeddedModel):
    amount = jsonstore.IntegerField()
    currency = jsonstore.CharField(
        max_length=3, default="USD")

class Product(models.Model):
    data = models.JSONField(default=dict)
    price = jsonstore.EmbeddedField(
        Money, null=True)

product.price = Money(amount=100)
product.data
# {"price": {"amount": 100, "currency": "USD"}}
product.price.amount  # 100

order.lines.append(LineItem(sku="c", qty=3))
order.lines[0].qty = 5
order.save()

One table, several types

Polymorphic models without a second table

Virtual fields operate with proxy models. Each proxy declares the fields of its own type, and every type shares the one JSON column of the base model. Multi-table inheritance is not necessary, and a new subclass adds no table and no migration.

class User(PolymorphicModel, AbstractUser):
    data = models.JSONField(default=dict)

class Client(User):
    address = jsonstore.CharField(max_length=250)
    city = jsonstore.CharField(max_length=250)
    vip = jsonstore.BooleanField()

    class Meta:
        proxy = True

Install

One command, and no application to register

pip install django-jsonstore

Do not add an application to INSTALLED_APPS. The package needs Python 3.10 or later, and Django 5.2, 6.0 or 6.1. Add one models.JSONField to the model, then declare the virtual fields.

Databases

Every backend that Django supports for JSON

Django's own JSONField writes the data. Thus the requirements are the same as the requirements of Django. A query on a virtual field becomes a key transform, and the database must be able to read a key in the document. The suite passes on SQLite, PostgreSQL 17, MySQL 8.4, MariaDB 11 and Oracle 23ai.

Limitations

What the database no longer does for you

A value in a document is outside the reach of the schema. Read this list before you select a document over a column.

FAQ

Questions

Does a jsonstore field need a migration?

No. A virtual field has no column of its own, and makemigrations produces nothing for it. The model needs one models.JSONField, and that column takes one migration. After that, a new field is a code change only.

Can I filter and sort on a virtual field?

Yes. A query on a virtual field becomes a key transform on the JSON column. filter(), exclude(), order_by(), values() and values_list() accept the field name. The field has no index, so a filter reads each document. Add a functional index on the JSON column for a hot query.

How is this different from a plain JSONField?

A plain JSONField gives you a dictionary. There is no type, no validation, no form field and no admin column, and every query needs the data__ prefix. JSONStore gives each key a real Django field. The field converts the value, validates it, builds the form widget, and accepts its own name in a query.

What happens when a field must become a real column?

Remove the jsonstore. prefix and run makemigrations. Then write a data migration that copies the values out of the JSON document. No other code changes are necessary, because the field name and the field type stay the same.

Which license applies?

The AGPL, with the additional permissions in LICENSE_EXCEPTION. The exception permits use in a project with a license that is not compatible with the AGPL, and a proprietary project is included. Your own code keeps your own license. The condition is that you do not change the source code of this package.