Иногда структура данных заранее не известна. Разные товары в каталоге — разные атрибуты. Разные события в журнале аудита — разные поля. Добавлять под каждый случай отдельную колонку неудобно. Именно для таких случаев PostgreSQL умеет хранить JSON прямо в колонке.
Без индекса условие с @> проверяют у каждой строки — база читает таблицу целиком, даже когда подходит одна запись. GIN с jsonb_path_ops хранит отпечатки пар «путь — значение» и ведёт сразу к нужной строке.
json и jsonb — в чём разница
PostgreSQL предлагает два типа: json и jsonb. Разница принципиальная.
json хранит текст как есть — буквально строку с фигурными скобками. При каждом запросе PostgreSQL заново разбирает эту строку, что медленно. Зато json сохраняет порядок ключей и дубликаты (редко нужно, но иногда важно).
jsonb хранит данные в двоичном разобранном виде — как готовую структуру. Читать и искать по ней быстро. Порядок ключей не сохраняется, дубликаты убираются (последний выигрывает).
Нормализацию, которую делает jsonb, несложно повторить самому: дубликат ключа затирается последним значением, а сами ключи раскладываются по длине имени и алфавиту.
живой пример
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
public class JsonbNormalize {
public static void main(String[] args) {
String raw = "{\"b\": 1, \"a\": 2, \"long_key\": 3, \"a\": 9}";
List<String[]> pairs = List.of(
new String[]{"b", "1"}, new String[]{"a", "2"},
new String[]{"long_key", "3"}, new String[]{"a", "9"});
Map<String, String> jsonb = new TreeMap<>(
Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));
for (String[] pair : pairs) {
jsonb.put(pair[0], pair[1]);
}
System.out.println("json : " + raw);
System.out.println("jsonb: " + jsonb);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатается json : {"b": 1, "a": 2, "long_key": 3, "a": 9} и jsonb: {a=9, b=1, long_key=3}. PostgreSQL на тот же документ отвечает {"a": 9, "b": 1, "long_key": 3} — порядок свой, дубликат остался один, лишние пробелы убраны.
Практически всегда нужен jsonb. Исключение — когда принципиально важно сохранить входную строку бит-в-бит (например, для криптографической подписи).
Колонка или JSON-ключ
Поиск по attributes->>'sku' на миллионе строк идёт полным перебором, а по колонке sku с индексом отвечает мгновенно. Поэтому перед тем как убрать поле в JSONB, стоит задать один вопрос: по этому полю будут фильтровать, сортировать или делать JOIN?
Если да — это колонка, а не JSON-ключ. Колонка индексируется, типизируется, работает быстро. JSON-ключ без специального индекса — полный перебор таблицы.
Частая ошибка — положить в JSONB всё подряд и потом удивляться медленным запросам:
-- плохо: email и status — поля, по которым ищут постоянно
CREATE TABLE customer (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
data jsonb NOT NULL
);
SELECT * FROM customer WHERE data->>'email' = 'ivan@example.com'; -- полный перебор
SELECT * FROM customer WHERE data->>'status' = 'ACTIVE'; -- полный перебор
Правильно — держать основные поля колонками, а в JSONB класть только то, что редко используется в условиях:
-- хорошо: основные поля — колонками, гибкие атрибуты — в jsonb
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE customer (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email citext NOT NULL UNIQUE,
status varchar(20) NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
Когда JSONB действительно помогает
Журнал событий
Событие ORDER_PAID содержит одни поля, USER_REGISTERED — другие. Не создавать же сотни колонок для всех возможных типов событий.
CREATE TABLE event_log (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
aggregate_id uuid NOT NULL,
event_type varchar(50) NOT NULL,
occurred_at timestamptz NOT NULL,
payload jsonb NOT NULL
);
Колонка event_type — для фильтрации и индексирования. Полиморфное содержимое — в payload.
Атрибуты товаров разных категорий
Кроссовки имеют размер и ширину колодки. Ноутбуки — объём памяти и диагональ. Всё это не нужно хранить как колонки — 200 nullable-полей неудобны и для запросов, и для чтения кода.
CREATE TABLE product (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku varchar(50) NOT NULL UNIQUE,
name text NOT NULL,
price numeric(15, 2) NOT NULL,
attributes jsonb NOT NULL DEFAULT '{}'::jsonb
);
-- {"size": 42, "material": "leather"}
-- {"diagonal": 15.6, "ram_gb": 16}
Конфигурации интеграций
У каждого канала уведомлений — свои настройки. Хранить их в одной JSONB-колонке намного проще, чем делать отдельную таблицу под каждый тип.
CREATE TABLE integration_channel (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
kind varchar(20) NOT NULL, -- 'SMTP', 'TELEGRAM', 'WEBHOOK'
config jsonb NOT NULL
);
Снимок ответа внешнего API
Структуру диктует внешний сервис — она может меняться. JSONB фиксирует «что отдал API» без необходимости менять схему при каждом изменении.
Основные операторы
Работать с JSONB в запросах удобно через несколько операторов:
живой пример
SELECT
payload -> 'items' AS items, -- возвращает jsonb
payload ->> 'orderId' AS order_id, -- возвращает text
payload #> '{items, 0}' AS first_item, -- путь → jsonb
payload #>> '{items, 0, productId}' AS first_product -- путь → text
FROM outbox
WHERE event_type = 'OrderCreated';
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Оператор -> даёт вложенный объект (тип jsonb), ->> — строку (тип text). Для вложенного пути используют #> и #>>.
Проверить, содержит ли документ нужные ключи или подмножество:
-- документ содержит подмножество
SELECT * FROM event_log WHERE payload @> '{"event_type": "ORDER_PAID"}'::jsonb;
-- колонка содержит ключ
SELECT * FROM product WHERE attributes ? 'material';
-- содержит хотя бы один из ключей
SELECT * FROM product WHERE attributes ?| array['material', 'size'];
-- содержит все перечисленные ключи
SELECT * FROM product WHERE attributes ?& array['material', 'size'];
Оператор @> спрашивает не о равенстве, а о вложенности: документ подходит, если в нём есть все пары «ключ — значение» из образца. То же правило одной строкой:
живой пример
import java.util.Map;
public class JsonbContains {
public static void main(String[] args) {
Map<String, Object> payload = Map.of(
"event_type", "ORDER_PAID", "amount", 1990, "currency", "RUB");
Map<String, Object> byType = Map.of("event_type", "ORDER_PAID");
Map<String, Object> byTypeAndAmount = Map.of("event_type", "ORDER_PAID", "amount", 100);
System.out.println("@> {event_type} -> " + contains(payload, byType));
System.out.println("@> {event_type, amount} -> " + contains(payload, byTypeAndAmount));
}
static boolean contains(Map<String, Object> document, Map<String, Object> query) {
return document.entrySet().containsAll(query.entrySet());
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Печатается true и false. Второй образец не подошёл не потому, что ключа нет, а потому, что значение другое: в документе amount равен 1990.
Чтение вложенности и массивов
Стрелки достают значение по одному ключу, а документ редко бывает плоским. Для разбора есть функции, превращающие документ в строки — дальше с ними работает обычный SQL.
jsonb_array_elements разворачивает массив внутри документа в набор строк: товар с массивом отзывов становится набором «товар — отзыв», и по нему уже можно фильтровать и считать.
живой пример
SELECT p.id, review->>'author' AS author, (review->>'rating')::int AS rating
FROM products p, jsonb_array_elements(p.attributes->'reviews') AS review
WHERE (review->>'rating')::int <= 2;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Запятая здесь — это соединение с функцией, которое видит текущую строку слева (то самое LATERAL, только в краткой записи). У товара без отзывов строк не будет вовсе; чтобы такие товары остались, пишут LEFT JOIN LATERAL … ON true.
jsonb_each делает то же для объекта: даёт пары «ключ — значение», когда имена ключей заранее неизвестны. А jsonb_to_record и jsonb_populate_record раскладывают документ по колонкам с типами — удобно, когда форма всё-таки известна и хочется работать с ней как с таблицей:
живой пример
SELECT r.material, r.weight
FROM products p, jsonb_to_record(p.attributes) AS r(material text, weight numeric)
WHERE r.weight > 1;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Изменение документа
Обновление в PostgreSQL всегда переписывает документ целиком — но собирать новый документ руками не нужно, для правок есть операторы и функции.
-- заменить или добавить значение по пути
UPDATE products SET attributes = jsonb_set(attributes, '{material}', '"steel"') WHERE id = 1;
-- добавить сразу несколько ключей (слияние на верхнем уровне)
UPDATE products SET attributes = attributes || '{"color": "black", "weight": 1.2}'::jsonb WHERE id = 1;
-- удалить ключ верхнего уровня и значение по пути
UPDATE products SET attributes = attributes - 'color' WHERE id = 1;
UPDATE products SET attributes = attributes #- '{specs,legacy_code}' WHERE id = 1;
Две детали, на которых спотыкаются. jsonb_set по умолчанию не создаёт отсутствующий путь — если родительского ключа нет, документ останется прежним; чтобы создавал, четвёртым аргументом передают true. И jsonb_set возвращает NULL, если хоть один аргумент NULL: обновление колонки, где лежал NULL вместо {}, тихо обнулит её целиком, поэтому такую колонку объявляют NOT NULL DEFAULT '{}'::jsonb.
Ключа нет или значение null
Это два разных состояния, и путать их дорого. payload->>'color' IS NULL истинно в обоих случаях: и когда ключа color нет вовсе, и когда в нём лежит JSON-значение null. Различает их оператор ? — «есть ли такой ключ»:
живой пример
SELECT count(*) FILTER (WHERE NOT attributes ? 'color') AS klyucha_net,
count(*) FILTER (WHERE attributes ? 'color'
AND jsonb_typeof(attributes->'color') = 'null') AS yavnyy_null
FROM products;
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Для прикладной задачи «товары, у которых цвет не указан» правильный ответ обычно объединяет оба случая: WHERE attributes->>'color' IS NULL. А вот «цвет явно стёрли» — это уже второй запрос, и без ? его не написать.
Капкан для Java. В JDBC знак вопроса — это плейсхолдер параметра, поэтому attributes ? 'material' в запросе из кода не доедет до базы: драйвер попытается подставить туда параметр. В pgjdbc оператор экранируют удвоением — attributes ?? 'material', — а надёжнее вызвать функцию: jsonb_exists(attributes, 'material'). В jOOQ то же самое: либо DSL.condition("jsonb_exists({0}, {1})", …), либо экранирование. Это первая вещь, которая ломается при переносе запроса из psql в код.
Индексы — как ускорить поиск по JSONB
Без индекса поиск по JSONB — полный перебор. Есть два варианта.
GIN-индекс подходит, когда фильтруют по разным ключам или вложенным объектам через оператор @>. Вариант jsonb_path_ops меньше и быстрее стандартного GIN, но умеет не всё: он поддерживает вхождение @> и запросы на языке jsonpath (@?, @@), а вот проверку наличия ключа (?, ?|) — нет:
CREATE INDEX event_log_payload_gin
ON event_log USING gin (payload jsonb_path_ops);
-- теперь это работает быстро
SELECT * FROM event_log WHERE payload @> '{"event_type": "ORDER_PAID"}';
Функциональный индекс дешевле GIN и лучше, когда всегда ищут по одному конкретному ключу:
CREATE INDEX event_log_event_type
ON event_log ((payload ->> 'event_type'));
-- ускоряет точечный поиск
SELECT * FROM event_log WHERE payload ->> 'event_type' = 'ORDER_PAID';
Если постоянно фильтруют по одному и тому же ключу — функциональный индекс предпочтительнее. GIN нужен, когда условия разнообразны или структура вложенная.
Ещё одна особенность, объясняющая кривые планы именно на jsonb. Планировщик не собирает статистику по содержимому документа: он не знает, сколько строк подойдёт под attributes @> '{"brand": "acme"}', и подставляет фиксированную оценку — заметную долю таблицы. Поэтому запрос с точным условием по редкому ключу может получить план «читаем всё подряд» просто потому, что база думает, будто подойдёт половина строк. Признак узнаваемый: в EXPLAIN (ANALYZE) оценка rows= расходится с фактом в сотни раз. Лечится вынесением горячего поля в обычную колонку — там статистика есть — или генерируемой колонкой поверх jsonb с обычным индексом.
Что не стоит хранить в JSONB
JSONB — для структурированных документов небольшого размера. Несколько вещей туда не стоит класть:
- Двоичные данные (изображения, файлы) — для них есть
byteaили внешнее хранилище. - Длинные тексты (статьи, описания) — лучше
text, PostgreSQL сам разместит большие значения эффективно. - Данные в base64 — это двоичное содержимое, закодированное в текст с лишними накладными расходами.
Если JSONB-поле вырастает до десятков килобайт на запись — это сигнал пересмотреть дизайн.
Ещё одна плата за JSONB — обновления. Изменить один ключ нельзя «на месте»: PostgreSQL перезаписывает весь документ как новую версию строки (а документ больше пары килобайт ещё и уходит в TOAST и читается по частям). Поле, которое меняется часто — остаток, счётчик, статус, — в JSONB стоит дорого и раздувает таблицу; ему место в обычной колонке.
Работа с JSONB в коде приложения
Драйвер сериализует объект в JSON перед отправкой в базу и десериализует при чтении. Лучше работать с типизированными объектами — так видна структура, понятно что обязательно.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Instant;
import java.util.Map;
import java.util.UUID;
import org.jooq.Converter;
import org.jooq.JSONB;
public class JsonbConverter implements Converter<JSONB, JsonNode> {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public JsonNode from(JSONB db) {
try { return db == null ? null : mapper.readTree(db.data()); }
catch (Exception e) { throw new IllegalStateException(e); }
}
@Override
public JSONB to(JsonNode node) {
try { return node == null ? null : JSONB.valueOf(mapper.writeValueAsString(node)); }
catch (Exception e) { throw new IllegalStateException(e); }
}
@Override
public Class<JSONB> fromType() {
return JSONB.class;
}
@Override
public Class<JsonNode> toType() {
return JsonNode.class;
}
}
record EventPayload(UUID customerId, String eventType, Instant occurredAt, Map<String, Object> details) {}
import (
"context"
"encoding/json"
"github.com/jackc/pgx/v5/pgxpool"
)
type EventPayload struct {
CustomerID string `json:"customerId"`
EventType string `json:"eventType"`
OccurredAt string `json:"occurredAt"`
Details map[string]any `json:"details"`
}
func insertEvent(ctx context.Context, pool *pgxpool.Pool, p EventPayload) error {
raw, err := json.Marshal(p)
if err != nil {
return err
}
_, err = pool.Exec(ctx,
`INSERT INTO event_log (aggregate_id, event_type, occurred_at, payload)
VALUES ($1, $2, now(), $3)`,
p.CustomerID, p.EventType, raw,
)
return err
}
func loadPayload(ctx context.Context, pool *pgxpool.Pool, id int64) (EventPayload, error) {
var raw []byte
err := pool.QueryRow(ctx, `SELECT payload FROM event_log WHERE id = $1`, id).Scan(&raw)
if err != nil {
return EventPayload{}, err
}
var p EventPayload
return p, json.Unmarshal(raw, &p)
}
import { Pool } from 'pg';
interface EventPayload {
customerId: string;
eventType: string;
occurredAt: string;
details: Record<string, unknown>;
}
const pool = new Pool();
async function insertEvent(p: EventPayload): Promise<void> {
await pool.query(
`INSERT INTO event_log (aggregate_id, event_type, occurred_at, payload)
VALUES ($1, $2, now(), $3)`,
[p.customerId, p.eventType, JSON.stringify(p)],
);
}
async function loadPayload(id: number): Promise<EventPayload> {
const { rows } = await pool.query<{ payload: EventPayload }>(
`SELECT payload FROM event_log WHERE id = $1`,
[id],
);
return rows[0].payload; // pg десериализует jsonb в объект автоматически
}
from dataclasses import dataclass, asdict
from typing import Any
import psycopg
from psycopg.rows import dict_row
from psycopg.types.json import Jsonb
@dataclass
class EventPayload:
customer_id: str
event_type: str
occurred_at: str
details: dict[str, Any]
async def insert_event(conn: psycopg.AsyncConnection, p: EventPayload) -> None:
await conn.execute(
"""
INSERT INTO event_log (aggregate_id, event_type, occurred_at, payload)
VALUES (%s, %s, now(), %s)
""",
(p.customer_id, p.event_type, Jsonb(asdict(p))),
)
async def load_payload(conn: psycopg.AsyncConnection, event_id: int) -> EventPayload:
async with conn.cursor(row_factory=dict_row) as cur:
await cur.execute("SELECT payload FROM event_log WHERE id = %s", (event_id,))
row = await cur.fetchone()
return EventPayload(**row["payload"])
Как удержать хоть какую-то схему
«Схему никто не знает» — главная цена jsonb, но совсем беззащитным документ быть не обязан. Три недорогих способа.
Проверить, что в колонке вообще объект, а не число и не массив: CHECK (jsonb_typeof(payload) = 'object'). Проверить обязательные ключи: CHECK (payload ?& array['type', 'version']). И проверить тип значения там, где на него опирается код: CHECK (jsonb_typeof(payload->'items') = 'array').
Уникальность тоже достижима — индексом по выражению: CREATE UNIQUE INDEX ... ON events ((payload->>'external_id')) не даст записать два документа с одним внешним идентификатором. Тот же приём годится и для обычного поиска, когда ключ один и горячий.
И ограничение самого типа, о котором стоит знать заранее: jsonb хранит не текст документа, а его разобранное представление. Пробелы теряются, порядок ключей не сохраняется, дубли ключей схлопываются (остаётся последний), а числа приводятся к numeric — 1e2 при чтении станет 100, 1.10 станет 1.10, но 0100 станет 100. Для «снимка ответа внешней службы», который потом придётся показать как доказательство, это важно: если нужен байт в байт тот самый ответ, его хранят в text (или json, который текст сохраняет), а jsonb заводят рядом — для поиска.
Распространённые ошибки
Всё в одну колонку data jsonb. Кажется гибким, на деле — потеря типизации, индексов и читаемости. Через год никто не знает, какие ключи там есть и что обязательно.
Поле, по которому ищут, лежит в JSONB без индекса. Email, статус, идентификатор — это колонки.
JSONB вместо миграций. Добавлять новые «поля» через JSON-ключи, чтобы не писать ALTER TABLE, — соблазнительно, но опасно. Схема расходится между сервисами, данные перестают валидироваться, схему никто не знает.
Коротко
- Почти всегда нужен
jsonb, неjson— двоичное хранение, быстрый поиск, поддержка GIN. - Если по полю фильтруют, сортируют или делают JOIN — это колонка, не JSON-ключ.
- JSONB хорошо подходит для: журналов событий, полиморфных атрибутов, конфигураций интеграций, снимков внешних API.
->возвращаетjsonb,->>—text;@>проверяет вхождение подмножества.- GIN с
jsonb_path_opsускоряет@>; функциональный индекс дешевле для одного конкретного ключа. - Двоичные данные, длинные тексты и base64 в JSONB не кладут, а сам документ не должен вырастать больше нескольких килобайт: обновление одного ключа переписывает его целиком.
- Документ правят
jsonb_set,||,-и#-;jsonb_setне создаёт отсутствующий путь без четвёртого аргумента и возвращаетNULLотNULL-колонки. - «Ключа нет» и «значение null» различает только оператор
?, а в JDBC его пишут как??или заменяют наjsonb_exists(…). - Статистики по содержимому документа нет: оценка
@>фиксированная, и планы врут — горячее поле выносят в колонку. Схему держатCHECKпоjsonb_typeofи?&, уникальность — индексом по выражению.
Что почитать дальше
- Массивы и range-типы в PostgreSQL — когда массив лучше JSONB для списков.
- Индексы в PostgreSQL — подробнее о GIN и других типах индексов.
- Строковые типы в PostgreSQL — когда использовать text вместо JSONB.
- EXPLAIN ANALYZE — как убедиться, что индекс по JSONB действительно работает.