JSONB — самый популярный тип в современном Postgres и одновременно самый частый источник архитектурных проблем. Он позволяет хранить произвольную JSON-структуру внутри одной ячейки и работать с ней почти так же эффективно, как со столбцами таблицы. Это решает реальную проблему: иногда у объектов много полей с разной структурой, и описать каждую в виде колонки нереалистично.
Но платформа за эту гибкость собирает дань: типизация частичная, индексация дорогая, отладка сложнее. Этот урок — про то, как пользоваться JSONB осмысленно.
JSON vs JSONB — выбирай всегда JSONB
В Postgres есть два типа: JSON и JSONB. На первый взгляд они одинаковые, но различие фундаментальное:
JSONхранит текст. Каждый раз при чтении или операторе Postgres парсит его заново.JSONBхранит разобранный бинарный формат. Парсинг один раз — приINSERT, а потом операторы работают по дереву напрямую.
JSONB теряет порядок ключей и схлопывает дубликаты ключей (при повторе побеждает последний). JSON сохраняет всё «как было». На практике порядок ключей нужен лишь в редких случаях — например, при подписи payload’а для внешнего API.
JSONB. Дальше в курсе мы будем работать только с ним.
JSON — сырой текст, парсится на каждый запрос. JSONB — заранее разобранное дерево, читается быстро.
Наша JSONB-вселенная
В колонке customers.profile лежат настройки клиента, в products.attrs — характеристики товара. Посмотрим, что есть:
Глянем на содержимое profile у первых клиентов:
Атрибуты ноутбуков — там самые «жирные» JSONB:
Операторы доступа: → и →>
Два главных оператора:
->возвращает значение как JSONB.->>возвращает значение как TEXT.
Это самое путающее место в JSONB. Разница в одном символе >, но семантика разная: -> оставляет тип JSONB (можно идти дальше внутрь), ->> извлекает «наружу» как обычную строку.
Сравни возвращаемые типы:
Заметь — as_jsonb показывается в кавычках ("email"), потому что это всё ещё JSONB-строковое значение. as_text — без кавычек, это уже обычный TEXT.
Правило: ->> — если нужно сравнивать со строкой или показывать пользователю. -> — если нужно «провалиться» глубже внутрь объекта.
Найди клиентов, которые подписаны на рассылку:
Обрати внимание на ::boolean — это явный cast. JSONB не делает implicit cast: если ты сравнишь profile->>'newsletter' = TRUE без cast, Postgres пожалуется на несовпадение типов.
Доступ по индексу — массивы внутри JSONB
-> и ->> работают и с массивами — но в качестве ключа передаётся целое число (индекс с нуля, как в JS):
Если бы у нас был JSONB-массив, доступ был бы такой:
Обрати внимание: внутри JSONB-массивов индексация с нуля и поддерживаются отрицательные индексы — это резко отличается от родных TEXT[], где индексация с единицы.
Глубокий путь: #> и #>>
Когда нужно нырнуть на несколько уровней, цеплять -> -> -> неудобно. Для этого есть path-операторы:
#>принимает массив ключей, возвращает JSONB.#>>принимает массив ключей, возвращает TEXT.
Достанем количество USB-C портов у ноутбуков — это вложенный путь attrs.ports.usb_c:
Эта запись эквивалентна attrs -> 'ports' -> 'usb_c', но читается лучше, особенно когда путь длинный.
Проверка существования ключа: оператор ?
Иногда нужно знать «есть ли вообще такой ключ» — не «равно ли значение чему-то», а именно есть/нет.
У каких клиентов в profile проставлен флаг b2b:
Оператор ? проверяет наличие ключа на верхнем уровне JSONB. Это не то же самое, что profile->'b2b' IS NOT NULL — на верхнем уровне совпадает, но семантика немного разная: ? ищет именно ключ, а -> пытается достать значение (и не отличит «ключа нет» от «ключ есть, но значение JSON null»).
Агрегация и группировка по JSONB-полям
JSONB-поля можно использовать в GROUP BY и агрегатах ровно как обычные колонки — нужно только не забывать про cast при сравнении.
Сколько продуктов в каждом цвете:
Средний вес ноутбуков — числовое значение из JSONB:
В обоих запросах extracted-значение явно приводится к нужному типу: для числовой агрегации это критично — иначе Postgres попытается сложить строки.
Когда JSONB — это хорошо, а когда — побег от схемы
Хорошие сценарии:
- Сильно вариативные атрибуты товара (разные продукты — разные характеристики). Делать колонку под каждое поле = разреженная таблица из 200 колонок.
- Снимки внешних данных (webhook payload, ответ API), где структура продиктована поставщиком и может меняться.
- Конфигурации пользователя, где набор настроек большой и редко используется в фильтрах.
Плохие сценарии (то, что должно быть колонками):
- Поля, по которым часто фильтруют или сортируют (e-mail, дата создания, статус). Колонка с B-tree индексом всегда быстрее JSONB-фильтра.
- Поля с FOREIGN KEY. JSONB не умеет ссылочную целостность.
- Поля с CHECK constraint на конкретное значение. Тоже сложнее, чем в обычной колонке.
- Когда в JSONB лежит «таблица под видом объекта» —
{"1": {...}, "2": {...}}, где ключи это id. Этов чистом виде; почти всегда лучше нормальная таблица.EAV-антипаттерн
Чек-лист
- Используй
JSONB, неJSON.JSON— только когда нужна побайтовая идентичность. ->возвращает JSONB (можно идти глубже),->>возвращает TEXT (готово к сравнению со строкой).#>и#>>ходят по пути из массива ключей — удобнее, чем длинная цепочка->.- Индексация массивов внутри JSONB — с нуля, в отличие от
TEXT[](с единицы). ?проверяет наличие ключа на верхнем уровне.- При сравнении JSONB-значения с числом или булем — нужен явный cast (
::int,::boolean). - JSONB оправдан для вариативных, редко-фильтруемых данных. Поля, по которым фильтруют каждый запрос, должны быть обычными колонками.