Django’s JSONField with PostgreSQL is actually the database enforcing your schema, not Django.

Here’s a Product model with a details JSONField:

from django.db import models

class Product(models.Model):
    name = models.CharField(max_length=255)
    details = models.JSONField(default=dict)

    def __str__(self):
        return self.name

And here’s how you might use it:

# Create a product
product1 = Product.objects.create(name="Laptop")
product1.details = {
    "brand": "TechCo",
    "specs": {
        "cpu": "i7",
        "ram_gb": 16,
        "storage_gb": 512
    },
    "features": ["backlit keyboard", "webcam"]
}
product1.save()

# Retrieve and query
laptop = Product.objects.get(name="Laptop")
print(laptop.details["brand"]) # Output: TechCo
print(laptop.details["specs"]["ram_gb"]) # Output: 16

# Querying specific JSON keys
high_ram_laptops = Product.objects.filter(details__specs__ram_gb__gte=16)
print(high_ram_laptops.count()) # Output: 1

This leverages PostgreSQL’s native JSONB type, which is indexed and allows for efficient querying directly within the database. Django just provides a Pythonic interface to it.

The real power comes from how PostgreSQL handles these JSONB documents. It doesn’t just store them as opaque blobs; it parses them and allows you to index specific paths within the JSON structure, making queries like details__specs__ram_gb__gte=16 performant. This means you can have deeply nested, arbitrarily structured data without needing to alter your database schema with ALTER TABLE statements for every new attribute you want to track.

Internally, when you save a Product object with data in its details field, Django serializes that Python dictionary or list into a JSON string and sends it to PostgreSQL. PostgreSQL’s JSONB type then stores this as a binary representation, which is optimized for querying. When you retrieve the object, Django fetches the JSON string from PostgreSQL and deserializes it back into a Python object. The magic is in PostgreSQL’s ability to understand and index the structure of that JSONB data.

The default=dict argument is crucial. If you don’t provide a default, and you try to access product.details on a new instance before assigning anything, you’ll get a KeyError or TypeError when trying to access keys within None. default=dict ensures that product.details is always a dictionary, even if it’s empty, preventing these errors and allowing you to immediately start adding key-value pairs.

What most people don’t realize is that while JSONField offers schema flexibility, you’re not entirely free from schema considerations. PostgreSQL’s indexing capabilities are what make JSONField truly practical for anything beyond simple storage. Without appropriate indexing on the JSONB paths you query frequently, performance can degrade significantly as the dataset grows. You can create GIN indexes on your JSONField to speed up lookups on specific keys or paths, for example: CREATE INDEX product_details_gin_idx ON your_app_product USING GIN (details); (this indexes all keys) or more specific indexes like CREATE INDEX product_details_ram_idx ON your_app_product USING GIN ((details -> 'specs' ->> 'ram_gb')); (though this requires a bit more work with PostgreSQL versions and specific indexing strategies).

The next step is understanding how to effectively index your JSONField for complex queries.

Want structured learning?

Take the full Django course →