sqlpostgresqljsonbjson

The JSONB @> Operator in PostgreSQL: Document Containment and GIN-Index Acceleration

How the @> operator checks whether a JSONB document contains a fragment, matches nested objects and array members, rides a GIN index, and differs from ->> filters.

9 min lukuaikaReferencesql · postgresql · jsonb · json · indexing
Tämä artikkeli on tällä hetkellä venäjäksi — englanninkielinen käännös on työn alla.

В PostgreSQL тип jsonb часто используют для гибких структурированных данных.

Например, в таблице users может быть колонка payload:

{
  "plan": "pro",
  "active": true,
  "tags": ["beta", "eu"],
  "settings": {
    "theme": "dark",
    "language": "ru"
  }
}

Иногда нужно не просто достать одно поле через ->>.

Иногда нужно спросить у JSON-документа:

Содержишь ли ты вот такой фрагмент?

Например:

{"plan": "pro"}

или:

{"settings": {"theme": "dark"}}

Для такой проверки в PostgreSQL есть оператор:

@>

Он называется containment operator.

По-русски его удобно понимать как:

содержит

Запрос:

payload @> '{"plan":"pro"}'

означает:

payload содержит JSON-фрагмент {"plan":"pro"}?

Если да — условие вернёт true.

Что делает оператор @>

Оператор @> проверяет, содержит ли левый jsonb правый jsonb-фрагмент.

Пример:

SELECT
  id,
  email
FROM users
WHERE payload @> '{"plan":"pro"}';

Этот запрос найдёт пользователей, у которых в payload есть:

{
  "plan": "pro"
}

При этом в самом payload могут быть и другие поля.

Например, такой документ подходит:

{
  "plan": "pro",
  "active": true,
  "tags": ["beta", "eu"],
  "settings": {
    "theme": "dark"
  }
}

Почему подходит?

Потому что он содержит нужный фрагмент:

{"plan": "pro"}

Оператор @> не требует, чтобы документы были равны целиком. Он проверяет именно наличие фрагмента.

@> — это не равенство

Важно не путать @> с обычным равенством.

Равенство:

payload = '{"plan":"pro"}'::jsonb

означает:

payload полностью равен этому JSON?

А containment:

payload @> '{"plan":"pro"}'

означает:

payload содержит этот JSON-фрагмент?

Пример:

{
  "plan": "pro",
  "active": true
}

Для такого документа:

payload = '{"plan":"pro"}'::jsonb

вернёт false, потому что в документе есть ещё active.

А:

payload @> '{"plan":"pro"}'

вернёт true, потому что нужный фрагмент внутри есть.

Можно запомнить так:

=   проверяет полное равенство
@>  проверяет содержание фрагмента

Базовый пример

Допустим, есть таблица users:

id | email         | payload
---+---------------+------------------------------------------------
1  | anna@mail.com | {"plan":"pro","active":true}
2  | bob@mail.com  | {"plan":"free","active":true}
3  | max@mail.com  | {"plan":"pro","active":false}

Найдём всех пользователей с тарифом pro:

SELECT
  id,
  email
FROM users
WHERE payload @> '{"plan":"pro"}';

Результат:

id | email
---+---------------
1  | anna@mail.com
3  | max@mail.com

Пользователь 2 не попал, потому что у него:

{"plan": "free"}

Несколько условий внутри одного JSON-фрагмента

В правом фрагменте можно указать несколько полей.

Например, найти пользователей, у которых:

  • plan = "pro";
  • active = true.
SELECT
  id,
  email
FROM users
WHERE payload @> '{"plan":"pro","active":true}';

Такой фрагмент:

{"plan":"pro","active":true}

означает:

в payload должны быть оба поля с такими значениями

Подойдёт документ:

{
  "plan": "pro",
  "active": true,
  "tags": ["beta"]
}

Не подойдёт:

{
  "plan": "pro",
  "active": false
}

И не подойдёт:

{
  "plan": "free",
  "active": true
}

То есть поля внутри правого JSON-фрагмента работают как несколько условий одновременно.

Типы должны совпадать строго

jsonb различает типы.

Например, это boolean:

{"active": true}

А это строка:

