JSONField в Django: рабочие приёмы из практики
Как искать по вложенным ключам, обновлять данные без гонок и не платить за это скоростью. Разбор приёмов для JSONField в PostgreSQL с оговорками по индексам и другим СУБД.

Вы завели JSONField, чтобы не плодить таблицы под гибкие настройки, а через месяц запрос по вложенному ключу начал сканировать весь миллион строк. Или два параллельных запроса перезаписали друг другу словарь, и половина данных пропала. JSONField в Django удобен ровно до тех пор, пока вы не начали искать, обновлять и сортировать по его содержимому. Ниже — приёмы, которые снимают эти боли, с честной ценой каждого.
Материал написан для Django 5.x и PostgreSQL 16. Большая часть приёмов завязана именно на PostgreSQL: у него JSONField ложится в тип jsonb с богатым набором операторов. На MySQL и SQLite поведение и набор доступных lookup-ов отличаются — об этом отдельно ниже.
Что вообще умеет lookup по ключам
Базовая задача: найти строки, у которых внутри JSON лежит нужное значение. Django позволяет обращаться к ключам через двойное подчёркивание прямо в filter().
# модель
from django.db import models
class Event(models.Model):
payload = models.JSONField(default=dict)
# поиск по вложенному ключу payload["user"]["role"] == "admin"
Event.objects.filter(payload__user__role="admin")
# ключ существует
Event.objects.filter(payload__has_key="user")
# любой из ключей присутствует
Event.objects.filter(payload__has_any_keys=["user", "system"])
# значение содержится (contains) — проверка на вложенный фрагмент
Event.objects.filter(payload__contains={"user": {"role": "admin"}})
Разница между payload__user__role="admin" и payload__contains тонкая, но важная. Первый достаёт конкретное значение по пути и сравнивает его. Второй проверяет, что весь переданный фрагмент вложен в документ — это оператор @> в PostgreSQL, и именно он умеет опираться на GIN-индекс.
Осторожно с has_key и contains на других СУБД
Лукапы has_key, has_any_keys, has_keys, contains, contained_by в полном объёме работают на PostgreSQL. На SQLite и MySQL часть из них либо не поддерживается, либо ведёт себя иначе — например, contains и contained_by на этих бэкендах официально не поддерживаются. Если пишете код, который должен переезжать между СУБД, сверьтесь с разделом querying JSONField в документации Django для вашей версии, а не полагайтесь на то, что заработало на локальном PostgreSQL.
Индексы: почему поиск по JSON тормозит
По умолчанию к jsonb-полю не приложен ни один индекс, кроме того, что покрывает первичный ключ. Любой filter(payload__...) — это последовательное сканирование таблицы. На тысяче строк вы разницы не заметите, на миллионе запрос уйдёт в секунды.
Есть два разных инструмента, и путать их — типичная ошибка.
GIN-индекс — под contains и has_key
GIN-индекс на всё поле ускоряет операторы вхождения (@>, то есть contains) и проверки существования ключей. Он не помогает с равенством по конкретному пути вида payload__user__role="admin" напрямую.
from django.contrib.postgres.indexes import GinIndex
class Event(models.Model):
payload = models.JSONField(default=dict)
class Meta:
indexes = [
GinIndex(fields=["payload"], name="event_payload_gin"),
]
Есть вариант с классом операторов jsonb_path_ops — он компактнее и быстрее на contains, но не поддерживает has_key. Задаётся через opclasses в GinIndex. Если ваши запросы — только contains, этот вариант выгоднее по размеру индекса.
B-tree по выражению — под равенство и сортировку
Если вы всегда фильтруете по одному и тому же пути, GIN избыточен. Дешевле построить обычный B-tree индекс по выражению, которое достаёт конкретный ключ. В Django это делается через функциональный индекс с F() и KeyTextTransform.
from django.db.models import Index, F
from django.db.models.fields.json import KeyTextTransform
class Event(models.Model):
payload = models.JSONField(default=dict)
class Meta:
indexes = [
Index(
KeyTextTransform("role", KeyTextTransform("user", "payload")),
name="event_user_role_idx",
),
]
Такой индекс покроет и равенство по payload__user__role, и сортировку по нему. После создания любого индекса проверьте, что планировщик его использует: оберните реальный запрос в EXPLAIN ANALYZE и убедитесь, что там Index Scan, а не Seq Scan.
Индекс без проверки планом запроса — это надежда, а не оптимизация. GIN на JSON-поле легко весит больше самой таблицы и при этом не участвует в вашем главном запросе, если тот идёт по пути, а не через contains.
Обновление без гонок
Самая дорогая ошибка с JSONField — обновление «сначала прочитал в Python, поменял словарь, сохранил обратно». Между чтением и сохранением второй процесс успевает записать своё, и его правка теряется.
# так делать опасно при параллельных запросах
event = Event.objects.get(pk=42)
event.payload["views"] = event.payload.get("views", 0) + 1
event.save() # перезапишет весь payload целиком
Первый способ обезопаситься — блокировка строки на время транзакции через select_for_update().
from django.db import transaction
with transaction.atomic():
event = Event.objects.select_for_update().get(pk=42)
event.payload["views"] = event.payload.get("views", 0) + 1
event.save(update_fields=["payload"])
Блокировка надёжна, но сериализует доступ к строке: конкурирующие запросы встают в очередь. Для счётчика с высокой частотой это узкое место.
Второй способ — обновить JSON на стороне СУБД одним запросом, не вытаскивая документ в Python. В PostgreSQL для этого есть функция jsonb_set. В Django её можно вызвать через Func-выражение. Точную сигнатуру функции и её поведение при отсутствии ключа сверьте в документации PostgreSQL по функциям для jsonb — она отличается в нюансах создания недостающего пути.
Если структура позволяет, самый простой путь — вынести часто меняющееся числовое значение из JSON в отдельную колонку и обновлять его атомарно через F(). JSON хорош для гибкой схемы, но плохой контейнер для горячего счётчика.
Дефолт, который стреляет
Классическая ловушка новичка перекочевала и сюда: изменяемый объект как значение по умолчанию.
# НЕПРАВИЛЬНО — один и тот же словарь на все объекты
payload = models.JSONField(default={})
# ПРАВИЛЬНО — передаём вызываемый объект
payload = models.JSONField(default=dict)
В первом варианте Django при загрузке модели ругается системной проверкой (fields.E010), потому что мутабельный дефолт разделяется между экземплярами. Передавайте dict или list без скобок — Django вызовет их для каждого нового объекта.
Сравнение приёмов чтения и записи
| Задача | Приём | Цена |
|---|---|---|
| Проверка вложенного фрагмента | contains + GIN-индекс | Индекс может весить больше таблицы |
| Равенство по фиксированному пути | B-tree по KeyTextTransform | Один индекс на один путь |
| Инкремент счётчика редко | select_for_update | Сериализует доступ к строке |
| Инкремент счётчика часто | Отдельная колонка + F() | Теряется гибкость схемы |
| Точечная правка ключа в БД | jsonb_set через Func | Привязка к PostgreSQL |
Когда JSONField — не ваш выбор
JSONField выигрывает, когда структура заранее неизвестна или редко участвует в фильтрах: настройки, снапшоты, ответы внешних API «как есть». Он проигрывает, когда по полям внутри нужно постоянно искать, соединять и агрегировать — реляционная нормализация тут и быстрее, и понятнее в поддержке. Если вы уже навесили три GIN-индекса и всё равно пишете сложные условия по вложенным ключам, это сигнал, что часть данных пора вынести в обычные колонки или отдельную таблицу.
Что делать дальше
Возьмите свой самый частый запрос по JSON-полю и прогоните его через EXPLAIN ANALYZE на данных, близких к продовым по объёму — на пустой таблице план обманчив. Дальше решайте по факту: Seq Scan на большой таблице означает, что нужен индекс под конкретный тип запроса, а не «какой-нибудь». Если приложение должно работать не только на PostgreSQL, заведите тест, который гоняет запросы на целевой СУБД — половина удобных lookup-ов там просто недоступна.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Django documentation — Querying JSONField, PostgreSQL documentation — JSON Functions and Operators
Частые вопросы
У меня SQLite в разработке и PostgreSQL в проде — почему contains не работает локально?
Почему GIN-индекс не ускорил мой запрос по payload__user__role?
А если два запроса одновременно обновляют один JSON — данные точно потеряются?
Django ругается fields.E010 на JSONField — что не так?
Стоит ли класть jsonb_path_ops вместо стандартного GIN?
Можно ли сортировать выдачу по значению внутри JSON?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.