Все материалы
Продуктовая аналитикапрактикумсредний

JSONB в PostgreSQL: операторы, типы и запросы к свойствам событий

Как читать JSONB в аналитике: операторы ->, ->>, @> и ?, приведение типов, три вида пропущенного значения, разворачивание массивов и индексы под реальные запросы.

КПКейсПрактика6 августа 2026 г.14 мин

Продуктовые события почти всегда приезжают с полем properties типа JSONB: тариф, экран, промокод, размер скидки, флаги эксперимента. Разработчику это удобно — новый параметр не требует миграции. Аналитику достаётся обратная сторона: скидка в одном релизе приходит числом, в другом строкой, у части событий ключа нет вовсе, а сумма в отчёте не сходится с биллингом на пару процентов. Разберём операторы JSONB и те места, где аналитический запрос по гибкой схеме молча возвращает неправильное число.

Операторы, которые нужны в 90% запросов

Базовых операторов немного, и различие между ними принципиальное. Стрелка -> возвращает JSON-значение, то есть снова jsonb. Двойная стрелка ->> извлекает то же значение как текст. Для вложенных путей есть #> и #>> — они работают так же, но принимают массив ключей.

Разница между -> и ->> не формальная. Первый оставляет значение в JSON, поэтому строка вернётся вместе с кавычками, а сравнение с обычным текстом не сработает. Второй отдаёт готовый text, который можно сравнивать, группировать и приводить к нужному типу. В аналитических запросах почти всегда нужен ->>, а -> пригодится, когда вы идёте глубже по структуре или проверяете тип значения.

Отдельно стоят операторы проверки. ? отвечает на вопрос «есть ли такой ключ», а @> — «содержит ли этот JSON вот такой фрагмент». Второй особенно полезен для флагов эксперимента: одним условием можно проверить сразу пару ключ-значение, и именно этот оператор умеет опираться на индекс. Начиная с PostgreSQL 12 к ним добавился язык путей JSONPath с операторами @? и @@ — он удобнее, когда условие уходит в глубину вложенной структуры.

Операторы JSONB в PostgreSQL
ОператорЧто делаетПримерРезультат
->значение по ключу как jsonbproperties->'plan'"pro" (в кавычках, это JSON)
->>значение по ключу как textproperties->>'plan'pro
#>>значение по пути как textproperties#>>'{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
ключа нетfalseNULLNULL
ключ есть, значение nulltrueNULLnull
ключ есть, пустая строкаtrue'' (пустая строка)string
ключ есть, число 0true'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, вынесены в типизированные колонки витрины?
Продолжить чтение
Вся библиотека