{"active": "true"}

Для PostgreSQL это разные значения.

Запрос:

SELECT id
FROM users
WHERE payload @> '{"active":true}';

найдёт документы, где active — именно JSON boolean true.

Он не найдёт документы, где active хранится строкой:

{"active": "true"}

То же самое с числами.

Это число:

{"priority": 5}

А это строка:

{"priority": "5"}

Запрос:

WHERE payload @> '{"priority":5}'

не совпадает с:

{"priority": "5"}

Главное правило:

В jsonb @> важны не только ключи и значения, но и JSON-типы.

Это одна из причин, почему важные поля лучше хранить в обычных колонках правильного типа, а не только в JSON.

Вложенные объекты

Оператор @> хорошо работает с вложенными объектами.

Допустим, в заказе есть адрес доставки:

{
  "shipping": {
    "country": "DE",
    "city": "Berlin",
    "zip": "10115"
  }
}

Чтобы найти заказы с доставкой в Германию, можно написать:

SELECT
  id,
  amount
FROM orders
WHERE payload @> '{"shipping":{"country":"DE"}}';

Этот запрос не требует, чтобы внутри shipping было только поле country.

Документ подходит, даже если там есть ещё city, zip и другие поля:

{
  "shipping": {
    "country": "DE",
    "city": "Berlin",
    "zip": "10115"
  }
}

Потому что он содержит фрагмент:

{
  "shipping": {
    "country": "DE"
  }
}

Важное уточнение про «глубину»

Иногда говорят, что @> ищет фрагмент «на любой глубине». Это можно понять неправильно.

@> не делает свободный поиск ключа по всему документу.

Например, если документ такой:

{
  "shipping": {
    "country": "DE"
  }
}

то условие:

payload @> '{"country":"DE"}'

не сработает.

Почему?

Потому что на верхнем уровне нет ключа country.

Правильно указать путь структурой фрагмента:

payload @> '{"shipping":{"country":"DE"}}'

То есть @> умеет проверять вложенные структуры, но вы должны описать нужную вложенность в правом JSON-фрагменте.

Массивы: порядок обычно не важен

@> также работает с массивами.

Допустим, у пользователя есть теги:

{
  "tags": ["beta", "eu", "paid"]
}

Найти пользователей с тегом beta:

SELECT
  id,
  email
FROM users
WHERE payload @> '{"tags":["beta"]}';

Найти пользователей, у которых есть оба тега beta и eu:

SELECT
  id,
  email
FROM users
WHERE payload @> '{"tags":["beta","eu"]}';

Порядок элементов в массиве не важен.

Такой документ подходит:

{
  "tags": ["eu", "paid", "beta"]
}

Потому что в нём есть и beta, и eu.

Можно запомнить так:

Для JSONB-массивов @> проверяет наличие элементов, а не их позицию.

Примеры с массивами

Проверим идею на простых JSONB-массивах.

SELECT
  '[1,2,3]'::jsonb @> '[2,1]'::jsonb AS contains;

Результат:

contains
--------
true

Порядок справа [2,1] не совпадает с порядком слева [1,2,3], но containment всё равно истинный.

А вот так:

SELECT
  '[1,2]'::jsonb @> '[1,2,3]'::jsonb AS contains;

Результат:

contains
--------
false

Потому что слева нет элемента 3.

Ещё одна особенность: дубликаты в JSONB-массивах для containment обычно не играют роль как отдельные обязательные копии.

Например, если слева есть 1, это не значит, что нужно хранить две копии 1, чтобы пройти проверку фрагмента с двумя единицами. Поэтому не стоит использовать @> для задач, где важно количество одинаковых элементов в массиве.

Массив объектов

Очень полезный кейс — массив объектов.

Допустим, в заказе есть товары:

{
  "items": [
    {"sku": "A-100", "qty": 2},
    {"sku": "B-200", "qty": 1}
  ]
}

Найти заказы, где среди товаров есть SKU A-100:

SELECT
  id
FROM orders
WHERE payload @> '{"items":[{"sku":"A-100"}]}';

Почему это работает?

Потому что объект:

{"sku":"A-100","qty":2}

содержит фрагмент:

