← назад к разделу

Иногда структура данных заранее не известна. Разные товары в каталоге — разные атрибуты. Разные события в журнале аудита — разные поля. Добавлять под каждый случай отдельную колонку неудобно. Именно для таких случаев PostgreSQL умеет хранить JSON прямо в колонке.

payload @> '{"event_type": "ORDER_PAID"}' без индекса event_log с GIN-индексом "event_type": "USER_REGISTERED" "event_type": "CART_UPDATED" "event_type": "MAIL_SENT" "event_type": "ORDER_PAID" "event_type": "ORDER_SHIPPED" прочитано 5 из 5 GIN (jsonb_path_ops) ORDER_PAID → 4 прочитано 1 из 5

Без индекса условие с @> проверяют у каждой строки — база читает таблицу целиком, даже когда подходит одна запись. 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 и ?&, уникальность — индексом по выражению.

Что почитать дальше