JSONField в Django: когда брать, а когда делать поля
JSONField кажется удобным для гибких данных, но за гибкость платишь индексами, валидацией и запросами. Разбираем, когда он оправдан, а когда лучше нормальные колонки или отдельная таблица.

У вас модель заказа, и в неё регулярно прилетают новые атрибуты: сегодня скидочный купон, завтра параметры доставки, послезавтра флаг из маркетинговой акции. Соблазн очевиден — сложить всё в один JSONField и не трогать миграции при каждом изменении. Через полгода этот же JSONField тормозит фильтрацию, ломает аналитику и не даёт поставить NOT NULL на поле, которое на самом деле обязательное. Разберём, где JSONField действительно выигрывает, а где вы платите за него позже.
Речь про Django 4.2+ и 5.x на PostgreSQL — именно там JSONField работает в полную силу. На SQLite и MySQL возможности запросов урезаны, об этом ниже.
Что вообще делает JSONField
С Django 3.1 класс django.db.models.JSONField единый для всех бэкендов. На PostgreSQL под ним лежит колонка типа jsonb — бинарное представление с поддержкой индексов и операторов пути. На SQLite и MySQL данные хранятся как текст, а запросы эмулируются, что сразу ограничивает то, что можно делать эффективно.
Ключевое отличие jsonb от простого текстового поля: по нему можно фильтровать, индексировать и обращаться к вложенным ключам прямо в ORM.
# Django 5.x, PostgreSQL 16
from django.db import models
class Order(models.Model):
number = models.CharField(max_length=32)
# гибкие атрибуты, набор которых меняется
attributes = models.JSONField(default=dict, blank=True)
# Фильтр по вложенному ключу
Order.objects.filter(attributes__delivery__type="pickup")
# Проверка наличия ключа
Order.objects.filter(attributes__has_key="coupon")Синтаксис attributes__delivery__type транслируется в оператор пути jsonb. Работает это только там, где бэкенд умеет такие запросы — на PostgreSQL полноценно, на SQLite ограниченно.
Три сценария и цена каждого
Одно поле JSONField закрывает несколько разных потребностей, и путать их — главная ошибка. Разберём, что вы выигрываете и чем платите.
Сценарий 1: разреженные необязательные атрибуты
Данные, которые есть у части записей и по которым вы редко фильтруете: технические метки, снимок ответа от внешнего API, служебная телеметрия. Здесь JSONField на месте — заводить под каждый ключ отдельную nullable-колонку было бы расточительно.
Цена: по таким полям сложно строить агрегаты, а валидация структуры целиком на вашей стороне.
Сценарий 2: полуструктурированные данные с фильтрацией
Атрибуты, по которым вы регулярно ищете: тип доставки, статус, регион внутри JSON. Технически JSONField справится, но без индекса каждый фильтр — это полное сканирование таблицы с распаковкой JSON на каждой строке.
Цена: нужен GIN-индекс, а он больше и дороже на запись, чем обычный B-tree по колонке.
# Миграция: GIN-индекс на всё поле jsonb
# Django 5.x
from django.contrib.postgres.indexes import GinIndex
class Order(models.Model):
attributes = models.JSONField(default=dict, blank=True)
class Meta:
indexes = [
GinIndex(fields=["attributes"], name="order_attrs_gin"),
]GIN-индекс на всё поле ускоряет проверки вида has_key и содержания (contains), но не всегда помогает точечному пути attributes__delivery__type=.... Для конкретного пути на PostgreSQL иногда эффективнее выражательный B-tree индекс по извлечённому значению. Проверяйте через EXPLAIN ANALYZE, использует ли планировщик индекс на вашем запросе.
Сценарий 3: данные, которые вы притворяетесь гибкими, а на деле они таблица
Самый частый промах. В JSONField лежит список позиций заказа, каждая со своей ценой и количеством, и вы уже считаете по ним суммы, ищете по товару, строите отчёты. Это не гибкая структура — это отдельная таблица, которую загнали в JSON.
Если по данным внутри JSON вы делаете агрегаты, JOIN-подобную логику или отчёты — это признак того, что данные хотят быть строками таблицы, а не значением одного поля.
Цена: аналитика превращается в распаковку JSON на каждой строке, целостность (тот же товар существует?) не проверить внешним ключом, а миграция на нормальную схему потом стоит дорого.
Сравнение подходов
| Критерий | JSONField | Отдельные колонки | Связанная таблица |
|---|---|---|---|
| Набор полей меняется часто | Да, без миграций | Каждое поле — миграция | Схема фиксирована |
| Фильтрация по значению | Медленно без индекса | Быстро, B-tree | Быстро, B-tree + FK |
| Агрегаты и отчёты | Больно, распаковка на строке | Обычный SQL | Обычный SQL, GROUP BY |
| Целостность (NOT NULL, FK) | Только своей валидацией | На уровне БД | На уровне БД |
| Связь один-ко-многим | Массив в JSON | Не подходит | Естественно |
| Кросс-бэкенд (SQLite/MySQL) | Запросы урезаны | Работает везде | Работает везде |
Когда что выбирать
- JSONField — когда набор ключей заранее неизвестен или меняется от записи к записи, а фильтрация редкая. Настройки, ответы внешних API, метаданные.
- Отдельные колонки — когда поле присутствует почти всегда, по нему фильтруют или сортируют, и оно должно быть обязательным на уровне БД. Не бойтесь миграций: одна миграция дешевле года медленных запросов.
- Связанная таблица — когда внутри JSON лежит список однотипных сущностей, по которым вы считаете, ищете или проверяете целостность. Позиции заказа, история статусов, теги с атрибутами.
Практичный компромисс: держите горячие, часто фильтруемые ключи как настоящие колонки, а редкий хвост — в JSONField. Так вы получаете индексы там, где они нужны, и гибкость там, где она реальна.
Что ломается на практике
Мутация без пересохранения. Django не отслеживает изменения внутри dict. Если вы делаете order.attributes["coupon"] = "X" и вызываете save(), всё в порядке. Но если меняете вложенный словарь, полученный отдельной ссылкой, и забываете сохранить сам объект — изменение потеряется. Всегда присваивайте изменённую структуру обратно полю и вызывайте save().
Гонки при частичном обновлении. Два процесса читают JSON, каждый добавляет свой ключ, оба сохраняют — второй перезатрёт первого целиком, потому что пишется всё поле. Для точечного обновления используйте атомарные операции на уровне БД или select_for_update() в транзакции.
Валидация. JSONField не проверяет структуру. Пустой словарь, строка вместо числа, отсутствие обязательного ключа — БД примет всё. Валидируйте на входе через сериализатор DRF, Pydantic или clean() модели, иначе через полгода в поле будет зоопарк форматов.
SQLite в тестах, PostgreSQL в проде. Классическая ловушка: тесты гоняются на SQLite, где часть операторов пути ведёт себя иначе или не поддерживается. Запрос, зелёный в тестах, падает в проде. Тестируйте на том же бэкенде, что в бою.
Что делать дальше
Прогоните ваши текущие JSONField по трём сценариям выше. Если по какому-то ключу фильтруете чаще пары раз в день — снимите EXPLAIN ANALYZE с этого запроса и посмотрите на Seq Scan: если он есть на большой таблице, вам нужен индекс или вынос ключа в колонку. Если внутри JSON лежит список сущностей, по которым считаете суммы — планируйте выделение связанной таблицы, пока данных мало и миграция дешёвая. Индексы по путям jsonb сверяйте с разделом про JSONField в документации Django вашей версии — синтаксис lookups там актуальный.
Конспект и шпаргалка: Лекция, глава учебника или запись — в конспект, карточки и таблицы
Спросить за 12 ₽Источники: Django documentation — JSONField, PostgreSQL documentation — JSON Types
Частые вопросы
У меня MySQL, а не PostgreSQL — JSONField будет работать?
Фильтр по JSONField работает, но запрос медленный — что делать?
Изменил значение внутри JSONField, вызвал save(), а в базе старое — почему?
Можно ли поставить NOT NULL на ключ внутри JSONField?
Как безопасно обновить один ключ, если поле пишут несколько процессов?
Стоит ли переносить существующий JSONField в нормальные колонки?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.