{"sku":"A-100"}

А массив items содержит объект, который подходит под этот фрагмент.

Это очень удобно: не нужно разворачивать массив через jsonb_array_elements, если достаточно проверить наличие подходящего объекта.

Массив объектов: несколько условий

Можно указать несколько полей внутри объекта.

Например, найти заказы, где есть товар A-100 с количеством 2:

SELECT
  id
FROM orders
WHERE payload @> '{"items":[{"sku":"A-100","qty":2}]}';

Такой документ подойдёт:

{
  "items": [
    {"sku": "A-100", "qty": 2, "price": 99.90}
  ]
}

Потому что объект внутри массива содержит нужные поля:

{"sku":"A-100","qty":2}

Но такой документ не подойдёт:

{
  "items": [
    {"sku": "A-100", "qty": 1}
  ]
}

Потому что qty не совпадает.

Пустой объект и пустой массив

С пустыми фрагментами нужно быть аккуратным.

Пустой объект:

{}

содержится почти в любом JSON-объекте.

Например:

SELECT
  '{"plan":"pro"}'::jsonb @> '{}'::jsonb AS contains_empty_object;

Результат:

contains_empty_object
---------------------
true

Поэтому условие:

payload @> '{}'

обычно бесполезно: оно будет истинно почти для всех объектов.

Похожая осторожность нужна с пустыми массивами.

Фрагмент:

{"tags":[]}

проверяет наличие совместимой структуры, но не означает «массив tags непустой».

Если нужно проверить, что ключ есть, используйте оператор ?.

Если нужно проверить длину массива, используйте функции вроде jsonb_array_length.

Например:

SELECT id
FROM users
WHERE payload ? 'tags'
  AND jsonb_typeof(payload -> 'tags') = 'array'
  AND jsonb_array_length(payload -> 'tags') > 0;

Зеркальный оператор <@

У @> есть зеркальный оператор:

<@

Выражение:

a <@ b

означает:

a содержится в b

То есть:

a @> b

и:

b <@ a

по смыслу зеркальны.

Пример:

SELECT
  '{"plan":"pro"}'::jsonb <@ '{"plan":"pro","active":true}'::jsonb AS result;

Результат:

result
------
true

На практике чаще используют @>, потому что его легче читать:

payload @> '{"plan":"pro"}'

То есть:

payload содержит такой фрагмент

@> против ->>

Теперь сравним два подхода.

Через ->>:

SELECT id
FROM users
WHERE payload ->> 'plan' = 'pro';

Через @>:

SELECT id
FROM users
WHERE payload @> '{"plan":"pro"}';

Оба запроса могут найти пользователей с тарифом pro.

Но смысл немного разный.

->> говорит:

достань поле plan как текст и сравни со строкой 'pro'

@> говорит:

проверь, содержит ли JSON-фрагмент {"plan":"pro"}

Для простого строкового поля оба варианта понятны.

Но для вложенных объектов и массивов @> часто читается лучше.

Например:

WHERE payload @> '{"shipping":{"country":"DE"}}'

или:

WHERE payload @> '{"tags":["beta","eu"]}'

Это компактнее, чем писать несколько извлечений через -> и ->>.

Когда лучше использовать @>

@> хорошо подходит, когда условие естественно описывается JSON-фрагментом.

Например:

payload @> '{"plan":"pro"}'
payload @> '{"active":true}'
payload @> '{"shipping":{"country":"DE"}}'
payload @> '{"tags":["beta","eu"]}'
payload @> '{"items":[{"sku":"A-100"}]}'

Особенно хорошо @> подходит, когда:

  • нужно проверить несколько полей сразу;
  • структура вложенная;
  • нужно проверить наличие элементов массива;
  • нужно использовать один GIN-индекс на JSONB-колонку;
  • набор ключей гибкий и заранее не фиксирован полностью.

Когда @> не подходит

@> проверяет containment, но не умеет делать диапазонные сравнения.

Например, так нельзя выразить:

total > 100
created_at < 2026-01-01
priority >= 5

Фрагмент:

{"total": 100}

означает именно:

total равен 100

А не:

total больше 100

Для диапазонов нужен другой подход:

