JSONB: хранение и доступ
Как хранить в одной колонке целый документ и доставать из него любое поле без отдельных таблиц.
jsonb — бинарный тип PostgreSQL для хранения JSON-документов: данные разбираются при вставке и хранятся в дереве, благодаря чему доступ к полям и индексация работают быстро.
Реляционная модель любит, когда у каждого факта своя колонка. Но в реальных проектах бывают данные с плавающей структурой: настройки пользователя, атрибуты товара из разных категорий, ответ внешнего API, лог события. Заводить под каждое возможное поле колонку — значит плодить десятки NULL-ов. PostgreSQL предлагает компромисс: положить такой документ целиком в одну колонку типа jsonb и всё равно обращаться к его полям прямо в SQL.
Зачем это на практике
Три типичных повода. Первый — полуструктурированные данные: у товара «ноутбук» есть диагональ и вес, у товара «книга» — автор и число страниц; единой схемы нет. Второй — гибкие настройки: профиль пользователя, где набор полей со временем растёт, а городить миграцию на каждый чекбокс не хочется. Третий — сырой ответ интеграции: сохранить JSON от платёжного шлюза как есть, чтобы потом разобраться, что именно он прислал.
json против jsonb
В PostgreSQL два типа: json и jsonb. Тип json хранит текст дословно — с пробелами, порядком ключей и даже дублями ключей. Тип jsonb при вставке разбирает документ в бинарное дерево: пробелы теряются, ключи упорядочиваются, дубли схлопываются (остаётся последний). За разбор платят чуть более медленной вставкой, зато доступ к полям, операторы поиска и, главное, индексы работают только с jsonb.
| Свойство | json | jsonb |
| Хранение | текст как есть | разобранное дерево |
| Дубли ключей | сохраняются | остаётся последний |
| Порядок ключей | сохраняется | не гарантирован |
| Индексация (GIN) | нет | да |
| Когда брать | аудит/лог «байт-в-байт» | почти всегда |
Практическое правило: если вы собираетесь искать или фильтровать по содержимому — берите jsonb. Чистый json оправдан, лишь когда документ нужно вернуть точно в том виде, в каком он пришёл.
Создаём таблицу и кладём документы
Объявить колонку просто. Литерал JSON — это обычная строка, которую PostgreSQL приводит к jsonb.
CREATE TABLE product (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
attrs jsonb NOT NULL DEFAULT '{}'
);
INSERT INTO product (title, attrs) VALUES
('Ноутбук Pro', '{"brand": "Acme", "ram": 16, "screen": {"size": 14, "oled": true}, "tags": ["work", "travel"]}'),
('Книга по SQL', '{"author": "К. Дейт", "pages": 560, "tags": ["db", "reference"]}');
Обратите внимание: у двух строк разный набор ключей внутри attrs — это и есть гибкость JSONB. Схему таблицы это не нарушает.
Операторы доступа: -> и ->>
Два главных оператора отличаются типом результата. Стрелка -> достаёт поле как jsonb (можно идти глубже), а двойная стрелка ->> достаёт поле как text (для сравнения, вывода, приведения к числу).
SELECT
attrs -> 'brand' AS brand_jsonb, -- "Acme" (с кавычками, тип jsonb)
attrs ->> 'brand' AS brand_text, -- Acme (тип text)
attrs ->> 'ram' AS ram_text, -- 16, но это ТЕКСТ
(attrs ->> 'ram')::int AS ram_int -- 16 как число
FROM product
WHERE title = 'Ноутбук Pro';
Вывод:
brand_jsonb | brand_text | ram_text | ram_int "Acme" | Acme | 16 | 16
Ключевая мысль: число внутри JSON, добытое через ->>, приходит как текст. Чтобы сравнить его как число (например, ram > 8), нужно явно привести: (attrs ->> 'ram')::int. Без приведения сравнение пойдёт лексикографически, и '9' > '16' окажется истиной.
Вложенность: цепочки и оператор #>
Стрелки -> можно выстраивать в цепочку, спускаясь по дереву. Для массивов индекс задаётся числом (нумерация с нуля). А оператор #> (и его текстовый собрат #>>) принимает путь массивом ключей сразу.
SELECT
attrs -> 'screen' ->> 'size' AS size_chain, -- через цепочку
attrs #>> '{screen,size}' AS size_path, -- через путь
attrs -> 'tags' ->> 0 AS first_tag, -- первый элемент массива
attrs #> '{screen}' AS screen_obj -- вложенный объект как jsonb
FROM product
WHERE title = 'Ноутбук Pro';
Вывод:
size_chain | size_path | first_tag | screen_obj
14 | 14 | work | {"size": 14, "oled": true}
Путь '{screen,size}' — это литерал массива текстов. Он удобен, когда глубина известна заранее и не хочется писать длинную цепочку стрелок. Если по пути чего-то нет, оператор вернёт NULL, а не ошибку — это важно для документов с плавающей структурой.
Как это работает под капотом
При INSERT в колонку jsonb PostgreSQL не хранит вашу строку дословно. Парсер разбирает её в дерево объектов, массивов и скаляров и сериализует в компактный бинарный формат, где ключи объекта отсортированы, а к значениям есть быстрый доступ по смещению. Поэтому при чтении поля движку не нужно заново парсить текст — он идёт сразу к нужной ветке. Отсюда же три «побочных эффекта» jsonb: исчезают незначащие пробелы, теряется исходный порядок ключей и схлопываются дубликаты. Для json ничего этого не происходит: там лежит ваша строка байт-в-байт, а каждый доступ к полю заново её разбирает.
Когда JSONB вместо отдельных колонок
JSONB — мощный инструмент, но не замена нормализации. Ориентир такой:
- Берите JSONB, когда набор полей плавающий, разрежённый или заранее неизвестен; когда данные приходят документом извне; когда поля редко участвуют в строгих ограничениях и связях.
- Берите обычные колонки для атрибутов, по которым вы постоянно фильтруете, джойните, накладываете
FOREIGN KEY,CHECKилиUNIQUE. Тип колонки даёт строгую валидацию и предсказуемые планы запросов «из коробки».
Частый рабочий гибрид: важные поля (price, category_id, created_at) — отдельными колонками со связями и индексами, а «всё остальное и переменчивое» — в одной колонке attrs jsonb.
Частые ошибки
- Путают -> и ->>.
->возвращает jsonb (строка остаётся в кавычках:"Acme"),->>— чистый text. ДляWHEREи вывода почти всегда нужен->>. - Сравнивают числа как текст.
attrs ->> 'ram' > '8'сравнивает строки, а не числа. Приводите:(attrs ->> 'ram')::int > 8. - Выбирают json вместо jsonb «по привычке». По
jsonнельзя построить GIN-индекс и нет операторов поиска. Если не нужен дословный текст — это всегдаjsonb. - Ждут ошибку на отсутствующем ключе.
attrs ->> 'missing'вернётNULL, а не упадёт. Это удобно, но требует аккуратной обработкиNULLв условиях.
Итоги
jsonbхранит документ разобранным деревом — быстрый доступ и индексируемость;jsonхранит текст дословно.->достаёт поле как jsonb,->>— как text; для сравнений берите->>и приводите тип.- В глубину идут цепочкой стрелок или путём через
#>/#>>; индекс массива — число с нуля. - JSONB — для плавающих и разрежённых данных; то, по чему фильтруете и связываете, лучше держать отдельными колонками.