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