SELECT id
FROM orders
WHERE (payload ->> 'total')::numeric > 100;

Или лучше — отдельная колонка:

orders.total numeric

Если поле часто используется в фильтрах по диапазону, сортировках и агрегатах, хранить его только внутри JSONB обычно не лучшая идея.

GIN-индекс для JSONB

Главное преимущество @> в PostgreSQL — он может хорошо работать с GIN-индексом.

Без индекса запрос:

SELECT id
FROM users
WHERE payload @> '{"plan":"pro"}';

может читать всю таблицу.

На большой таблице это дорого.

Можно создать GIN-индекс:

CREATE INDEX users_payload_gin_idx
ON users
USING gin (payload);

После этого PostgreSQL может использовать индекс для containment-запросов:

SELECT id
FROM users
WHERE payload @> '{"plan":"pro"}';

Чтобы проверить, используется ли индекс, смотрите план:

EXPLAIN ANALYZE
SELECT id
FROM users
WHERE payload @> '{"plan":"pro"}';

Если видите что-то вроде:

Bitmap Index Scan

по вашему GIN-индексу, значит индекс подхватился.

jsonb_ops и jsonb_path_ops

Для GIN-индекса по jsonb есть разные классы операторов.

Самый обычный вариант:

CREATE INDEX users_payload_gin_idx
ON users
USING gin (payload);

Это индекс с операторным классом по умолчанию, обычно jsonb_ops.

Он поддерживает разные JSONB-операторы, включая containment @> и операторы существования ключей вроде:

?
?|
?&

Например:

payload ? 'plan'

проверяет наличие ключа plan.

Есть другой вариант:

CREATE INDEX users_payload_path_gin_idx
ON users
USING gin (payload jsonb_path_ops);

jsonb_path_ops обычно меньше и быстрее именно для запросов containment через @>.

Но он не подходит для операторов существования ключей вроде ?.

То есть:

payload @> '{"plan":"pro"}'

может хорошо использовать jsonb_path_ops.

А:

payload ? 'plan'

обычно требует индекс с jsonb_ops, если вы хотите ускорять именно такой оператор.

Коротко:

jsonb_ops       -- шире по поддержке операторов, индекс обычно больше
jsonb_path_ops  -- компактнее для @>, но уже по возможностям

Какой GIN-индекс выбрать

Если у вас разные типы JSONB-запросов:

payload @> '{"plan":"pro"}'
payload ? 'tags'
payload ?| array['plan', 'active']

то чаще выбирают обычный индекс:

CREATE INDEX users_payload_gin_idx
ON users
USING gin (payload);

Если у вас почти все частые запросы именно containment:

payload @> '{"plan":"pro"}'
payload @> '{"settings":{"theme":"dark"}}'
payload @> '{"tags":["beta"]}'

можно рассмотреть:

CREATE INDEX users_payload_path_gin_idx
ON users
USING gin (payload jsonb_path_ops);

Но выбор лучше подтверждать на реальных запросах через:

EXPLAIN ANALYZE

Не стоит создавать индексы «на всякий случай». GIN-индексы могут быть большими и замедлять вставки и обновления.

GIN-индекс — не всегда лучший вариант

GIN-индекс по всей JSONB-колонке удобен, когда вы ищете по разным ключам и фрагментам.

Но если вы постоянно фильтруете по одному конкретному полю, иногда лучше выражающий B-tree индекс.

Например, частый запрос:

SELECT id
FROM users
WHERE payload ->> 'plan' = 'pro';

Индекс:

CREATE INDEX users_payload_plan_idx
ON users ((payload ->> 'plan'));

Такой индекс будет проще и точнее для конкретного поля plan.

Для числового поля:

CREATE INDEX orders_payload_total_idx
ON orders (((payload ->> 'total')::numeric));

Но с числовыми cast-индексами нужно быть осторожным: если в JSON есть грязные значения, приведение типа может падать.

Общее правило:

Много разных containment-запросов по JSONB — смотрите в сторону GIN. Один стабильный ключ — часто лучше выражающий индекс. Важное бизнес-поле — лучше отдельная колонка.

@> и параметры запроса

В примерах часто пишут JSON-фрагмент строкой:

