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

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

json и jsonb — в чём разница

PostgreSQL предлагает два типа: json и jsonb. Разница принципиальная.

json хранит текст как есть — буквально строку с фигурными скобками. При каждом запросе PostgreSQL заново разбирает эту строку, что медленно. Зато json сохраняет порядок ключей и дубликаты (редко нужно, но иногда важно).

jsonb хранит данные в двоичном разобранном виде — как готовую структуру. Читать и искать по ней быстро. Порядок ключей не сохраняется, дубликаты убираются (последний выигрывает).

Практически всегда нужен jsonb. Исключение — когда принципиально важно сохранить входную строку бит-в-бит (например, для криптографической подписи).

Главное правило: колонка или JSON-ключ

Перед тем как убрать поле в 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 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 -> 'customer'           AS customer_obj,   -- возвращает jsonb
    payload ->> 'event_type'        AS type,           -- возвращает text
    payload #> '{order, items, 0}'  AS first_item,     -- путь → jsonb
    payload #>> '{order, total}'    AS total_str       -- путь → text
FROM event_log;

Оператор -> даёт вложенный объект (тип 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'];

Индексы — как ускорить поиск по JSONB

Без индекса поиск по JSONB — полный перебор. Есть два варианта.

GIN-индекс подходит, когда фильтруют по разным ключам или вложенным объектам через оператор @>. Вариант jsonb_path_ops меньше и быстрее стандартного GIN, но работает только с @>:

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

JSONB — для структурированных документов небольшого размера. Несколько вещей туда не стоит класть:

  • Двоичные данные (изображения, файлы) — для них есть bytea или внешнее хранилище.
  • Длинные тексты (статьи, описания) — лучше text, PostgreSQL сам разместит большие значения эффективно.
  • Данные в base64 — это двоичное содержимое, закодированное в текст с лишними накладными расходами.

Если JSONB-поле вырастает до десятков килобайт на запись — это сигнал пересмотреть дизайн.

Работа с JSONB в коде приложения

Драйвер сериализует объект в JSON перед отправкой в базу и десериализует при чтении. Лучше работать с типизированными объектами — так видна структура, понятно что обязательно.

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
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); }
    }
}

public 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


@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, asdict(p)),  # psycopg сериализует dict автоматически
    )


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"])

Распространённые ошибки

Всё в одну колонку data jsonb. Кажется гибким, на деле — потеря типизации, индексов и читаемости. Через год никто не знает, какие ключи там есть и что обязательно.

Поле, по которому ищут, лежит в JSONB без индекса. Каждый такой запрос — полный перебор. Email, статус, идентификатор — это колонки.

JSONB вместо миграций. Добавлять новые «поля» через JSON-ключи, чтобы не писать ALTER TABLE, — соблазнительно, но опасно. Схема расходится между сервисами, данные перестают валидироваться, схему никто не знает.

Слишком большие документы. JSONB не предназначен для мегабайтных объектов. Если документ растёт — пора разбить на колонки или вынести в отдельное хранилище.

Коротко

  • Почти всегда нужен jsonb, не json — двоичное хранение, быстрый поиск, поддержка GIN.
  • Если по полю фильтруют, сортируют или делают JOIN — это колонка, не JSON-ключ.
  • JSONB хорошо подходит для: журналов событий, полиморфных атрибутов, конфигураций интеграций, снимков внешних API.
  • -> возвращает jsonb, ->>text; @> проверяет вхождение подмножества.
  • GIN с jsonb_path_ops ускоряет @>; функциональный индекс дешевле для одного конкретного ключа.
  • Двоичные данные, длинные тексты и base64 в JSONB не кладут.
  • Документ не должен вырастать больше нескольких килобайт на запись.

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

  • Массивы и range-типы в PostgreSQL — когда массив лучше JSONB для списков.
  • Индексы в PostgreSQL — подробнее о GIN и других типах индексов.
  • Строковые типы в PostgreSQL — когда использовать text вместо JSONB.