Postgres JSONB vs JSON: когда что выбирать на практике
JSON-типы в Postgres — это история про «выбирай между удобством сейчас и проблемами потом». Я несколько раз ловила и одно, и второе, поэтому хочу разложить, чем JSON и JSONB отличаются на самом деле и когда какой брать.
Короткая разница
В Postgres два типа: json и jsonb. Звучат похоже, но устроены принципиально по-разному.
- json хранит исходный текст почти как есть. Парсер только проверяет, что это валидный JSON. Пробелы, порядок ключей, дубли ключей — всё сохраняется.
- jsonb — бинарный разобранный формат. Пробелов нет, порядок ключей не гарантирован, дубли отбрасываются (остаётся последний). Зато внутрь можно индексироваться, добираться до полей быстро, делать оператор
@>.
Это и есть главный вопрос на собеседованиях. На практике 95% случаев — это jsonb. json я применяла буквально пару раз, и то с конкретной целью.
Где выигрывает jsonb
Скорость доступа к полям
Если тебе нужно достать одно поле, jsonb уже разобран — это просто чтение по offset. На json каждый раз парсится заново.
SELECT payload->>'user_id' FROM events WHERE id = $1;На json эта операция парсит всю строку. На jsonb — берёт поле напрямую. Разница ощутима на тысячах вызовов.
Индексы
На jsonb работают GIN-индексы, и это даёт быстрые запросы вида «у объекта есть такой ключ» и «объект содержит такое поле».
CREATE INDEX events_payload_gin_idx
ON events USING gin(payload jsonb_path_ops);
SELECT * FROM events WHERE payload @> '{"type":"signup"}';На обычном json такой индекс не построишь: для GIN нужен бинарный формат. Поэтому если ты планируешь поиски по содержимому — это автоматически jsonb.
Богатый набор операторов и функций
На jsonb работает jsonb_set, jsonb_path_query, jsonb_object_agg, JSON_TABLE в Postgres 17. Большая часть инструментов в стандартной библиотеке делалась под jsonb. На json половины этих функций нет или они работают через cast.
Где выигрывает json
Это короткий список, но он есть.
- Хранение «как есть». Если важен исходный порядок ключей и форматирование — например, ты сохраняешь подписанный JWT, для которого подпись считается по конкретной строке — нужно использовать
jsonили простоtext. jsonb перепакует тебе данные. - Архив, лог запросов, аудит. Когда ты складываешь сырой запрос или ответ, и поверх ничего не ищешь, json чуть дешевле по записи: не надо разбирать структуру.
- Очень большие документы, к которым ты не обращаешься. На больших объектах разбор в jsonb — заметная нагрузка на запись.
Во всех остальных случаях — jsonb.
Реальные ловушки jsonb
1. TOAST и большие объекты
jsonb-объект больше 2 КБ Postgres утаскивает в TOAST — отдельное хранилище со сжатием. На больших документах сжатие даёт хорошее место, но любой доступ к полю становится дороже: сначала разжать, потом разобрать.
Если у тебя есть «горячие» поля, по которым ты постоянно фильтруешь — выноси их в отдельные колонки. Это и индексировать проще, и не упираешься в TOAST.
2. Уникальные индексы по полю
Хочется уникальность по payload->>'external_id'. Делается так:
CREATE UNIQUE INDEX events_external_id_uidx
ON events ((payload->>'external_id'))
WHERE payload ? 'external_id';Скобочки вокруг выражения обязательны: уникальный индекс по выражению. И WHERE добавляю, чтобы записи без поля не конфликтовали.
3. jsonb_set с null
Очень классическая ошибка. jsonb_set(value, path, null) ставит null. А jsonb_set(null, ...) возвращает null. Если в строке payload null, ты не сможешь обновить вложенное поле — нужно coalesce(payload, '{}'::jsonb).
UPDATE events
SET payload = jsonb_set(coalesce(payload, '{}'::jsonb), '{status}', '"done"')
WHERE id = $1;4. Партиальные обновления и ?, @>, ?|
Если массово фильтруешь «у объекта есть такое поле и оно равно X», смотри в сторону @>. Он ложится на GIN с jsonb_path_ops и обычно даёт лучший план, чем сложные комбинации ? и стрелочек.
SELECT * FROM events WHERE payload @> '{"status":"done","channel":"email"}';Полу-структурированные данные: jsonb vs отдельная таблица
Иногда хочется свалить всё в jsonb и не возиться со схемой. Это работает первые полгода. Потом накапливаются проблемы:
- Типы внутри jsonb — это
text,numeric,boolean. Дата и timestamp — строки. Всякий раз cast. - Пустые поля и null поля — разные сущности. Половина запросов ловит баги именно тут.
- Миграции данных в jsonb писать сложнее, чем DDL по обычной колонке.
Простое правило: если по полю когда-нибудь придётся писать миграцию данных или агрегацию — оно должно жить в обычной колонке, а не в jsonb. jsonb хорош для расширяемых атрибутов, которых много, но они read-mostly.
Производительность: что я мерила у себя
На таблице 50 млн строк, payload — около 1.5 КБ jsonb с 12 ключами:
- Доступ к полю по id: jsonb — субмилли, json — 0.3 мс на разбор + чтение.
- Поиск с условием по полю: с GIN-индексом по jsonb — десятки мс на 100 совпадениях; с json без индекса — секунды.
- Запись: jsonb с 1.5 КБ — на 5–10% медленнее, чем такой же текст в json. Платим за разбор.
То есть для read-heavy сценариев jsonb выигрывает катастрофически за счёт индексов. Для write-heavy без поиска — json чуть бодрее, но обычно не настолько, чтобы ради этого отказываться от индексов.
Как я решаю на старте проекта
Когда проектирую таблицу с гибким полем, я задаю себе два вопроса:
- Будет ли поиск по содержимому? Если да — jsonb с GIN. Если нет — можно json или text.
- Будут ли отдельные поля «вылазить» в горячий путь? Если да — выношу их в обычные колонки. jsonb остаётся для всего остального.
Этого хватает. На моём опыте, если ответы на оба вопроса нечёткие, лучше брать jsonb: расширяться будет проще.
Краткая шпаргалка
- По умолчанию — jsonb.
- Хранение «как есть» (подпись, аудит, архив без поиска) — json или
text. - Большие документы со сжатием — jsonb попадёт в TOAST, об этом помни.
- GIN-индекс с
jsonb_path_opsдля оператора@>. - Уникальность по полю — индекс по выражению с скобками.
- Горячие поля выноси в отдельные колонки заранее.
jsonb — отличный инструмент, но он не заменяет нормализацию. Это удобный карман для атрибутов, которых много и они «полу-структурированы». Всё остальное по-прежнему живёт в обычных колонках, и нет ничего плохого, чтобы половина таблицы была привычным реляционным полем, а вторая половина — гибкими данными в jsonb.