WHERE payload @> '{"plan":"pro"}'

PostgreSQL обычно понимает тип по контексту оператора.

Но в приложениях лучше явно передавать параметр как jsonb.

Например:

WHERE payload @> $1::jsonb

И передавать значение:

{"plan":"pro"}

Это безопаснее и удобнее, чем склеивать JSON-строку вручную.

Особенно если значения приходят от пользователя.

Не собирайте такие условия строковой конкатенацией:

'{"plan":"' || user_input || '"}'

Лучше используйте параметры.

@> и проверка ключа через ?

Иногда нужно просто проверить, что ключ существует.

Для этого лучше использовать не @>, а оператор ?.

Например:

SELECT id
FROM users
WHERE payload ? 'plan';

Это означает:

на верхнем уровне JSONB есть ключ plan?

А если нужно проверить и наличие ключа, и значение:

SELECT id
FROM users
WHERE payload ? 'plan'
  AND payload @> '{"plan":"pro"}';

Хотя второе условие уже подразумевает наличие plan со значением pro, отдельная проверка ключа бывает полезна, когда вы хотите различать случаи:

  • ключа нет;
  • ключ есть, но значение другое;
  • ключ есть, но значение null.

Для вложенных ключей сначала перейдите к вложенному объекту:

SELECT id
FROM users
WHERE (payload -> 'settings') ? 'theme';

@> и JSON null

С JSON null нужно быть внимательным.

Документ:

{"coupon": null}

содержит фрагмент:

{"coupon": null}

То есть:

SELECT
  '{"coupon":null}'::jsonb @> '{"coupon":null}'::jsonb AS result;

вернёт:

result
------
true

Но документ без ключа:

{}

не содержит этот фрагмент:

SELECT
  '{}'::jsonb @> '{"coupon":null}'::jsonb AS result;

вернёт:

result
------
false

Это отличие от ->>, где и отсутствующий ключ, и JSON null часто дают SQL NULL.

Через @> можно явно проверить наличие ключа со значением JSON null.

Практические примеры

Найти пользователей с тарифом pro

SELECT
  id,
  email
FROM users
WHERE payload @> '{"plan":"pro"}';

Найти активных pro-пользователей

SELECT
  id,
  email
FROM users
WHERE payload @> '{"plan":"pro","active":true}';

Найти пользователей с тегами beta и eu

SELECT
  id,
  email
FROM users
WHERE payload @> '{"tags":["beta","eu"]}';

Найти заказы с доставкой в Германию

SELECT
  id,
  amount
FROM orders
WHERE payload @> '{"shipping":{"country":"DE"}}';

Найти заказы с конкретным SKU

SELECT
  id
FROM orders
WHERE payload @> '{"items":[{"sku":"A-100"}]}';

Найти заказы с SKU и количеством

SELECT
  id
FROM orders
WHERE payload @> '{"items":[{"sku":"A-100","qty":2}]}';

Проверить наличие ключа

SELECT
  id
FROM users
WHERE payload ? 'plan';

Проверить JSON null через containment

SELECT
  id
FROM orders
WHERE payload @> '{"coupon":null}';

Создать обычный GIN-индекс

CREATE INDEX users_payload_gin_idx
ON users
USING gin (payload);

Создать GIN-индекс для @>

CREATE INDEX users_payload_path_gin_idx
ON users
USING gin (payload jsonb_path_ops);

Выражающий индекс по одному ключу

CREATE INDEX users_payload_plan_idx
ON users ((payload ->> 'plan'));

Проверить план

EXPLAIN ANALYZE
SELECT
  id
FROM users
WHERE payload @> '{"plan":"pro"}';

MySQL: JSON_CONTAINS

В MySQL нет PostgreSQL-оператора @>.

Похожая идея реализуется через функцию:

JSON_CONTAINS()

Пример:

SELECT
  id
FROM users
WHERE JSON_CONTAINS(payload, '{"plan":"pro"}');

Для вложенного объекта:

SELECT
  id
FROM orders
WHERE JSON_CONTAINS(payload, '{"shipping":{"country":"DE"}}');

Для извлечения конкретного поля в MySQL часто используют:

