JSONB в PostgreSQL: работа с полуструктурированными данными
JSON vs JSONB
В PostgreSQL есть два типа для JSON: JSON и JSONB. Разница важна.
JSON хранит данные как текст. При каждом обращении парсится заново. Сохраняет whitespace и порядок ключей точно как введено.
JSONB (binary) — декомпилированная форма. Индексирует внутренние структуры, поддерживает операторы и индексы. Не сохраняет whitespace и порядок ключей.
Для аналитики всегда JSONB. JSON разве что для сервисов, где важно сохранить исходный формат (например, webhook-пейлоады для audit).
Зачем это аналитику
Event-based аналитика часто сталкивается с полуструктурированными данными. Клиентские события имеют общие поля (user_id, event_time) и variable properties, которые зависят от типа события. Это удобно хранить как JSONB:
CREATE TABLE events (
event_id BIGSERIAL PRIMARY KEY,
user_id BIGINT,
event_time TIMESTAMPTZ,
event_name VARCHAR(50),
properties JSONB
);
-- Purchase event
INSERT INTO events VALUES (..., 'purchase', '{"amount": 1500, "category": "electronics", "items": 3}');
-- View event
INSERT INTO events VALUES (..., 'page_view', '{"url": "/product/123", "referrer": "google.com"}');Без JSONB пришлось бы либо делать огромную таблицу с сотнями колонок (большинство NULL), либо EAV-pattern (отдельная таблица для key-value пар, медленно на чтении).
Операторы для извлечения
Самые частые операторы.
-> возвращает поле как JSONB:
SELECT properties -> 'amount' FROM events WHERE event_name = 'purchase';
-- Результат: "1500" (как JSONB)->> возвращает поле как text:
SELECT properties ->> 'category' FROM events WHERE event_name = 'purchase';
-- Результат: 'electronics' (как text)#> и #>> — для вложенных путей:
SELECT properties #>> '{address, city}' FROM users;
-- Работает, если properties = {"address": {"city": "Moscow"}}Для чисел и фильтров используется ->> с cast:
SELECT * FROM events
WHERE event_name = 'purchase'
AND (properties ->> 'amount')::numeric > 1000;Операторы проверки
? — существует ли ключ:
SELECT * FROM events WHERE properties ? 'amount';
-- Все события, где есть ключ amount?| — существует ли хотя бы один из ключей:
SELECT * FROM events WHERE properties ?| ARRAY['amount', 'price'];?& — существуют ли все ключи:
SELECT * FROM events WHERE properties ?& ARRAY['user_id', 'session_id'];@> — содержит ли JSONB (contains):
SELECT * FROM events
WHERE properties @> '{"category": "electronics"}';
-- Все события с category = electronicsОператор @> особенно ценен, потому что может использовать GIN-индекс. Об этом ниже.
Агрегация на JSONB
Pattern: считать что-то по событиям с фильтрацией по properties.
-- Revenue по категориям из JSONB
SELECT
properties ->> 'category' AS category,
SUM((properties ->> 'amount')::numeric) AS revenue,
COUNT(*) AS orders
FROM events
WHERE event_name = 'purchase'
GROUP BY 1
ORDER BY revenue DESC;Это работает, но медленно без индекса. Для production-аналитики нужны индексы.
Индексы на JSONB
PostgreSQL поддерживает несколько типов индексов на JSONB.
GIN (Generalized Inverted iNdex) — полнофункциональный, но медленно строится и занимает место:
CREATE INDEX idx_events_properties_gin ON events USING GIN (properties);Этот индекс ускоряет @> (contains), ?, ?|, ?& операторы. Поиск по «все события с category = electronics» будет быстрым.
GIN с jsonb_path_ops — более компактный, но только для @>:
CREATE INDEX idx_events_properties_gin_path ON events USING GIN (properties jsonb_path_ops);Занимает меньше места, быстрее на @>, но не поддерживает ? операторы.
BTree на extract expression — для поиска по конкретному полю:
CREATE INDEX idx_events_category ON events ((properties ->> 'category'));Это обычный btree-индекс на результате выражения. Ускоряет WHERE properties ->> 'category' = 'electronics'.
Для аналитических запросов обычно полезны expression indexes на часто используемых полях. GIN хорош, когда нужно искать по произвольным ключам.
Разбираться с такими тонкостями — важно для senior-уровня. В тренажёре Карьерник есть задачи на проектирование схем для event-based аналитики, где эти вопросы всплывают постоянно.
Модификация JSONB
Для UPDATE-ов есть функции.
jsonb_set — устанавливает значение по пути:
UPDATE events
SET properties = jsonb_set(properties, '{refunded}', 'TRUE')
WHERE event_id = 42;Второй аргумент — путь массивом, третий — новое значение (тоже JSONB).
|| — конкатенация, мерджит два JSONB:
UPDATE events
SET properties = properties || '{"processed_at": "2026-04-16"}'::jsonb
WHERE ...;- — удаляет ключ:
UPDATE events SET properties = properties - 'tmp_field';jsonb_each и jsonb_array_elements
Функции для «разворачивания» JSONB в строки.
jsonb_each — разворачивает объект в строки (key, value):
SELECT key, value
FROM events, jsonb_each(properties)
WHERE event_id = 42;jsonb_array_elements — разворачивает массив в строки:
-- Если properties = {"items": [{"name": "A"}, {"name": "B"}]}
SELECT event_id, item ->> 'name' AS item_name
FROM events, jsonb_array_elements(properties -> 'items') AS item;Это мощно для событий с вложенными массивами. Классический случай — список товаров в заказе.
Пример: конверсионная воронка из JSONB events
Реальная задача: посчитать конверсию по воронке из event-based данных, где шаги определяются через event_name и свойства.
WITH funnel_steps AS (
SELECT
user_id,
MAX(CASE WHEN event_name = 'landing_view' THEN 1 ELSE 0 END) AS step1,
MAX(CASE WHEN event_name = 'add_to_cart'
AND properties ?& ARRAY['product_id', 'price']
THEN 1 ELSE 0 END) AS step2,
MAX(CASE WHEN event_name = 'checkout_start' THEN 1 ELSE 0 END) AS step3,
MAX(CASE WHEN event_name = 'purchase'
AND (properties ->> 'amount')::numeric > 0
THEN 1 ELSE 0 END) AS step4
FROM events
WHERE event_time >= '2026-04-01'
GROUP BY user_id
)
SELECT
SUM(step1) AS landing,
SUM(step2) AS cart,
SUM(step3) AS checkout,
SUM(step4) AS purchase,
ROUND(SUM(step4) * 100.0 / NULLIF(SUM(step1), 0), 2) AS overall_conv_pct
FROM funnel_steps;Проверки на наличие ключей через ?& гарантируют, что мы считаем только валидные события.
Типичные ловушки
properties ->> 'amount' всегда возвращает text. Чтобы использовать в математике — cast: ::numeric или ::integer.
NULL vs missing. Если ключа нет, properties ->> 'amount' вернёт NULL. Если ключ есть со значением null JSON, — тоже NULL в text-виде. Не различить напрямую.
Индексы — зверь. GIN-индекс на JSONB может быть в несколько раз больше самой таблицы. Следите за размером.
Плохая производительность без индекса. Seq scan через миллион JSONB-ов даже по простому фильтру — медленно.
Смешение типов. JSON позволяет хранить число, строку, bool, объект, массив в одной колонке. Если структура непредсказуема, получите сюрпризы при извлечении.
Когда НЕ использовать JSONB
Если структура данных известна и стабильна — лучше нормальные колонки. Быстрее запросы, типизация, constraints.
Если данные часто меняются — UPDATE-ы на JSONB медленнее, чем на обычных колонках.
Если основной use-case — full-text search по значениям — лучше выделенное FTS-решение (tsvector, Elasticsearch).
JSONB — хорош для event-based данных с variable properties. Для классических business-данных с fixed schema — нет.
Читайте также
FAQ
JSON или JSONB?
JSONB всегда, кроме случаев, когда нужно точное сохранение формата (webhook logs, audit).
Можно ли делать JOIN на поле внутри JSONB?
Можно: JOIN users ON users.user_id = (events.properties ->> 'user_id')::bigint. Но для performance лучше извлечь в отдельную колонку.
Как часто менять структуру JSONB?
Технически в любой момент — no schema enforcement. Практически — документируйте изменения, иначе старые events с одной структурой и новые с другой создадут хаос.
ClickHouse JSON vs PostgreSQL JSONB?
ClickHouse имеет свой JSON тип и функции. В целом похоже, но синтаксис и возможности отличаются. Для event-analytics в ClickHouse часто лучше колонки-массивы, чем JSON.