Перейти к содержанию
Innovialabs

JSONB в PostgreSQL: когда и как хранить JSON в таблице

Практика4 мин

Коротко

Храните JSON в колонке jsonb, индексируйте её GIN-индексом и используйте для действительно гибких данных вроде атрибутов товаров и сырых webhook; всё, по чему вы фильтруете, связываете и считаете деньги, выносите в обычные колонки.

Коротко: json или jsonb

В PostgreSQL есть два типа для JSON:

jsonjsonb
Как хранитсяКак исходный текстВ разобранном бинарном виде
Пробелы и порядок ключейСохраняютсяНе сохраняются
Повторяющиеся ключиСохраняютсяОстаётся последний
ЗаписьЧуть быстрееЧуть медленнее (разбор при вставке)
Чтение и поискКаждый раз разбирается зановоБыстрее
Индексы GINНетДа

Практическое правило: почти всегда выбирайте jsonb. json нужен, только если важно сохранить документ байт в байт, например для проверки подписи входящего запроса.

Основные операторы

Пусть есть таблица товаров:

CREATE TABLE products (
  id     bigserial PRIMARY KEY,
  name   text NOT NULL,
  price  numeric(14, 2) NOT NULL,
  attrs  jsonb NOT NULL DEFAULT '{}'
);

INSERT INTO products (name, price, attrs) VALUES
  ('Ноутбук', 1000.00,
   '{"brand": "Lenovo", "ram_gb": 16, "colors": ["black", "grey"], "dims": {"weight_kg": 1.4}}');
  • -> возвращает значение как jsonb: attrs -> 'dims'.
  • ->> возвращает значение как text: attrs ->> 'brand'.
  • #> и #>> — то же по пути: attrs #>> '{dims,weight_kg}'.
  • @> — «содержит»: attrs @> '{"brand": "Lenovo"}'.
  • ? — есть ли ключ верхнего уровня: attrs ? 'ram_gb'.
SELECT name, attrs ->> 'brand' AS brand
FROM products
WHERE attrs @> '{"colors": ["black"]}'
  AND (attrs ->> 'ram_gb')::int >= 16;

Обратите внимание: ->> всегда возвращает текст, поэтому для сравнения чисел нужно явное приведение типа.

Индексы

GIN-индекс на всю колонку ускоряет @>, ?, ?| и ?&:

CREATE INDEX products_attrs_gin ON products USING GIN (attrs);

Если вы ищете только через @>, используйте класс операторов jsonb_path_ops — индекс получается компактнее, но не поддерживает ?:

CREATE INDEX products_attrs_path ON products USING GIN (attrs jsonb_path_ops);

B-tree по выражению — для одного ключа, по которому часто фильтруют или сортируют:

CREATE INDEX products_ram ON products (((attrs ->> 'ram_gb')::int));

Запрос должен использовать то же выражение, что и индекс, иначе планировщик индекс не возьмёт.

Обновление вложенных полей

-- заменить значение по пути
UPDATE products SET attrs = jsonb_set(attrs, '{dims,weight_kg}', '1.3') WHERE id = 1;

-- добавить или перезаписать ключи верхнего уровня
UPDATE products SET attrs = attrs || '{"warranty_months": 12}' WHERE id = 1;

-- удалить ключ и вложенный путь
UPDATE products SET attrs = attrs - 'colors' WHERE id = 1;
UPDATE products SET attrs = attrs #- '{dims,weight_kg}' WHERE id = 1;

Важные детали:

  • jsonb_set создаёт только последний ключ пути. Если промежуточного объекта (dims) нет, документ останется без изменений.
  • Оператор || объединяет только верхний уровень: вложенный объект заменяется целиком, а не сливается.
  • В новых версиях PostgreSQL можно обновлять через индексацию: SET attrs['brand'] = '"HP"'.
  • Любое изменение переписывает весь документ. Большие JSON, которые часто правят по одному полю, создают лишнюю нагрузку.

Когда JSON-колонка уместна

  • Атрибуты товаров: у телефона — память и диагональ, у обуви — размер и материал. Общие поля (название, цена, остаток, категория) — колонки, специфичные — jsonb.
  • Сырые webhook-платежи от Payme, Click и других провайдеров. Сохраните полный payload для разбора спорных ситуаций, а идентификатор транзакции, сумму и статус вынесите в колонки:
CREATE TABLE payment_events (
  id           bigserial PRIMARY KEY,
  provider     text NOT NULL,
  external_id  text NOT NULL,
  status       text NOT NULL,
  amount       numeric(14, 2) NOT NULL,
  payload      jsonb NOT NULL,
  received_at  timestamptz NOT NULL DEFAULT now(),
  UNIQUE (provider, external_id, status)
);

Уникальный ключ защищает от повторной обработки одного и того же уведомления, если провайдер пришлёт его дважды.

  • Настройки и метаданные, которые читаются целиком и редко фильтруются.
  • Ответы внешних API, формат которых вы не контролируете.

Когда это антипаттерн

Тревожные признаки:

  • поле есть у всех строк и участвует почти в каждом WHERE;
  • по полю делают JOIN или в нём лежат id других записей — теряются внешние ключи;
  • нужны ограничения: NOT NULL, уникальность, тип, CHECK;
  • по полю считают деньги и отчёты: суммы, группировки;
  • разные части кода пишут в JSON одно и то же под разными ключами.

Ещё одна причина: планировщик не собирает подробную статистику по ключам внутри jsonb, поэтому оценки для сложных запросов бывают неточными. Если поле стало важным — выносите его в колонку миграцией.

Частые вопросы

Да, через CHECK: например, CHECK (attrs ? 'brand') или CHECK (jsonb_typeof(attrs -> 'ram_gb') = 'number'). Но если ограничений становится много, это знак, что данные пора хранить в обычных колонках.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.