payload ->> '$.plan'

или:

JSON_UNQUOTE(JSON_EXTRACT(payload, '$.plan'))

Идея похожая, но синтаксис отличается от PostgreSQL.

ClickHouse: чаще JSONExtract-функции

В ClickHouse обычно нет прямого аналога PostgreSQL jsonb @> как универсального containment-оператора.

Чаще JSON разбирают через функции.

Например:

SELECT
  id
FROM users
WHERE JSONExtractString(payload, 'plan') = 'pro';

Для числа:

SELECT
  id
FROM orders
WHERE JSONExtractFloat(payload, 'total') > 100;

Если нужно работать с массивами или вложенными структурами, подход зависит от формата данных, версии ClickHouse и выбранных JSON-функций.

При переносе запросов из PostgreSQL в ClickHouse containment-логику обычно приходится переписывать явно.

Частые ошибки

Перепутали @> и ->>

@> проверяет JSON-фрагмент:

payload @> '{"plan":"pro"}'

->> извлекает поле как текст:

payload ->> 'plan' = 'pro'

Оба варианта могут работать для простого поля, но это разные операции.

Ожидали поиск ключа на любой глубине

Не сработает:

payload @> '{"country":"DE"}'

если JSON такой:

{"shipping":{"country":"DE"}}

Правильно:

payload @> '{"shipping":{"country":"DE"}}'

Не учли строгие JSON-типы

Не одно и то же:

{"active": true}

и:

{"active": "true"}

Запрос:

payload @> '{"active":true}'

не найдёт строку со строковым "true".

Использовали @> для диапазона

Так нельзя выразить:

total > 100

@> проверяет наличие конкретного фрагмента, а не больше/меньше.

Для диапазона:

(payload ->> 'total')::numeric > 100

или отдельная колонка.

Создали GIN-индекс без реальной необходимости

GIN-индекс полезен, но не бесплатен.

Он занимает место и может замедлять запись.

Перед добавлением индекса проверьте:

  • как часто выполняется запрос;
  • сколько строк в таблице;
  • какой план без индекса;
  • какой план после индекса;
  • насколько часто обновляется JSONB-колонка.

Что важно запомнить

@> проверяет, содержит ли JSONB-документ заданный JSONB-фрагмент.

Пример:

SELECT
  id
FROM users
WHERE payload @> '{"plan":"pro"}';

Главные правила:

  • левый JSONB должен содержать правый JSONB-фрагмент;
  • это не равенство всего документа;
  • документ может иметь дополнительные ключи;
  • типы должны совпадать строго;
  • вложенные объекты нужно описывать вложенным фрагментом;
  • @> не ищет ключ автоматически на любой глубине;
  • для массивов порядок элементов обычно не важен;
  • массив объектов можно проверять по частичному объекту;
  • @> хорошо сочетается с GIN-индексом;
  • jsonb_ops шире по операторам;
  • jsonb_path_ops компактнее для containment-запросов;
  • для диапазонов и сортировок @> не подходит;
  • для одного частого поля часто лучше выражающий индекс или обычная колонка.

Короткий вывод

JSONB @> — это оператор для проверки, содержит ли JSONB-документ нужный фрагмент.

Например:

payload @> '{"plan":"pro"}'

означает:

payload содержит поле plan со значением pro

Для вложенной структуры:

payload @> '{"shipping":{"country":"DE"}}'

Для массива тегов:

payload @> '{"tags":["beta","eu"]}'

Главная мысль:

@> используем, когда условие удобно описать JSON-фрагментом.

Если таких запросов много, добавляйте GIN-индекс:

CREATE INDEX users_payload_gin_idx
ON users
USING gin (payload);

Но если вы постоянно фильтруете одно и то же поле, особенно числовое или датовое, лучше подумать о выражающем индексе или отдельной колонке правильного типа.

jsonb даёт гибкость, @> даёт удобный containment-поиск, а GIN-индекс помогает сделать такие запросы быстрыми на больших таблицах.

Harjoittele oikeilla tehtävillä

Ratkaise tehtäviä SQL-harjoittelussa välittömällä arvioinnilla ja vihjeillä.

Avaa harjoittelu