JSON и JSONB в SQL: как достать значение и искать по свойствам событий
Как читать 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. Такой запрос не падает и заодно показывает, сколько событий пришло с неожиданным типом: это и есть метрика качества трекинга.
В учебной базе симулятора JSON-столбцов нет, поэтому запросы статьи несут данные с собой — с пометкой «учебная вставка» — и выполняются в PostgreSQL; запись для DuckDB и песочницы — в «Частых вопросах». В примере ниже семь покупок: у тарифа pro скидки складываются в 20, а ещё одна, пришедшая строкой "10", в сумму не попала и посчитана отдельно.
-- учебная вставка: семь покупок с разным содержимым discount
WITH events (event_id, properties) AS (
VALUES
(1, '{"plan":"pro","discount":15}'::jsonb),
(2, '{"plan":"pro","discount":"10"}'::jsonb),
(3, '{"plan":"pro","discount":5}'::jsonb),
(4, '{"plan":"basic","discount":null}'::jsonb),
(5, '{"plan":"basic"}'::jsonb),
(6, '{"plan":"basic","discount":""}'::jsonb),
(7, '{"plan":"team","discount":0}'::jsonb)
)
SELECT
properties->>'plan' AS plan,
count(*) AS purchases,
sum(
CASE WHEN jsonb_typeof(properties->'discount') = 'number'
THEN CAST(properties->>'discount' AS 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
GROUP BY 1
ORDER BY purchases DESC, plan;Три разных способа не иметь значения
В 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 |
Разбивка по свойству теряет события без ключа: 9 200 из 9 510 покупок
Когда свойство хранит не одно значение, а список билетов или допуслуг, оператора ->> недостаточно: после разворота JSON-массива в строки меняется зерно результата, и пустые списки надо сохранять осознанно. Отдельный пример показывает это на покупках учебной базы авиакомпании.
Три вида пропуска выше — это про одно событие. Теперь посмотрим, что они делают со знаменателем отчёта. Пример условный: запрос ниже собирает июль учебной вставкой — 9 510 событий purchase. Ключ plan есть у 9 200 из них, у остальных 310 его нет вовсе — 3,3% покупок. Разбивка по тарифу через properties->>'plan' отправит эти 310 строк в группу с NULL, и дальше всё зависит от того, кто смотрит отчёт: BI часто прячет пустую категорию, а человек читает оставшиеся группы как полную картину.
С деньгами из того же JSON картина хуже, потому что пропуск расползается по двум причинам сразу. Значение amount приехало числом у 8 700 событий. У 190 оно пришло строкой или пустым — защищённое приведение типа их отбрасывает, и sum уменьшается молча. Ещё у 620 событий ключа amount нет совсем: 9 510 минус 8 700 минус 190. Средний чек, посчитанный как avg, поделит выручку на 8 700 — на те строки, где значение удалось прочитать, — и окажется выше правды, если в пропущенных покупках суммы были небольшими. Ни одно из этих чисел не требует доверия на слово: все четыре бакета складываются в 9 510, и проверить это можно одним запросом.
Рабочее правило простое: событие без ключа остаётся в отчёте, но в явной категории. coalesce(properties->>'plan', '(не передано)') превращает невидимую группу NULL в строку, которую читатель дашборда заметит и про которую спросит. Это одна функция, и она меняет не число, а то, видно ли его. Для сверки удобно добавить rollup: он считает те же группы плюс итог по всему периоду, и расхождение между суммой групп и итогом становится видно сразу, в том же результате.
Отдельная ловушка живёт в фильтрах по свойству. Условие properties->>'plan' <> 'free' выглядит как «все тарифы, кроме бесплатного», но для события без ключа оно даёт UNKNOWN, и строка не проходит ни его, ни обратное условие — те самые 310 покупок исчезают из обеих половин разбивки. Если пропуск нужно сохранить, условие пишут как properties->>'plan' is distinct from 'free' или сначала подставляют категорию через coalesce, а фильтруют уже по ней.
Когда свойство попадает в регулярный отчёт, покрытие ключа стоит считать рядом с самой метрикой. Доля событий с заполненным amount — такой же показатель качества, как доля дублей: пока она держится на одном уровне, выручке из JSON можно верить, а её падение объясняет расхождение раньше, чем его найдут финансы.
| Бакет | События | Что делает с отчётом |
|---|---|---|
| ключ plan есть | 9 200 | попадают в разбивку по тарифу |
| ключа plan нет | 310 | уходят в группу NULL и исчезают, если BI прячет пустые |
| amount числом | 8 700 | попадают в sum и в знаменатель avg |
| amount строкой или пустой | 190 | отбрасываются приведением типа, сумма тихо уменьшается |
| ключа amount нет | 620 | NULL: sum игнорирует, avg делит на 8 700 |
| всего покупок | 9 510 | контрольное число, с которым сверяются группы |
-- учебная вставка: сколько покупок пришло с каким набором свойств
with shapes (events, properties) as (
values
(5100, '{"plan":"pro","amount":29}'::jsonb),
(3400, '{"plan":"basic","amount":19}'::jsonb),
(120, '{"plan":"basic","amount":"19"}'::jsonb),
(70, '{"plan":"pro","amount":""}'::jsonb),
(510, '{"plan":"pro"}'::jsonb),
(200, '{"amount":19}'::jsonb),
(110, '{}'::jsonb)
),
events as ( -- каждая строка shapes повторена events раз
select s.properties
from shapes s
cross join lateral generate_series(1, s.events)
)
select
coalesce(properties->>'plan', '(не передано)') as plan_bucket,
count(*) as purchases,
count(*) filter (where jsonb_typeof(properties->'amount') = 'number') as amount_number,
count(*) filter (where properties ? 'amount'
and jsonb_typeof(properties->'amount') <> 'number') as amount_broken,
count(*) filter (where not properties ? 'amount') as amount_missing,
sum(case when jsonb_typeof(properties->'amount') = 'number'
then cast(properties->>'amount' as numeric) end) as revenue
from events
group by rollup (1)
order by purchases desc, plan_bucket;
-- первая строка — итог rollup: plan_bucket пуст, purchases = 9510.
-- Он обязан совпасть с суммой групп: 5680 + 3520 + 310Массивы размножают строки — и суммы
Когда в свойствах лежит массив (список товаров в заказе, набор применённых промокодов), возникает соблазн развернуть его через jsonb_array_elements и посчитать по элементам. Функция возвращает набор строк, поэтому одно событие превращается в несколько — и любая агрегация по полям самого события после этого удваивается.
Правило простое: решите заранее, на каком зерне вы отвечаете на вопрос. «Сколько товаров продали» — зерно элемента массива, разворачивать нужно. «Сколько было покупок» — зерно события, и здесь достаточно jsonb_array_length без разворачивания.
Если нужны оба числа в одном отчёте, считайте их в разных слоях и соединяйте по ключу события, а не смешивайте в одном SELECT после разворачивания.
В примере ниже четыре покупки и шесть позиций. После разворачивания строк становится шесть, а покупка без items из результата исчезает.
-- учебная вставка: четыре покупки, у последней списка items нет
WITH events (event_id, properties) AS (
VALUES
(1, '{"items":[{"sku":"A-1","price":1200},{"sku":"B-7","price":300}]}'::jsonb),
(2, '{"items":[{"sku":"B-7","price":300}]}'::jsonb),
(3, '{"items":[{"sku":"A-1","price":1200},{"sku":"C-3","price":500},{"sku":"B-7","price":300}]}'::jsonb),
(4, '{"plan":"pro"}'::jsonb)
)
SELECT count(*) AS purchases,
count(*) FILTER (WHERE jsonb_typeof(properties->'items') = 'array') AS with_items,
sum(CASE WHEN jsonb_typeof(properties->'items') = 'array'
THEN jsonb_array_length(properties->'items')
END) AS items_total
FROM events;-- учебная вставка: те же четыре покупки
WITH events (event_id, properties) AS (
VALUES
(1, '{"items":[{"sku":"A-1","price":1200},{"sku":"B-7","price":300}]}'::jsonb),
(2, '{"items":[{"sku":"B-7","price":300}]}'::jsonb),
(3, '{"items":[{"sku":"A-1","price":1200},{"sku":"C-3","price":500},{"sku":"B-7","price":300}]}'::jsonb),
(4, '{"plan":"pro"}'::jsonb)
)
SELECT item->>'sku' AS sku,
count(*) AS times_bought,
sum(CAST(item->>'price' AS numeric)) AS revenue
FROM events e
CROSS JOIN LATERAL jsonb_array_elements(e.properties->'items') AS item
GROUP BY 1
ORDER BY revenue DESC, sku;Как выглядит расхождение с биллингом
Типичная история: продуктовая аналитика показывает средний размер скидки 12%, финансы видят 14,8%. Обе стороны уверены в своих цифрах, обсуждение уходит в спор о методологии. На деле разница почти всегда собирается из мелочей внутри JSON, и её можно разложить по слагаемым.
Начните с покрытия: какая доля событий вообще содержит нужный ключ. Затем посмотрите на типы: сколько значений пришло строкой вместо числа и потерялось при безопасном приведении. Потом на нули и null: считаются ли они как «скидки не было» или выпадают из знаменателя. И только после этого сравнивайте формулы.
На графике ниже — условный пример такого разложения. Полезно строить его один раз и оставлять в дашборде качества: тогда следующее расхождение объясняется за минуту, а не за встречу на час.
Данные иллюстративные. Смысл в порядке проверки: покрытие ключа, затем типы, затем трактовка нулей — и только потом формула метрики.
Индексы: какой запрос вы собираетесь ускорять
JSONB индексируется, но универсального индекса под все запросы не существует. GIN-индекс по всему полю поддерживает проверки существования и вхождения — ?, @> и операторы JSONPath. Это правильный выбор, когда фильтры разнообразны и ключи заранее неизвестны.
Если запросы всегда идут по одному конкретному ключу, дешевле обычный B-tree индекс по выражению: он меньше, быстрее обновляется и работает с равенством и диапазонами. Классический пример — фильтр по тарифу или по группе A/B-теста в каждом отчёте.
У GIN есть облегчённый вариант jsonb_path_ops: проверку наличия ключа ? он не поддерживает — только вхождение @> и операторы JSONPath, — но занимает заметно меньше места. Выбор между ними — это выбор между гибкостью и стоимостью, и делать его стоит по реальному списку запросов, а не заранее.
Блок ниже строит все три индекса на 20 000 событий во временной таблице. Проверку @> планировщик ведёт через jsonb_path_ops, проверку ? — через полный GIN, равенство по тарифу — через B-tree. В PostgreSQL 14 на этих данных полный GIN занимает 232 kB, облегчённый — 136 kB.
BEGIN;
-- учебная вставка: 20 000 событий во временной таблице
CREATE TEMP TABLE demo_events ON COMMIT DROP AS
SELECT g AS event_id,
jsonb_build_object(
'plan', CASE WHEN g % 3 = 0 THEN 'pro' ELSE 'basic' END,
'screen', 'screen_' || g % 40,
'ab_group', CASE WHEN g % 100 = 0 THEN 'test' ELSE 'control' END
) AS properties
FROM generate_series(1, 20000) AS g;
-- разнообразные фильтры по разным ключам
CREATE INDEX demo_events_props_gin ON demo_events USING gin (properties);
-- без проверки наличия ключа, зато компактнее
CREATE INDEX demo_events_props_path ON demo_events USING gin (properties jsonb_path_ops);
-- один часто используемый ключ
CREATE INDEX demo_events_plan_idx ON demo_events ((properties->>'plan'));
ANALYZE demo_events;
-- проверяем, какой индекс выбрал планировщик
EXPLAIN (COSTS OFF)
SELECT count(*) FROM demo_events
WHERE properties @> '{"ab_group": "test"}';
-- Bitmap Index Scan on demo_events_props_path
EXPLAIN (COSTS OFF)
SELECT count(*) FROM demo_events WHERE properties ? 'promo';
-- Bitmap Index Scan on demo_events_props_gin
EXPLAIN (COSTS OFF)
SELECT count(*) FROM demo_events WHERE properties->>'plan' = 'pro';
-- Bitmap Index Scan on demo_events_plan_idx
SELECT pg_size_pretty(pg_relation_size('demo_events_props_gin')) AS gin_full, -- 232 kB
pg_size_pretty(pg_relation_size('demo_events_props_path')) AS gin_path; -- 136 kB
ROLLBACK; -- таблица и индексы исчезаютКонтракт события важнее удобных операторов
Гибкая схема хороша ровно до того момента, пока по ней не считают деньги. Как только параметр из JSON попадает в регулярный отчёт, у него появляются те же требования, что и у обычной колонки: известный тип, известный набор допустимых значений, владелец и правило на случай отсутствия.
Практический порядок такой. Раз в период собирайте каталог ключей: какие вообще встречаются, в каких событиях, с какими типами и долей заполнения. Ключи, которые попали в KPI, выносите в типизированные колонки витрины — это снимает и приведение типов, и вопрос индексов. В JSONB оставляйте то, что действительно меняется часто: экспериментальные атрибуты, отладочный контекст, редкие параметры. В примере ниже discount есть у шести покупок из семи и приходит в трёх видах: числом, строкой и null.
-- учебная вставка: семь покупок
WITH events (event_id, properties) AS (
VALUES
(1, '{"plan":"pro","discount":15,"ab_group":"test"}'::jsonb),
(2, '{"plan":"pro","discount":"10"}'::jsonb),
(3, '{"plan":"pro","discount":5}'::jsonb),
(4, '{"plan":"basic","discount":null}'::jsonb),
(5, '{"plan":"basic"}'::jsonb),
(6, '{"plan":"basic","discount":""}'::jsonb),
(7, '{"plan":"team","discount":0}'::jsonb)
)
SELECT
props.key,
count(*) AS events,
round(100.0 * count(*) / (SELECT count(*) FROM events), 1) AS pct_of_events,
count(*) FILTER (WHERE jsonb_typeof(props.value) = 'string') AS as_string,
count(*) FILTER (WHERE jsonb_typeof(props.value) = 'number') AS as_number,
count(*) FILTER (WHERE jsonb_typeof(props.value) = 'null') AS as_null
FROM events e
CROSS JOIN LATERAL jsonb_each(e.properties) AS props(key, value)
GROUP BY props.key
ORDER BY events DESC, props.key;Частые вопросы
Как достать значение из JSON в SQL?
В PostgreSQL — оператором ->>: properties ->> 'plan' возвращает значение ключа текстом. До вложенного ключа доходят цепочкой properties -> 'payment' ->> 'method' или путём properties #>> '{payment,method}'. Число после извлечения приводят явно: CAST(properties ->> 'discount' AS numeric). Если в ключе может оказаться строка вроде "none", приведение оборачивают проверкой jsonb_typeof, иначе запрос упадёт с ошибкой.
Чем JSON отличается от JSONB в PostgreSQL?
Тип json хранит исходный текст как есть — с пробелами, порядком и повторами ключей — и разбирает его при каждом обращении. jsonb хранит уже разобранную структуру: {"b":1, "a":2, "a":3} превращается в {"a": 3, "b": 1}. Стрелки -> и ->> работают с обоими типами, а проверки @> и ? и GIN-индекс по документу — только с jsonb. Документация PostgreSQL советует выбирать jsonb, если нет особых причин хранить текст.
Как достать значение, если JSON лежит в текстовой колонке?
Сначала привести тип: CAST(properties AS jsonb) ->> 'plan'. Без приведения PostgreSQL отвечает operator does not exist: text ->> unknown.
Работают ли эти операторы в DuckDB?
-> и ->> работают, в том числе цепочкой и по текстовой колонке с JSON без приведения. Оператора #>> в DuckDB нет, вместо него путь пишут в самой стрелке: properties ->> '$.payment.method'. В PostgreSQL такая запись не вызовет ошибку, а вернёт NULL: база будет искать ключ с именем $.payment.method. Проверок @> и ? для JSON в DuckDB тоже нет, их заменяют функции json_contains и json_exists. Тип значения вместо jsonb_typeof отдаёт json_type — в названиях DuckDB: UBIGINT, VARCHAR, NULL. Запрос ниже выполняется в песочнице симулятора.
-- учебная вставка: четыре события, свойства — обычный текст
WITH events (event_id, properties) AS (
VALUES
(1, '{"plan":"pro","discount":15,"payment":{"method":"card"},"ab_group":"test"}'),
(2, '{"plan":"basic","discount":"none"}'),
(3, '{"plan":"basic","discount":null}'),
(4, '{"plan":"team"}')
)
SELECT event_id,
properties ->> 'plan' AS plan,
properties ->> '$.payment.method' AS payment_method, -- вместо #>>
json_exists(properties, '$.discount') AS has_discount, -- вместо ?
json_contains(properties, '{"ab_group":"test"}') AS is_test, -- вместо @>
json_type(properties, '$.discount') AS discount_type, -- вместо jsonb_typeof
TRY_CAST(properties ->> 'discount' AS DECIMAL(10, 2)) AS discount -- "none" даёт NULL, а не ошибку
FROM events
ORDER BY event_id;Чеклист перед расчётом по JSONB
Свойства событий — самая частая причина расхождений между продуктовой аналитикой и биллингом. Проверка занимает несколько минут и обычно окупается сразу.
- Для каждого используемого ключа известен ожидаемый тип, и он проверен через
jsonb_typeof? - Отсутствие ключа, JSON null и пустая строка разделены, а не свалены в один NULL?
- Приведение типов защищено от неожиданных значений, чтобы отчёт не падал после релиза?
- При работе с массивами зерно результата выбрано осознанно, и суммы не удвоились после разворачивания?
- Индекс соответствует реальным фильтрам, а не добавлен «на всякий случай»?
- Ключи, которые попали в KPI, вынесены в типизированные колонки витрины?
Материалы по теме

LATERAL JOIN в PostgreSQL: последняя запись и top-N на пользователя
LATERAL JOIN в PostgreSQL и SQL: что это, чем LEFT JOIN LATERAL отличается от CROSS, как взять последний платёж и три последних события пользователя.

LIKE и ILIKE в SQL: разница, шаблоны % и _, регистр и индексы
Как искать по тексту в SQL: шаблоны LIKE и ILIKE, экранирование пользовательского ввода, работа с регистром и момент, когда запрос перестаёт использовать индекс.

Индексы в SQL: что это, как работают и когда помогают
Индексы в SQL: что это, как работает B-tree, составной, частичный индекс и индекс по выражению, когда индекс не помогает — на планах EXPLAIN ANALYZE.