Гайды

JSONB в PostgreSQL: когда использовать и как индексировать

json vs jsonb, операторы, GIN и выраженные индексы, jsonb_set, валидация и типичные ошибки при смешанной модели.

~14 мин чтения

JSONB в PostgreSQL: когда и как использовать

JSONB — бинарное представление JSON: нормализация ключей, эффективный доступ, индексы (GIN, btree по выражению и др.). Тип json хранит текст и парсит при чтении — почти всегда для новых проектов выбирайте jsonb, если нет узкой причины оставаться на json.


Сравнение json и jsonb

jsonjsonb
ХранениеИсходный текстДекомпозированное бинарное дерево
ВставкаЧасто быстрееЧуть дороже (нормализация)
Чтение / поискМедленнееБыстрее
ИндексыОграниченноGIN, btree по выражению, частичные

Когда JSONB уместен

  1. Часто меняющаяся или заранее нефиксируемая схема — настройки, метаданные, атрибуты продуктов.
  2. Сырой ответ внешнего API — сохранить как есть для аудита и последующей нормализации.
  3. EAV-подобные «доп. поля» без сотен nullable-колонок.
  4. Логи и события с разной структурой payload.
  5. Прототипирование до стабилизации требований.
  6. «Ленивая» нормализация — сначала документ, потом витрина.

Пример: настройки пользователя

sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name TEXT NOT NULL,
    settings JSONB NOT NULL DEFAULT '{}'
);

INSERT INTO users (name, settings) VALUES
('Alice', '{"theme": "dark", "notifications": {"email": true, "push": false}}'),
('Bob',   '{"theme": "light", "locale": "ru"}');

Пример: лог API

sql
CREATE TABLE api_logs (
    id BIGSERIAL PRIMARY KEY,
    endpoint TEXT NOT NULL,
    request_time TIMESTAMPTZ DEFAULT now(),
    request_data JSONB,
    response_data JSONB
);

Когда не стоит злоупотреблять JSONB

  1. Жёстко структурированные поля (email, phone) — теряются FK, строгая типизация на уровне столбцов, проще оптимизировать JOIN.
  2. Частые точечные обновления без jsonb_set — перезапись большого документа дороже, больше bloat.
  3. Высокая кардинальность «внутри» JSON — часто выгоднее вынести в столбец и индексировать его напрямую.
  4. Тяжёлый OLAP на миллиардах строк — специализированные движки / колоночные хранилища.

Правило: идентификаторы, FK и поля фильтрации отчётов — в нормальные колонки; «хвост» гибких полей — в jsonb.


Операторы и функции

Доступ по ключам

sql
-- ->  JSONB  |  ->>  text
SELECT '{"a":1,"b":{"c":"hello"}}'::jsonb -> 'b' -> 'c';
SELECT '{"a":1,"b":{"c":"hello"}}'::jsonb #>> '{b,c}';

Наличие ключей

sql
SELECT * FROM users WHERE settings ? 'locale';
SELECT * FROM users WHERE settings ?| ARRAY['theme', 'timezone'];

Обновление

sql
UPDATE users
SET settings = jsonb_set(settings, '{notifications,email}', 'false', true)
WHERE name = 'Alice';

UPDATE users SET settings = settings - 'locale';

Объединение

sql
SELECT '{"a":1}'::jsonb || '{"b":2}'::jsonb;
SELECT '{"a":1}'::jsonb || '{"a":99}'::jsonb;  -- ключ a перезаписан

Агрегация

sql
SELECT jsonb_agg(settings) FROM users;
SELECT jsonb_object_agg(name, settings) FROM users;

Индексы

GIN по всему документу

sql
CREATE INDEX idx_users_settings ON users USING gin (settings);

Ускоряет @>, ?, ?|, ?&. Не заменяет btree для условий вида settings->>'theme' = 'dark' — для этого нужен выраженный индекс или переписывание запроса под @>.

B-tree по выражению

sql
CREATE INDEX idx_users_theme ON users ((settings->>'theme'));

jsonb_path_ops (компактнее для @>)

sql
CREATE INDEX idx_users_settings_path ON users USING gin (settings jsonb_path_ops);

Не поддерживает ? — выбирайте операторный класс под реальные запросы.

Массив внутри документа

sql
CREATE INDEX idx_docs_tags ON documents USING gin ((data->'tags'));

Производительность (порядок величин)

  • Поиск ->> по 10M строк без индекса — секунды (Seq Scan).
  • С btree на (settings->>'theme') — миллисекунды.
  • jsonb_set на большом документе переписывает весь JSONB — следите за размером документа (часто >10–20 КБ уже боль).

Валидация

sql
ALTER TABLE users ADD CONSTRAINT settings_theme_valid
CHECK ((settings->>'theme') IS NULL OR length(settings->>'theme') > 0);

Для строгих JSON Schema смотрите расширения вроде pg_jsonschema (отдельная установка, не «из коробки» в vanilla).


Разворачивание в реляционный вид

sql
SELECT id, elem->>'tag' AS tag, (elem->>'count')::int AS cnt
FROM documents,
     jsonb_array_elements(documents.data->'items') AS elem;
sql
SELECT *
FROM users,
     jsonb_to_recordset(users.settings->'preferences') AS pref(foo text, bar int);

Типичные ошибки

ОшибкаКак лучше
Вся сущность в одном dataВынести ключи, FK, фильтры в колонки
JSONB как первичный ключИспользовать surrogate key (int / UUID)
GIN создан, а фильтр через ->>B-tree на выражение или запрос через @>
Большие документы целиком обновлятьjsonb_set или вынести горячие поля
Нет DEFAULT '{}' / NOT NULLЗащита от NULL в приложении

Пример: товары и атрибуты

sql
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    sku TEXT UNIQUE NOT NULL,
    title TEXT NOT NULL,
    price NUMERIC(10,2),
    attributes JSONB NOT NULL DEFAULT '{}'
);

CREATE INDEX idx_products_attrs ON products USING gin (attributes);
CREATE INDEX idx_products_weight ON products ((attributes->>'weight'));

SELECT * FROM products
WHERE attributes @> '{"brand": "Apple"}'
  AND (attributes->>'weight')::float > 0.5;

Итог

JSONB — расширение реляционной модели, а не замена: фундамент таблицы — нормализованные столбцы; JSONB — для гибкости, логов и полусхематичных полей. Индексируйте под реальные предикаты запросов.