JSONField в Django: рабочие приёмы и типичные ошибки
Как хранить и запрашивать полуструктурированные данные в JSONField без сюрпризов: индексы, фильтры по вложенным ключам, разница между null и валидацией.

Вы завели JSONField под настройки пользователя или ответ внешнего API, сложили туда словарь и обрадовались гибкости. Через месяц фильтр по вложенному ключу тормозит на секунду, filter(data__isnull=True) находит не то, что вы ждали, а миграция типа падает на боевой базе. Разберём приёмы, которые убирают эти грабли на старте. Материал для Django 5.x и PostgreSQL 16, но принципиальные вещи справедливы и для 4.2 LTS.
Речь только о работе с данными внутри поля: как фильтровать, индексировать и не потерять типы. Проектирование схемы (что вообще стоит хранить в JSON, а что вынести в отдельную таблицу) — отдельная тема, её здесь не трогаем.
Где обычно ломается
Очевидное решение — объявить data = models.JSONField(default=dict) и работать со словарём как в Python. Проблемы начинаются на трёх участках.
- Мутабельный дефолт. Нельзя писать
default={}илиdefault=[]— один объект разделится между всеми экземплярами. Нужен вызываемый:default=dictбез скобок. - JSON
nullпротив SQLNULL. Это два разных состояния. ПитоновскийNoneвнутри JSON и отсутствие значения в колонке — не одно и то же, и__isnullловит только второе. - Фильтр без индекса. Запрос по вложенному ключу на большой таблице делает последовательное сканирование. На демо-данных незаметно, на проде — секунды.
Объявление поля и дефолты
В Django 3.1+ JSONField живёт в django.db.models и работает на PostgreSQL, MySQL, SQLite (с поддержкой JSON1) и Oracle. Отдельный django.contrib.postgres.fields.JSONField устарел и удалён — не тащите его из старых примеров.
from django.db import models
class Order(models.Model):
# словарь по умолчанию: вызываемое, а не литерал
payload = models.JSONField(default=dict)
# если поле может физически отсутствовать в строке
meta = models.JSONField(null=True, blank=True)
Разница между двумя строками важна. У payload в базе всегда лежит хотя бы {}. У meta может быть SQL NULL, и это отдельное состояние, которое надо учитывать в запросах.
Если вы не понимаете, чем JSONnullотличается от SQLNULLв вашей таблице, значит половина ваших фильтров по этому полю однажды вернёт неожиданный результат.
Фильтрация по вложенным ключам
Django поддерживает обращение к ключам и элементам массива через двойное подчёркивание. Это переводится в операторы JSON на стороне БД.
# точное совпадение вложенного ключа
Order.objects.filter(payload__status="paid")
# два уровня вложенности
Order.objects.filter(payload__customer__city="Москва")
# элемент массива по индексу
Order.objects.filter(payload__items__0__sku="ABC-1")
# наличие ключа (PostgreSQL)
Order.objects.filter(payload__has_key="discount")
# любой из ключей присутствует
Order.objects.filter(payload__has_any_keys=["discount", "promo"])
Важная тонкость с числами. Значения внутри JSON на PostgreSQL сравниваются по правилам JSON, и payload__amount__gt=100 ведёт себя корректно, но при сравнении текста и числа результат зависит от того, что реально лежит в поле. Если ключ иногда строка "100", а иногда число 100, фильтр будет молча пропускать половину. Приводите тип на записи, а не надейтесь на БД.
null внутри JSON — три разных запроса
Классическая путаница. Пусть у части строк meta равно SQL NULL, у части внутри лежит {"note": null}, у части ключа note нет вовсе.
# колонка целиком NULL
Order.objects.filter(meta__isnull=True)
# внутри JSON лежит null (значение есть, оно None)
from django.db.models import Value
from django.db.models.fields.json import KeyTextTransform
Order.objects.filter(meta__note=None) # ищет JSON null у ключа note
# ключа note нет вообще (PostgreSQL)
Order.objects.exclude(meta__has_key="note")
Эти три запроса дают три разных набора строк. Если вы ждёте «всё, где заметки нет» одним фильтром — вы не покроете все случаи. Решайте на уровне данных: договоритесь, что «нет заметки» — это отсутствие ключа, и не пишите null внутрь.
Индексы: без них фильтр по JSON — это seq scan
На PostgreSQL есть два рабочих варианта, и они закрывают разные задачи.
| Индекс | Что ускоряет | Чем платите |
|---|---|---|
| GIN на всё поле | has_key, contains, поиск по любому ключу | Крупный индекс, медленнее вставка |
GIN с jsonb_path_ops | Только оператор @> (contains) | Меньше и быстрее, но уже по возможностям |
| B-tree на выражении | Фильтр и сортировка по одному конкретному ключу | Индекс под каждый ключ отдельно |
GIN на всё поле объявляется через indexes в Meta:
from django.contrib.postgres.indexes import GinIndex
class Order(models.Model):
payload = models.JSONField(default=dict)
class Meta:
indexes = [
GinIndex(fields=["payload"], name="order_payload_gin"),
]
Если вы всегда фильтруете по одному ключу (например, payload__status) и хотите ещё и сортировать по нему, точечный B-tree по выражению будет компактнее и быстрее GIN. Django умеет индекс по выражению через models.Index с KeyTextTransform; синтаксис зависит от версии, поэтому сверьтесь с разделом «Indexes» и «JSONField» в документации Django для вашей версии, а после миграции обязательно проверьте EXPLAIN ANALYZE, что планировщик индекс использует.
Обновление вложенных данных без гонок
Типичная ошибка — прочитать объект, изменить словарь в Python, сохранить. Между чтением и записью другой процесс может обновить то же поле, и его изменения потеряются.
# так теряются параллельные изменения
order = Order.objects.get(pk=pk)
order.payload["status"] = "shipped"
order.save(update_fields=["payload"])
Если параллельная запись реальна, оборачивайте чтение и запись в транзакцию с блокировкой строки через select_for_update(), либо обновляйте конкретный ключ на стороне БД. На PostgreSQL точечное обновление ключа делается функцией jsonb_set; вызвать её из ORM можно через Func-выражение. Точную сигнатуру и способ обёртки смотрите в документации PostgreSQL по JSON-функциям — не восстанавливайте её по памяти, порядок аргументов там неочевиден.
Валидация: JSONField не проверяет структуру
Поле хранит любой валидный JSON. Оно не гарантирует, что внутри именно те ключи, которые ждёт ваш код. Если структура важна, валидируйте на входе — в сериализаторе DRF, в форме или через validators с собственной функцией. Не рассчитывайте, что full_clean() сам поймает лишние поля: он проверит, что это JSON, и на этом остановится.
Когда что выбирать
- Фиксированный набор атрибутов, по которым вы фильтруете и сортируете — выносите в обычные колонки. JSONField здесь только замедлит и лишит вас типов.
- Редко читаемый blob (сырой ответ API, лог события) — JSONField без индекса, читается целиком, не фильтруется по вложенному.
- Полуструктура с поиском по нескольким ключам — JSONField плюс GIN-индекс, это его основной сценарий.
Что делать дальше
Перед выкатом в прод прогоните ключевые запросы через EXPLAIN ANALYZE на объёме, близком к боевому, и убедитесь, что вместо Seq Scan стоит Bitmap Index Scan. Договоритесь в команде об одном соглашении: отсутствие значения — это отсутствие ключа, а не null внутри. И зафиксируйте схему JSON хотя бы в комментарии к модели или в отдельной pydantic-модели — через полгода никто не вспомнит, какие ключи там ожидаются.
Инфобез и 152-ФЗ: Аудит политик конфиденциальности, правила сбора cookie.
Спросить за 20 ₽Источники: Django documentation — JSONField, PostgreSQL documentation — JSON Functions and Operators
Частые вопросы
У меня SQLite в разработке и PostgreSQL в проде — будет ли разница?
Почему filter(meta__note=None) не находит строки, где note отсутствует?
А если у меня старая версия Django 3.2 — приёмы сработают?
Фильтр по JSON работает, но медленно даже с GIN-индексом. Что не так?
Можно ли обновить один ключ в JSON, не перезаписывая весь объект?
Нужно ли валидировать структуру данных перед записью в JSONField?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.