JSONB в PostgreSQL: операторы, типы и запросы к свойствам событий
Как читать JSONB в аналитике: операторы ->, ->>, @> и ?, приведение типов, три вида пропущенного значения, разворачивание массивов и индексы под реальные запросы.
Содержание статьи
Продуктовые события почти всегда приезжают с полем properties типа JSONB: тариф, экран, промокод, размер скидки, флаги эксперимента. Разработчику это удобно — новый параметр не требует миграции. Аналитику достаётся обратная сторона: скидка в одном релизе приходит числом, в другом строкой, у части событий ключа нет вовсе, а сумма в отчёте не сходится с биллингом на пару процентов. Разберём операторы JSONB и те места, где аналитический запрос по гибкой схеме молча возвращает неправильное число.
Операторы, которые нужны в 90% запросов
Базовых операторов немного, и различие между ними принципиальное. Стрелка -> возвращает JSON-значение, то есть снова jsonb. Двойная стрелка ->> извлекает то же значение как текст. Для вложенных путей есть #> и #>> — они работают так же, но принимают массив ключей.
Разница между -> и ->> не формальная. Первый оставляет значение в JSON, поэтому строка вернётся вместе с кавычками, а сравнение с обычным текстом не сработает. Второй отдаёт готовый text, который можно сравнивать, группировать и приводить к нужному типу. В аналитических запросах почти всегда нужен ->>, а -> пригодится, когда вы идёте глубже по структуре или проверяете тип значения.
Отдельно стоят операторы проверки. ? отвечает на вопрос «есть ли такой ключ», а @> — «содержит ли этот JSON вот такой фрагмент». Второй особенно полезен для флагов эксперимента: одним условием можно проверить сразу пару ключ-значение, и именно этот оператор умеет опираться на индекс. Начиная с PostgreSQL 12 к ним добавился язык путей JSONPath с операторами @? и @@ — он удобнее, когда условие уходит в глубину вложенной структуры.
| Оператор | Что делает | Пример | Результат |
|---|---|---|---|
| -> | значение по ключу как jsonb | properties->'plan' | "pro" (в кавычках, это JSON) |
| ->> | значение по ключу как text | properties->>'plan' | pro |
| #>> | значение по пути как text | properties#>>'{payment,method}' | card |
| ? | есть ли ключ | properties ? 'discount' | true / false |
| @> | содержит ли фрагмент | properties @> '{"ab_group":"test"}' | true / false |
| jsonb_typeof | тип значения | jsonb_typeof(properties->'discount') | number / string / null |
Всё, что извлечено двойной стрелкой, — это текст
Оператор ->> всегда возвращает text, независимо от того, что лежало в JSON. Пока значение просто показывают, это незаметно. Проблема начинается при сортировке и агрегации: как текст «9» больше, чем «100», а sum по такому полю вообще не выполнится без приведения.
Приведение решает задачу, но приносит новый риск: если хотя бы одна строка содержит не число, весь запрос упадёт с ошибкой конвертации. Причём упасть он может через месяц после написания — когда в JSON приедет пустая строка или значение "none" из нового релиза.
Надёжный вариант — приводить тип только там, где вы уверены в содержимом, и проверять это через jsonb_typeof. Такой запрос не падает и заодно показывает, сколько событий пришло с неожиданным типом: это и есть метрика качества трекинга.
SELECT
properties->>'plan' AS plan,
count(*) AS purchases,
sum(
CASE WHEN jsonb_typeof(properties->'discount') = 'number'
THEN (properties->>'discount')::numeric
END
) AS discount_total,
count(*) FILTER (WHERE jsonb_typeof(properties->'discount') = 'string') AS discount_as_string,
count(*) FILTER (WHERE NOT properties ? 'discount') AS discount_missing
FROM events
WHERE event_name = 'purchase'
AND occurred_at >= date '2026-07-01'
GROUP BY 1
ORDER BY purchases DESC;Три разных способа не иметь значения
В JSONB «нет значения» бывает трёх видов, и они ведут себя по-разному. Ключа может не быть вообще. Ключ может присутствовать со значением JSON null. И значение может быть пустой строкой — формально заполнено, фактически нет.
Ловушка в том, что properties->>'discount' для первых двух случаев вернёт одинаковый SQL NULL, а вот properties ? 'discount' для JSON null вернёт true: ключ-то есть. Если различие важно для интерпретации — «параметр не передали» против «передали пустым» — проверять нужно именно через ? и jsonb_typeof, а не через сравнение извлечённого текста с NULL.
На практике это различие часто и объясняет расхождение метрик. «Скидки нет» и «скидку не залогировали» — разные вещи: первое можно считать нулём, второе честнее показать отдельной строкой отчёта.
| Ситуация | properties ? 'discount' | properties->>'discount' | jsonb_typeof |
|---|---|---|---|
| ключа нет | false | NULL | NULL |
| ключ есть, значение null | true | NULL | null |
| ключ есть, пустая строка | true | '' (пустая строка) | string |
| ключ есть, число 0 | true | '0' | number |
Массивы размножают строки — и суммы
Когда в свойствах лежит массив (список товаров в заказе, набор применённых промокодов), возникает соблазн развернуть его через jsonb_array_elements и посчитать по элементам. Функция возвращает набор строк, поэтому одно событие превращается в несколько — и любая агрегация по полям самого события после этого удваивается.
Правило простое: решите заранее, на каком зерне вы отвечаете на вопрос. «Сколько товаров продали» — зерно элемента массива, разворачивать нужно. «Сколько было покупок» — зерно события, и здесь достаточно jsonb_array_length без разворачивания.
Если нужны оба числа в одном отчёте, считайте их в разных слоях и соединяйте по ключу события, а не смешивайте в одном SELECT после разворачивания.
-- зерно: событие. Позиции считаем длиной массива
SELECT count(*) AS purchases,
sum(jsonb_array_length(properties->'items')) AS items_total
FROM events
WHERE event_name = 'purchase'
AND jsonb_typeof(properties->'items') = 'array';
-- зерно: позиция заказа. Одно событие даёт несколько строк
SELECT item->>'sku' AS sku,
count(*) AS times_bought,
sum((item->>'price')::numeric) AS revenue
FROM events e
CROSS JOIN LATERAL jsonb_array_elements(e.properties->'items') AS item
WHERE e.event_name = 'purchase'
GROUP BY 1
ORDER BY revenue DESC;Как выглядит расхождение с биллингом
Типичная история: продуктовая аналитика показывает средний размер скидки 12%, финансы видят 14,8%. Обе стороны уверены в своих цифрах, обсуждение уходит в спор о методологии. На деле разница почти всегда собирается из мелочей внутри JSON, и её можно разложить по слагаемым.
Начните с покрытия: какая доля событий вообще содержит нужный ключ. Затем посмотрите на типы: сколько значений пришло строкой вместо числа и потерялось при безопасном приведении. Потом на нули и null: считаются ли они как «скидки не было» или выпадают из знаменателя. И только после этого сравнивайте формулы.
На графике ниже — условный пример такого разложения. Полезно строить его один раз и оставлять в дашборде качества: тогда следующее расхождение объясняется за минуту, а не за встречу на час.
Данные иллюстративные. Смысл в порядке проверки: покрытие ключа, затем типы, затем трактовка нулей — и только потом формула метрики.
Индексы: какой запрос вы собираетесь ускорять
JSONB индексируется, но универсального индекса под все запросы не существует. GIN-индекс по всему полю поддерживает проверки существования и вхождения — ?, @> и операторы JSONPath. Это правильный выбор, когда фильтры разнообразны и ключи заранее неизвестны.
Если запросы всегда идут по одному конкретному ключу, дешевле обычный B-tree индекс по выражению: он меньше, быстрее обновляется и работает с равенством и диапазонами. Классический пример — фильтр по тарифу или по группе A/B-теста в каждом отчёте.
У GIN есть облегчённый вариант jsonb_path_ops: он поддерживает только проверку вхождения @>, но занимает заметно меньше места. Выбор между ними — это выбор между гибкостью и стоимостью, и делать его стоит по реальному списку запросов, а не заранее.
-- разнообразные фильтры по разным ключам
CREATE INDEX events_props_gin ON events USING gin (properties);
-- только проверки вхождения, зато компактнее
CREATE INDEX events_props_path ON events USING gin (properties jsonb_path_ops);
-- один часто используемый ключ
CREATE INDEX events_plan_idx ON events ((properties->>'plan'));
-- проверяем, что план изменился
EXPLAIN ANALYZE
SELECT count(*) FROM events
WHERE properties @> '{"ab_group": "test"}';Контракт события важнее удобных операторов
Гибкая схема хороша ровно до того момента, пока по ней не считают деньги. Как только параметр из JSON попадает в регулярный отчёт, у него появляются те же требования, что и у обычной колонки: известный тип, известный набор допустимых значений, владелец и правило на случай отсутствия.
Практический порядок такой. Раз в период собирайте каталог ключей: какие вообще встречаются, в каких событиях, с какими типами и долей заполнения. Ключи, которые попали в KPI, выносите в типизированные колонки витрины — это снимает и приведение типов, и вопрос индексов. В JSONB оставляйте то, что действительно меняется часто: экспериментальные атрибуты, отладочный контекст, редкие параметры.
SELECT
key,
count(*) AS events,
round(100.0 * count(*) / sum(count(*)) OVER (), 1) AS pct_of_rows,
count(*) FILTER (WHERE jsonb_typeof(value) = 'string') AS as_string,
count(*) FILTER (WHERE jsonb_typeof(value) = 'number') AS as_number,
count(*) FILTER (WHERE jsonb_typeof(value) = 'null') AS as_null
FROM events e
CROSS JOIN LATERAL jsonb_each(e.properties) AS props(key, value)
WHERE e.event_name = 'purchase'
AND e.occurred_at >= date '2026-07-01'
GROUP BY key
ORDER BY events DESC;Чеклист перед расчётом по JSONB
Свойства событий — самая частая причина расхождений между продуктовой аналитикой и биллингом. Проверка занимает несколько минут и обычно окупается сразу.
- Для каждого используемого ключа известен ожидаемый тип, и он проверен через
jsonb_typeof? - Отсутствие ключа, JSON null и пустая строка разделены, а не свалены в один NULL?
- Приведение типов защищено от неожиданных значений, чтобы отчёт не падал после релиза?
- При работе с массивами зерно результата выбрано осознанно, и суммы не удвоились после разворачивания?
- Индекс соответствует реальным фильтрам, а не добавлен «на всякий случай»?
- Ключи, которые попали в KPI, вынесены в типизированные колонки витрины?
Материалы по теме

LATERAL JOIN в PostgreSQL: последняя запись и top-N на сущность
Как работает LATERAL JOIN: последний заказ пользователя, несколько последних событий на клиента, разница между LEFT и CROSS, нужные индексы и сравнение с оконными функциями.

LIKE и ILIKE в SQL: поиск по строкам, регистр и индексы
Как искать по тексту в SQL: шаблоны LIKE и ILIKE, экранирование пользовательского ввода, работа с регистром и момент, когда запрос перестаёт использовать индекс.
Операторы SQL: шпаргалка для аналитика с примерами применения
Большая шпаргалка по SQL-операторам: фильтрация, сравнение, агрегаты, JOIN, окна, строки, даты и JSONB с маршрутами для практики.