Тази статия в момента е на руски — английският превод е в процес на изготвяне.
В PostgreSQL тип jsonb позволяет хранить структурированные данные прямо в колонке.
Например, в заказе может лежать поле payload:
{
"channel": "mobile",
"total": 1500,
"currency": "USD",
"customer": {
"email": "anna@mail.com",
"country": "Vietnam"
}
}
Это удобно, когда часть данных приходит из API, внешней системы, формы, webhook или события.
Но почти всегда в какой-то момент нужно достать из JSON конкретное значение:
- канал заказа;
- e-mail пользователя;
- тариф из настроек;
- сумму из метаданных;
- город доставки;
- флаг включённой функции;
- id внешней системы.
В PostgreSQL для этого часто используют оператор:
->>
Он достаёт поле из jsonb и возвращает его как обычный text.
Например:
payload ->> 'channel'
вернёт:
mobile
А не JSON-строку с кавычками.
Разберём, чем ->> отличается от ->, как доставать вложенные поля, как приводить результат к числу и почему отсутствующий ключ молча даёт NULL.
Что делает оператор ->>
Оператор ->> достаёт значение из JSON/JSONB по ключу и возвращает его как текст.
Допустим, есть таблица orders:
id | payload
---+--------------------------------------------------------------
1 | {"channel": "mobile", "total": 1500, "currency": "USD"}
2 | {"channel": "web", "total": 2300, "currency": "EUR"}
Запрос:
SELECT
id,
payload ->> 'channel' AS channel
FROM orders;
Результат:
id | channel
---+---------
1 | mobile
2 | web
Значение channel достали из JSONB и получили обычный текст.
Это значит, что с ним можно работать как с текстовой колонкой:
WHERE payload ->> 'channel' = 'mobile'
или:
GROUP BY payload ->> 'channel'
-> и ->>: главная разница
В PostgreSQL есть два похожих оператора:
->
->>
Они оба достают значение из JSON, но возвращают разные типы.
Оператор -> возвращает значение как jsonb.
Оператор ->> возвращает значение как text.
Пример:
SELECT
payload -> 'channel' AS as_jsonb,
payload ->> 'channel' AS as_text
FROM orders;
Если в payload лежит:
{"channel": "mobile"}
то результат будет таким:
as_jsonb | as_text
---------+--------
"mobile" | mobile
В колонке as_jsonb значение осталось JSONB-значением, поэтому строка отображается с кавычками.
В колонке as_text значение стало обычным текстом, поэтому кавычек уже нет.
Можно запомнить так:
-> возвращает JSONB
->> возвращает text
Когда использовать ->, а когда ->>
Простое правило:
-> используем, когда хотим продолжить идти внутрь JSON.
->> используем в конце, когда хотим получить готовое значение.
Например, есть JSON:
{
"customer": {
"email": "anna@mail.com",
"country": "Vietnam"
}
}
Чтобы достать email, сначала нужно войти в объект customer, а потом взять поле email как текст.
SELECT
payload -> 'customer' ->> 'email' AS email
FROM orders;
Здесь:
payload -> 'customer'
возвращает вложенный JSONB-объект:
{"email": "anna@mail.com", "country": "Vietnam"}
А затем:
->> 'email'
достаёт из него текст:
anna@mail.com
Если написать так:
payload ->> 'customer'
вы получите весь объект customer как текстовую строку. Это иногда нужно, но для дальнейшей навигации по JSON уже неудобно.
Вложенные поля через цепочку операторов
Для вложенных JSON-структур операторы можно соединять в цепочку.
Пример JSON:
{
"shipping": {
"address": {
"city": "Berlin",
"zip": "10115"
}
}
}
Чтобы достать город:
SELECT
payload -> 'shipping' -> 'address' ->> 'city' AS city
FROM orders;
Логика такая:
payload
-> shipping
-> address
->> city
То есть:
payload -> 'shipping'
возвращает JSONB.
-> 'address'
тоже возвращает JSONB.
->> 'city'
возвращает финальное значение как текст.
Результат:
city
------
Berlin
Альтернатива для вложенных путей: #>>
Для вложенных полей в PostgreSQL есть ещё оператор:
#>>
Он достаёт значение по пути и сразу возвращает текст.
Например:
SELECT
payload #>> '{shipping,address,city}' AS city
FROM orders;
Это эквивалентно:
SELECT
payload -> 'shipping' -> 'address' ->> 'city' AS city
FROM orders;
Оператор #>> удобен, когда путь длинный.
Сравните:
payload -> 'shipping' -> 'address' -> 'geo' ->> 'lat'
и:
payload #>> '{shipping,address,geo,lat}'
Оба варианта рабочие.
Для новичка часто понятнее цепочка -> и ->>, потому что видно каждый шаг.
Для короткой и аккуратной записи вложенного пути удобно использовать #>>.
Доступ к элементам массива
Операторы -> и ->> можно использовать не только с объектами, но и с массивами.
Допустим, в JSON лежит массив тегов:
{
"tags": ["sql", "postgresql", "jsonb"]
}
Чтобы получить первый тег:
SELECT
payload -> 'tags' ->> 0 AS first_tag
FROM articles;
Результат:
first_tag
---------
sql
Важно: индексация JSON-массивов начинается с 0.
0 -> sql
1 -> postgresql
2 -> jsonb
Если нужен второй тег:
SELECT
payload -> 'tags' ->> 1 AS second_tag
FROM articles;
А если нужно достать весь массив как JSONB:
SELECT
payload -> 'tags' AS tags_jsonb
FROM articles;
->> всегда возвращает text
Очень важный момент: ->> всегда возвращает текст.
Даже если в JSON лежит число.
Например:
{
"items_count": 3,
"total": 1500.50,
"is_paid": true
}
Запрос:
SELECT
payload ->> 'items_count' AS items_count,
payload ->> 'total' AS total,
payload ->> 'is_paid' AS is_paid
FROM orders;
вернёт текстовые значения:
items_count | total | is_paid
------------+---------+--------
3 | 1500.50 | true
Выглядит как число и boolean, но тип результата — text.
Это значит, что для числовых сравнений, сумм и дат нужно явно приводить тип.
Приведение результата к числу
Если вы хотите сравнивать значение как число, нужно привести результат ->> к нужному типу.
Например:
SELECT
id,
(payload ->> 'items_count')::int AS items_count,
(payload ->> 'total')::numeric AS total
FROM orders;
Обратите внимание на скобки:
(payload ->> 'total')::numeric
Это правильная запись.
Плохая запись:
payload ->> 'total'::numeric
Почему плохая?
Потому что PostgreSQL попробует привести строковый литерал 'total' к numeric, а не результат извлечения из JSON.
То есть ::numeric привяжется не туда.
Правило:
Если приводите результат ->> к типу, оборачивайте всё выражение в скобки.
Правильно:
(payload ->> 'total')::numeric
(payload ->> 'items_count')::int
(payload ->> 'created_at')::timestamp
(payload ->> 'is_paid')::boolean
Фильтрация по числу из JSONB
Допустим, нужно найти заказы, у которых total больше 1000.
Если написать так:
SELECT id
FROM orders
WHERE payload ->> 'total' > '1000';
это будет текстовое сравнение, а не числовое.
Текстовое сравнение может дать неожиданный результат, потому что строки сравниваются лексикографически.
Для чисел нужно привести тип:
SELECT id
FROM orders
WHERE (payload ->> 'total')::numeric > 1000;
Теперь PostgreSQL сравнивает значения как числа.
То же самое для сортировки:
SELECT
id,
(payload ->> 'total')::numeric AS total
FROM orders
ORDER BY (payload ->> 'total')::numeric DESC;
Если не привести тип, сортировка будет текстовой.
Например, текстовая сортировка может поставить 900 выше 10000, потому что сравниваются символы, а не числа.
Фильтрация по строке из JSONB
Если значение действительно текстовое, приводить тип не нужно.
Например, канал заказа:
{
"channel": "mobile"
}
Фильтр:
SELECT
id,
user_id
FROM orders
WHERE payload ->> 'channel' = 'mobile';
Группировка:
SELECT
payload ->> 'channel' AS channel,
COUNT(*) AS orders_count
FROM orders
GROUP BY payload ->> 'channel';
Результат:
channel | orders_count
--------+-------------
mobile | 120
web | 95
partner | 30
Для текстовых значений ->> как раз очень удобен: он сразу даёт обычный text.
Агрегация чисел из JSONB
Если в JSONB лежит сумма заказа, её можно агрегировать.
Например:
{
"channel": "mobile",
"total": 1500.50
}
Посчитаем средний total по каналам:
SELECT
payload ->> 'channel' AS channel,
AVG((payload ->> 'total')::numeric) AS avg_total
FROM orders
WHERE payload ? 'total'
GROUP BY payload ->> 'channel';
Здесь:
payload ->> 'channel'
возвращает канал как текст.
А:
(payload ->> 'total')::numeric
достаёт сумму как текст и приводит её к числу.
Без приведения к numeric агрегат AVG не сможет корректно считать среднее по текстовым значениям.
Что будет, если ключа нет
Если ключа в JSONB нет, оператор ->> не падает с ошибкой.
Он возвращает SQL NULL.
Например, есть данные:
{"channel": "mobile", "total": 1500}
А мы пытаемся достать coupon:
SELECT
payload ->> 'coupon' AS coupon
FROM orders;
Результат:
coupon
------
NULL
Это удобно: запрос не ломается из-за отсутствующего ключа.
Но это может быть ловушкой.
Например:
WHERE payload ->> 'coupon' = 'SUMMER'
не вернёт строки без coupon, потому что сравнение NULL = 'SUMMER' не даёт TRUE.
Если нужно найти строки без купона:
SELECT id
FROM orders
WHERE payload ->> 'coupon' IS NULL;
Но здесь есть ещё одна тонкость: NULL может означать разные вещи.
Отсутствующий ключ и JSON null
Допустим, есть два JSON-документа:
{"channel": "mobile"}
и:
{"channel": "mobile", "coupon": null}
В первом случае ключа coupon вообще нет.
Во втором случае ключ есть, но его значение — JSON null.
Если использовать ->>:
SELECT
payload ->> 'coupon' AS coupon
FROM orders;
оба случая дадут SQL NULL.
То есть только по ->> нельзя понять:
- ключ отсутствует;
- ключ есть, но его значение
null.
Если это важно, проверяйте наличие ключа отдельно через оператор ?.
SELECT
payload ? 'coupon' AS has_coupon_key,
payload ->> 'coupon' AS coupon_text
FROM orders;
Результат может быть таким:
has_coupon_key | coupon_text
---------------+------------
false | NULL
true | NULL
true | SUMMER
Теперь видно:
has_coupon_key = false — ключа нет;
has_coupon_key = true, но coupon_text IS NULL — ключ есть, но значение null;
coupon_text = 'SUMMER' — ключ есть и значение текстовое.
Важно: оператор ? проверяет наличие ключа на верхнем уровне объекта.
Для вложенного объекта нужно сначала перейти внутрь:
(payload -> 'customer') ? 'email'
Как отфильтровать строки, где ключ есть
Если нужно выбрать только строки, где в JSON есть ключ channel, используйте:
SELECT id
FROM orders
WHERE payload ? 'channel';
Если нужно выбрать строки, где ключа нет:
SELECT id
FROM orders
WHERE NOT (payload ? 'channel');
Если нужно выбрать строки, где ключ есть и значение не NULL:
SELECT id
FROM orders
WHERE payload ? 'channel'
AND payload ->> 'channel' IS NOT NULL;
Для вложенных ключей:
SELECT id
FROM orders
WHERE (payload -> 'customer') ? 'email';
Но если customer может отсутствовать или быть не объектом, такие запросы нужно тестировать аккуратно.
Грязные данные и ошибки приведения типов
->> возвращает текст.
Если вы приводите этот текст к числу, PostgreSQL попытается выполнить cast.
Например:
(payload ->> 'total')::numeric
Это работает, если значение выглядит как число:
{"total": 1500}
или:
{"total": "1500.50"}
Но если в JSON внезапно лежит пустая строка или текст:
{"total": ""}
или:
{"total": "unknown"}
запрос упадёт с ошибкой.
Например:
SELECT
(payload ->> 'total')::numeric AS total
FROM orders;
может сломаться на одной грязной строке.
Для данных из внешних API это частая проблема.
Как безопаснее приводить JSON-значение к числу
Если данные могут быть грязными, сначала проверьте формат.
Например:
SELECT
id,
CASE
WHEN payload ->> 'total' ~ '^[0-9]+(\.[0-9]+)?$'
THEN (payload ->> 'total')::numeric
ELSE NULL
END AS total
FROM orders;
Этот пример пропускает только положительные числа вроде:
100
1500.50
0.99
Если вам нужны отрицательные числа, можно расширить регулярное выражение:
CASE
WHEN payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$'
THEN (payload ->> 'total')::numeric
ELSE NULL
END
Ещё один частый приём — обработать пустую строку через NULLIF:
(NULLIF(payload ->> 'total', ''))::numeric
Но это спасает только от пустой строки. Если там будет unknown, cast всё равно упадёт.
Поэтому для внешних данных лучше:
- валидировать JSON при записи;
- нормализовать важные поля в отдельные typed-колонки;
- использовать проверки перед cast;
- не строить финансовые отчёты напрямую по грязному JSON без контроля качества данных.
Индекс для частого фильтра по JSON-полю
Если вы часто фильтруете по одному и тому же JSON-полю, можно создать выражающий индекс.
Например, часто есть запрос:
SELECT id
FROM orders
WHERE payload ->> 'channel' = 'mobile';
Тогда можно создать индекс:
CREATE INDEX orders_payload_channel_idx
ON orders ((payload ->> 'channel'));
После этого PostgreSQL сможет использовать индекс для фильтра по этому выражению.
Для числового поля тоже можно создать индекс по приведённому выражению:
CREATE INDEX orders_payload_total_idx
ON orders (((payload ->> 'total')::numeric));
Но здесь нужно быть осторожным: если в данных есть нечисловые значения, создание такого индекса может упасть из-за ошибки приведения.
Для важных полей иногда лучше вынести значение из JSONB в отдельную колонку:
orders.total numeric
orders.channel text
А JSONB оставить для дополнительных метаданных.
JSONB как метаданные, а не замена модели данных
jsonb удобен, но не стоит превращать его в склад всех важных полей.
Если поле активно используется в:
- фильтрах;
- JOIN;
- сортировках;
- агрегатах;
- ограничениях;
- индексах;
- отчётах;
- бизнес-логике,
часто лучше хранить его в обычной колонке правильного типа.
Например, если total — ключевая сумма заказа, лучше иметь:
orders.amount numeric
А не только:
{"total": 1500}
Почему?
Потому что обычную колонку проще:
- индексировать;
- валидировать;
- агрегировать;
- сравнивать;
- защищать constraints;
- использовать в JOIN;
- документировать в схеме.
Хороший паттерн:
Важные стабильные поля — обычными колонками.
Гибкие дополнительные данные — в JSONB.
->> отлично подходит для извлечения метаданных, но не должен заменять нормальную модель данных там, где поле стало частью ядра продукта.
Пример: e-mail из профиля пользователя
Допустим, в таблице users есть колонка profile jsonb.
{
"email": "anna@mail.com",
"name": "Anna",
"settings": {
"language": "ru",
"timezone": "Asia/Singapore"
}
}
Достаём e-mail и язык:
SELECT
id,
profile ->> 'email' AS email,
profile -> 'settings' ->> 'language' AS language
FROM users;
Результат:
id | email | language
---+---------------+---------
1 | anna@mail.com | ru
Если нужно отфильтровать русскоязычных пользователей:
SELECT id
FROM users
WHERE profile -> 'settings' ->> 'language' = 'ru';
Или через #>>:
SELECT id
FROM users
WHERE profile #>> '{settings,language}' = 'ru';
Пример: тариф из настроек
Допустим, есть таблица accounts, а в settings лежит тариф:
{
"billing": {
"plan": "pro",
"seats": 5
}
}
Запрос:
SELECT
id,
settings -> 'billing' ->> 'plan' AS plan,
(settings -> 'billing' ->> 'seats')::int AS seats
FROM accounts;
Фильтр по тарифу:
SELECT id
FROM accounts
WHERE settings -> 'billing' ->> 'plan' = 'pro';
Фильтр по количеству мест:
SELECT id
FROM accounts
WHERE (settings -> 'billing' ->> 'seats')::int >= 5;
Пример: средний чек по каналу из JSONB
Допустим, в orders.payload лежит:
{
"channel": "mobile",
"total": 1500.50
}
Нужно посчитать средний чек по каналам.
SELECT
payload ->> 'channel' AS channel,
AVG((payload ->> 'total')::numeric) AS avg_total,
COUNT(*) AS orders_count
FROM orders
WHERE payload ? 'channel'
AND payload ? 'total'
GROUP BY payload ->> 'channel'
ORDER BY avg_total DESC;
Если данные могут быть грязными, лучше добавить проверку:
SELECT
payload ->> 'channel' AS channel,
AVG((payload ->> 'total')::numeric) AS avg_total,
COUNT(*) AS orders_count
FROM orders
WHERE payload ? 'channel'
AND payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$'
GROUP BY payload ->> 'channel'
ORDER BY avg_total DESC;
Так запрос не упадёт, если где-то в total лежит не число.
->> и LIKE / ILIKE
Так как ->> возвращает текст, его можно использовать с текстовыми операторами.
Например, найти пользователей с e-mail на Gmail:
SELECT id
FROM users
WHERE profile ->> 'email' ILIKE '%@gmail.com';
Или найти заказы, где канал начинается с partner_:
SELECT id
FROM orders
WHERE payload ->> 'channel' LIKE 'partner_%';
Но помните: поиск по подстроке через ILIKE '%text%' может быть дорогим на больших таблицах. Для частых поисков нужны подходящие индексы и отдельная стратегия.
->> и COALESCE
Если ключ может отсутствовать, можно подставить значение по умолчанию через COALESCE.
Например:
SELECT
id,
COALESCE(payload ->> 'channel', 'unknown') AS channel
FROM orders;
Если channel отсутствует или равен JSON null, результат будет:
unknown
Это удобно для отчётов:
SELECT
COALESCE(payload ->> 'channel', 'unknown') AS channel,
COUNT(*) AS orders_count
FROM orders
GROUP BY COALESCE(payload ->> 'channel', 'unknown');
Но используйте COALESCE осознанно. Иногда отсутствие значения и явное значение "unknown" должны различаться.
MySQL: похожий оператор ->>
В MySQL тоже есть оператор ->> для JSON.
Но путь задаётся иначе — через JSONPath.
PostgreSQL:
SELECT
payload ->> 'channel' AS channel
FROM orders;
MySQL:
SELECT
payload ->> '$.channel' AS channel
FROM orders;
В MySQL оператор ->> по смыслу похож на:
JSON_UNQUOTE(JSON_EXTRACT(payload, '$.channel'))
То есть он достаёт значение из JSON и убирает JSON-кавычки.
Для вложенного поля:
SELECT
payload ->> '$.customer.email' AS email
FROM orders;
Идея похожая, но синтаксис путей отличается от PostgreSQL.
ClickHouse: функции вместо ->>
В ClickHouse обычно используют функции извлечения из JSON.
Например, для строки:
SELECT
JSONExtractString(payload, 'channel') AS channel
FROM orders;
Для числа:
SELECT
JSONExtractFloat(payload, 'total') AS total
FROM orders;
или:
SELECT
JSONExtractInt(payload, 'items_count') AS items_count
FROM orders;
То есть в PostgreSQL часто пишут:
payload ->> 'channel'
а в ClickHouse:
JSONExtractString(payload, 'channel')
Разница в том, что ClickHouse-функции часто сразу возвращают нужный тип: строку, число, boolean и так далее.
Практические шаблоны
Достать поле как текст
SELECT
id,
payload ->> 'channel' AS channel
FROM orders;
Достать вложенное поле
SELECT
id,
payload -> 'customer' ->> 'email' AS email
FROM orders;
Достать вложенное поле через #>>
SELECT
id,
payload #>> '{customer,email}' AS email
FROM orders;
Достать число и привести тип
SELECT
id,
(payload ->> 'total')::numeric AS total
FROM orders;
Фильтр по строковому JSON-полю
SELECT
id
FROM orders
WHERE payload ->> 'channel' = 'mobile';
Фильтр по числовому JSON-полю
SELECT
id
FROM orders
WHERE (payload ->> 'priority')::int >= 5;
Проверить наличие ключа
SELECT
id,
payload ? 'coupon' AS has_coupon
FROM orders;
Отличить отсутствующий ключ от JSON null
SELECT
id,
payload ? 'coupon' AS has_coupon_key,
payload ->> 'coupon' AS coupon_text
FROM orders;
Подставить значение по умолчанию
SELECT
id,
COALESCE(payload ->> 'channel', 'unknown') AS channel
FROM orders;
Безопаснее привести число после проверки
SELECT
id,
CASE
WHEN payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$'
THEN (payload ->> 'total')::numeric
ELSE NULL
END AS total
FROM orders;
Среднее значение из JSONB по группам
SELECT
payload ->> 'channel' AS channel,
AVG((payload ->> 'total')::numeric) AS avg_total
FROM orders
WHERE payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$'
GROUP BY payload ->> 'channel';
Индекс по JSONB-полю как тексту
CREATE INDEX orders_payload_channel_idx
ON orders ((payload ->> 'channel'));
Индекс по JSONB-полю как числу
CREATE INDEX orders_payload_total_idx
ON orders (((payload ->> 'total')::numeric));
Частые ошибки
Перепутали -> и ->>
Если нужно сравнить со строкой, обычно нужен ->>:
WHERE payload ->> 'channel' = 'mobile'
А не:
WHERE payload -> 'channel' = 'mobile'
payload -> 'channel' возвращает JSONB, а 'mobile' — текстовый литерал.
Забыли скобки перед приведением типа
Плохо:
payload ->> 'total'::numeric
Правильно:
(payload ->> 'total')::numeric
Сравнили число как текст
Плохо:
WHERE payload ->> 'total' > '1000'
Лучше:
WHERE (payload ->> 'total')::numeric > 1000
Не учли отсутствующий ключ
payload ->> 'coupon'
вернёт NULL, если ключа нет.
Если важно именно наличие ключа:
payload ? 'coupon'
Не проверили грязные данные перед cast
Такой запрос может упасть:
(payload ->> 'total')::numeric
если в JSON лежит:
{"total": "unknown"}
Для внешних данных лучше валидировать формат.
Что важно запомнить
Оператор ->> достаёт значение из JSONB и возвращает его как text.
Пример:
SELECT
payload ->> 'channel' AS channel
FROM orders;
Главные правила:
-> возвращает jsonb;
->> возвращает text;
-> удобно использовать для движения внутрь JSON;
->> обычно используют в конце цепочки;
- для вложенных полей можно писать цепочку
-> ... ->>;
- для вложенного пути можно использовать
#>>;
- результат
->> всегда текст, даже если в JSON лежит число;
- для числовых сравнений нужен явный cast;
- перед cast используйте скобки:
(payload ->> 'total')::numeric;
- отсутствующий ключ возвращает
NULL;
- JSON
null через ->> тоже превращается в SQL NULL;
- чтобы проверить наличие ключа, используйте оператор
?;
- под частые фильтры можно создавать выражающие индексы.
Короткий вывод
->> — один из самых полезных операторов PostgreSQL для работы с jsonb.
Он нужен, когда вы хотите достать из JSONB обычное скалярное значение и работать с ним как с текстом.
Например:
payload ->> 'channel'
достаёт канал заказа как текст.
Для вложенных полей:
payload -> 'customer' ->> 'email'
или:
payload #>> '{customer,email}'
Главная мысль:
-> оставляет значение в JSONB,
->> достаёт финальное значение как text.
Если значение нужно сравнивать как число, дату или boolean, приводите тип явно:
(payload ->> 'total')::numeric
И всегда помните: если ключа нет, ->> не упадёт, а вернёт NULL. Это удобно для гибких данных, но требует аккуратности в фильтрах, отчётах и приведении типов.
В PostgreSQL тип
jsonbпозволяет хранить структурированные данные прямо в колонке.Например, в заказе может лежать поле
payload:{ "channel": "mobile", "total": 1500, "currency": "USD", "customer": { "email": "anna@mail.com", "country": "Vietnam" } }Это удобно, когда часть данных приходит из API, внешней системы, формы, webhook или события.
Но почти всегда в какой-то момент нужно достать из JSON конкретное значение:
В PostgreSQL для этого часто используют оператор:
->>Он достаёт поле из
jsonbи возвращает его как обычныйtext.Например:
payload ->> 'channel'вернёт:
А не JSON-строку с кавычками.
Разберём, чем
->>отличается от->, как доставать вложенные поля, как приводить результат к числу и почему отсутствующий ключ молча даётNULL.Что делает оператор ->>
Оператор
->>достаёт значение из JSON/JSONB по ключу и возвращает его как текст.Допустим, есть таблица
orders:Запрос:
SELECT id, payload ->> 'channel' AS channel FROM orders;Результат:
Значение
channelдостали из JSONB и получили обычный текст.Это значит, что с ним можно работать как с текстовой колонкой:
WHERE payload ->> 'channel' = 'mobile'или:
GROUP BY payload ->> 'channel'-> и ->>: главная разница
В PostgreSQL есть два похожих оператора:
-> ->>Они оба достают значение из JSON, но возвращают разные типы.
Оператор
->возвращает значение какjsonb.Оператор
->>возвращает значение какtext.Пример:
SELECT payload -> 'channel' AS as_jsonb, payload ->> 'channel' AS as_text FROM orders;Если в
payloadлежит:{"channel": "mobile"}то результат будет таким:
В колонке
as_jsonbзначение осталось JSONB-значением, поэтому строка отображается с кавычками.В колонке
as_textзначение стало обычным текстом, поэтому кавычек уже нет.Можно запомнить так:
Когда использовать ->, а когда ->>
Простое правило:
Например, есть JSON:
{ "customer": { "email": "anna@mail.com", "country": "Vietnam" } }Чтобы достать
email, сначала нужно войти в объектcustomer, а потом взять полеemailкак текст.SELECT payload -> 'customer' ->> 'email' AS email FROM orders;Здесь:
payload -> 'customer'возвращает вложенный JSONB-объект:
{"email": "anna@mail.com", "country": "Vietnam"}А затем:
->> 'email'достаёт из него текст:
Если написать так:
payload ->> 'customer'вы получите весь объект
customerкак текстовую строку. Это иногда нужно, но для дальнейшей навигации по JSON уже неудобно.Вложенные поля через цепочку операторов
Для вложенных JSON-структур операторы можно соединять в цепочку.
Пример JSON:
{ "shipping": { "address": { "city": "Berlin", "zip": "10115" } } }Чтобы достать город:
SELECT payload -> 'shipping' -> 'address' ->> 'city' AS city FROM orders;Логика такая:
То есть:
payload -> 'shipping'возвращает JSONB.
-> 'address'тоже возвращает JSONB.
->> 'city'возвращает финальное значение как текст.
Результат:
Альтернатива для вложенных путей: #>>
Для вложенных полей в PostgreSQL есть ещё оператор:
#>>Он достаёт значение по пути и сразу возвращает текст.
Например:
SELECT payload #>> '{shipping,address,city}' AS city FROM orders;Это эквивалентно:
SELECT payload -> 'shipping' -> 'address' ->> 'city' AS city FROM orders;Оператор
#>>удобен, когда путь длинный.Сравните:
payload -> 'shipping' -> 'address' -> 'geo' ->> 'lat'и:
payload #>> '{shipping,address,geo,lat}'Оба варианта рабочие.
Для новичка часто понятнее цепочка
->и->>, потому что видно каждый шаг. Для короткой и аккуратной записи вложенного пути удобно использовать#>>.Доступ к элементам массива
Операторы
->и->>можно использовать не только с объектами, но и с массивами.Допустим, в JSON лежит массив тегов:
{ "tags": ["sql", "postgresql", "jsonb"] }Чтобы получить первый тег:
SELECT payload -> 'tags' ->> 0 AS first_tag FROM articles;Результат:
Важно: индексация JSON-массивов начинается с
0.Если нужен второй тег:
SELECT payload -> 'tags' ->> 1 AS second_tag FROM articles;А если нужно достать весь массив как JSONB:
SELECT payload -> 'tags' AS tags_jsonb FROM articles;->> всегда возвращает text
Очень важный момент:
->>всегда возвращает текст.Даже если в JSON лежит число.
Например:
{ "items_count": 3, "total": 1500.50, "is_paid": true }Запрос:
SELECT payload ->> 'items_count' AS items_count, payload ->> 'total' AS total, payload ->> 'is_paid' AS is_paid FROM orders;вернёт текстовые значения:
Выглядит как число и boolean, но тип результата —
text.Это значит, что для числовых сравнений, сумм и дат нужно явно приводить тип.
Приведение результата к числу
Если вы хотите сравнивать значение как число, нужно привести результат
->>к нужному типу.Например:
SELECT id, (payload ->> 'items_count')::int AS items_count, (payload ->> 'total')::numeric AS total FROM orders;Обратите внимание на скобки:
(payload ->> 'total')::numericЭто правильная запись.
Плохая запись:
payload ->> 'total'::numericПочему плохая?
Потому что PostgreSQL попробует привести строковый литерал
'total'кnumeric, а не результат извлечения из JSON.То есть
::numericпривяжется не туда.Правило:
Правильно:
(payload ->> 'total')::numeric(payload ->> 'items_count')::int(payload ->> 'created_at')::timestamp(payload ->> 'is_paid')::booleanФильтрация по числу из JSONB
Допустим, нужно найти заказы, у которых
totalбольше1000.Если написать так:
SELECT id FROM orders WHERE payload ->> 'total' > '1000';это будет текстовое сравнение, а не числовое.
Текстовое сравнение может дать неожиданный результат, потому что строки сравниваются лексикографически.
Для чисел нужно привести тип:
SELECT id FROM orders WHERE (payload ->> 'total')::numeric > 1000;Теперь PostgreSQL сравнивает значения как числа.
То же самое для сортировки:
SELECT id, (payload ->> 'total')::numeric AS total FROM orders ORDER BY (payload ->> 'total')::numeric DESC;Если не привести тип, сортировка будет текстовой.
Например, текстовая сортировка может поставить
900выше10000, потому что сравниваются символы, а не числа.Фильтрация по строке из JSONB
Если значение действительно текстовое, приводить тип не нужно.
Например, канал заказа:
{ "channel": "mobile" }Фильтр:
SELECT id, user_id FROM orders WHERE payload ->> 'channel' = 'mobile';Группировка:
SELECT payload ->> 'channel' AS channel, COUNT(*) AS orders_count FROM orders GROUP BY payload ->> 'channel';Результат:
Для текстовых значений
->>как раз очень удобен: он сразу даёт обычныйtext.Агрегация чисел из JSONB
Если в JSONB лежит сумма заказа, её можно агрегировать.
Например:
{ "channel": "mobile", "total": 1500.50 }Посчитаем средний
totalпо каналам:SELECT payload ->> 'channel' AS channel, AVG((payload ->> 'total')::numeric) AS avg_total FROM orders WHERE payload ? 'total' GROUP BY payload ->> 'channel';Здесь:
payload ->> 'channel'возвращает канал как текст.
А:
(payload ->> 'total')::numericдостаёт сумму как текст и приводит её к числу.
Без приведения к
numericагрегатAVGне сможет корректно считать среднее по текстовым значениям.Что будет, если ключа нет
Если ключа в JSONB нет, оператор
->>не падает с ошибкой.Он возвращает SQL
NULL.Например, есть данные:
{"channel": "mobile", "total": 1500}А мы пытаемся достать
coupon:SELECT payload ->> 'coupon' AS coupon FROM orders;Результат:
Это удобно: запрос не ломается из-за отсутствующего ключа.
Но это может быть ловушкой.
Например:
WHERE payload ->> 'coupon' = 'SUMMER'не вернёт строки без
coupon, потому что сравнениеNULL = 'SUMMER'не даётTRUE.Если нужно найти строки без купона:
SELECT id FROM orders WHERE payload ->> 'coupon' IS NULL;Но здесь есть ещё одна тонкость:
NULLможет означать разные вещи.Отсутствующий ключ и JSON null
Допустим, есть два JSON-документа:
{"channel": "mobile"}и:
{"channel": "mobile", "coupon": null}В первом случае ключа
couponвообще нет.Во втором случае ключ есть, но его значение — JSON
null.Если использовать
->>:SELECT payload ->> 'coupon' AS coupon FROM orders;оба случая дадут SQL
NULL.То есть только по
->>нельзя понять:null.Если это важно, проверяйте наличие ключа отдельно через оператор
?.SELECT payload ? 'coupon' AS has_coupon_key, payload ->> 'coupon' AS coupon_text FROM orders;Результат может быть таким:
Теперь видно:
has_coupon_key = false— ключа нет;has_coupon_key = true, ноcoupon_text IS NULL— ключ есть, но значениеnull;coupon_text = 'SUMMER'— ключ есть и значение текстовое.Важно: оператор
?проверяет наличие ключа на верхнем уровне объекта.Для вложенного объекта нужно сначала перейти внутрь:
(payload -> 'customer') ? 'email'Как отфильтровать строки, где ключ есть
Если нужно выбрать только строки, где в JSON есть ключ
channel, используйте:SELECT id FROM orders WHERE payload ? 'channel';Если нужно выбрать строки, где ключа нет:
SELECT id FROM orders WHERE NOT (payload ? 'channel');Если нужно выбрать строки, где ключ есть и значение не
NULL:SELECT id FROM orders WHERE payload ? 'channel' AND payload ->> 'channel' IS NOT NULL;Для вложенных ключей:
SELECT id FROM orders WHERE (payload -> 'customer') ? 'email';Но если
customerможет отсутствовать или быть не объектом, такие запросы нужно тестировать аккуратно.Грязные данные и ошибки приведения типов
->>возвращает текст.Если вы приводите этот текст к числу, PostgreSQL попытается выполнить cast.
Например:
(payload ->> 'total')::numericЭто работает, если значение выглядит как число:
{"total": 1500}или:
{"total": "1500.50"}Но если в JSON внезапно лежит пустая строка или текст:
{"total": ""}или:
{"total": "unknown"}запрос упадёт с ошибкой.
Например:
SELECT (payload ->> 'total')::numeric AS total FROM orders;может сломаться на одной грязной строке.
Для данных из внешних API это частая проблема.
Как безопаснее приводить JSON-значение к числу
Если данные могут быть грязными, сначала проверьте формат.
Например:
SELECT id, CASE WHEN payload ->> 'total' ~ '^[0-9]+(\.[0-9]+)?$' THEN (payload ->> 'total')::numeric ELSE NULL END AS total FROM orders;Этот пример пропускает только положительные числа вроде:
Если вам нужны отрицательные числа, можно расширить регулярное выражение:
CASE WHEN payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$' THEN (payload ->> 'total')::numeric ELSE NULL ENDЕщё один частый приём — обработать пустую строку через
NULLIF:(NULLIF(payload ->> 'total', ''))::numericНо это спасает только от пустой строки. Если там будет
unknown, cast всё равно упадёт.Поэтому для внешних данных лучше:
Индекс для частого фильтра по JSON-полю
Если вы часто фильтруете по одному и тому же JSON-полю, можно создать выражающий индекс.
Например, часто есть запрос:
SELECT id FROM orders WHERE payload ->> 'channel' = 'mobile';Тогда можно создать индекс:
CREATE INDEX orders_payload_channel_idx ON orders ((payload ->> 'channel'));После этого PostgreSQL сможет использовать индекс для фильтра по этому выражению.
Для числового поля тоже можно создать индекс по приведённому выражению:
CREATE INDEX orders_payload_total_idx ON orders (((payload ->> 'total')::numeric));Но здесь нужно быть осторожным: если в данных есть нечисловые значения, создание такого индекса может упасть из-за ошибки приведения.
Для важных полей иногда лучше вынести значение из JSONB в отдельную колонку:
А JSONB оставить для дополнительных метаданных.
JSONB как метаданные, а не замена модели данных
jsonbудобен, но не стоит превращать его в склад всех важных полей.Если поле активно используется в:
часто лучше хранить его в обычной колонке правильного типа.
Например, если
total— ключевая сумма заказа, лучше иметь:А не только:
{"total": 1500}Почему?
Потому что обычную колонку проще:
Хороший паттерн:
->>отлично подходит для извлечения метаданных, но не должен заменять нормальную модель данных там, где поле стало частью ядра продукта.Пример: e-mail из профиля пользователя
Допустим, в таблице
usersесть колонкаprofile jsonb.{ "email": "anna@mail.com", "name": "Anna", "settings": { "language": "ru", "timezone": "Asia/Singapore" } }Достаём e-mail и язык:
SELECT id, profile ->> 'email' AS email, profile -> 'settings' ->> 'language' AS language FROM users;Результат:
Если нужно отфильтровать русскоязычных пользователей:
SELECT id FROM users WHERE profile -> 'settings' ->> 'language' = 'ru';Или через
#>>:SELECT id FROM users WHERE profile #>> '{settings,language}' = 'ru';Пример: тариф из настроек
Допустим, есть таблица
accounts, а вsettingsлежит тариф:{ "billing": { "plan": "pro", "seats": 5 } }Запрос:
SELECT id, settings -> 'billing' ->> 'plan' AS plan, (settings -> 'billing' ->> 'seats')::int AS seats FROM accounts;Фильтр по тарифу:
SELECT id FROM accounts WHERE settings -> 'billing' ->> 'plan' = 'pro';Фильтр по количеству мест:
SELECT id FROM accounts WHERE (settings -> 'billing' ->> 'seats')::int >= 5;Пример: средний чек по каналу из JSONB
Допустим, в
orders.payloadлежит:{ "channel": "mobile", "total": 1500.50 }Нужно посчитать средний чек по каналам.
SELECT payload ->> 'channel' AS channel, AVG((payload ->> 'total')::numeric) AS avg_total, COUNT(*) AS orders_count FROM orders WHERE payload ? 'channel' AND payload ? 'total' GROUP BY payload ->> 'channel' ORDER BY avg_total DESC;Если данные могут быть грязными, лучше добавить проверку:
SELECT payload ->> 'channel' AS channel, AVG((payload ->> 'total')::numeric) AS avg_total, COUNT(*) AS orders_count FROM orders WHERE payload ? 'channel' AND payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$' GROUP BY payload ->> 'channel' ORDER BY avg_total DESC;Так запрос не упадёт, если где-то в
totalлежит не число.->> и LIKE / ILIKE
Так как
->>возвращает текст, его можно использовать с текстовыми операторами.Например, найти пользователей с e-mail на Gmail:
SELECT id FROM users WHERE profile ->> 'email' ILIKE '%@gmail.com';Или найти заказы, где канал начинается с
partner_:SELECT id FROM orders WHERE payload ->> 'channel' LIKE 'partner_%';Но помните: поиск по подстроке через
ILIKE '%text%'может быть дорогим на больших таблицах. Для частых поисков нужны подходящие индексы и отдельная стратегия.->> и COALESCE
Если ключ может отсутствовать, можно подставить значение по умолчанию через
COALESCE.Например:
SELECT id, COALESCE(payload ->> 'channel', 'unknown') AS channel FROM orders;Если
channelотсутствует или равен JSONnull, результат будет:Это удобно для отчётов:
SELECT COALESCE(payload ->> 'channel', 'unknown') AS channel, COUNT(*) AS orders_count FROM orders GROUP BY COALESCE(payload ->> 'channel', 'unknown');Но используйте
COALESCEосознанно. Иногда отсутствие значения и явное значение"unknown"должны различаться.MySQL: похожий оператор ->>
В MySQL тоже есть оператор
->>для JSON.Но путь задаётся иначе — через JSONPath.
PostgreSQL:
SELECT payload ->> 'channel' AS channel FROM orders;MySQL:
SELECT payload ->> '$.channel' AS channel FROM orders;В MySQL оператор
->>по смыслу похож на:JSON_UNQUOTE(JSON_EXTRACT(payload, '$.channel'))То есть он достаёт значение из JSON и убирает JSON-кавычки.
Для вложенного поля:
SELECT payload ->> '$.customer.email' AS email FROM orders;Идея похожая, но синтаксис путей отличается от PostgreSQL.
ClickHouse: функции вместо ->>
В ClickHouse обычно используют функции извлечения из JSON.
Например, для строки:
SELECT JSONExtractString(payload, 'channel') AS channel FROM orders;Для числа:
SELECT JSONExtractFloat(payload, 'total') AS total FROM orders;или:
SELECT JSONExtractInt(payload, 'items_count') AS items_count FROM orders;То есть в PostgreSQL часто пишут:
payload ->> 'channel'а в ClickHouse:
JSONExtractString(payload, 'channel')Разница в том, что ClickHouse-функции часто сразу возвращают нужный тип: строку, число, boolean и так далее.
Практические шаблоны
Достать поле как текст
SELECT id, payload ->> 'channel' AS channel FROM orders;Достать вложенное поле
SELECT id, payload -> 'customer' ->> 'email' AS email FROM orders;Достать вложенное поле через #>>
SELECT id, payload #>> '{customer,email}' AS email FROM orders;Достать число и привести тип
SELECT id, (payload ->> 'total')::numeric AS total FROM orders;Фильтр по строковому JSON-полю
SELECT id FROM orders WHERE payload ->> 'channel' = 'mobile';Фильтр по числовому JSON-полю
SELECT id FROM orders WHERE (payload ->> 'priority')::int >= 5;Проверить наличие ключа
SELECT id, payload ? 'coupon' AS has_coupon FROM orders;Отличить отсутствующий ключ от JSON null
SELECT id, payload ? 'coupon' AS has_coupon_key, payload ->> 'coupon' AS coupon_text FROM orders;Подставить значение по умолчанию
SELECT id, COALESCE(payload ->> 'channel', 'unknown') AS channel FROM orders;Безопаснее привести число после проверки
SELECT id, CASE WHEN payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$' THEN (payload ->> 'total')::numeric ELSE NULL END AS total FROM orders;Среднее значение из JSONB по группам
SELECT payload ->> 'channel' AS channel, AVG((payload ->> 'total')::numeric) AS avg_total FROM orders WHERE payload ->> 'total' ~ '^-?[0-9]+(\.[0-9]+)?$' GROUP BY payload ->> 'channel';Индекс по JSONB-полю как тексту
CREATE INDEX orders_payload_channel_idx ON orders ((payload ->> 'channel'));Индекс по JSONB-полю как числу
CREATE INDEX orders_payload_total_idx ON orders (((payload ->> 'total')::numeric));Частые ошибки
Перепутали -> и ->>
Если нужно сравнить со строкой, обычно нужен
->>:WHERE payload ->> 'channel' = 'mobile'А не:
WHERE payload -> 'channel' = 'mobile'payload -> 'channel'возвращает JSONB, а'mobile'— текстовый литерал.Забыли скобки перед приведением типа
Плохо:
payload ->> 'total'::numericПравильно:
(payload ->> 'total')::numericСравнили число как текст
Плохо:
WHERE payload ->> 'total' > '1000'Лучше:
WHERE (payload ->> 'total')::numeric > 1000Не учли отсутствующий ключ
payload ->> 'coupon'вернёт
NULL, если ключа нет.Если важно именно наличие ключа:
payload ? 'coupon'Не проверили грязные данные перед cast
Такой запрос может упасть:
(payload ->> 'total')::numericесли в JSON лежит:
{"total": "unknown"}Для внешних данных лучше валидировать формат.
Что важно запомнить
Оператор
->>достаёт значение из JSONB и возвращает его какtext.Пример:
SELECT payload ->> 'channel' AS channel FROM orders;Главные правила:
->возвращаетjsonb;->>возвращаетtext;->удобно использовать для движения внутрь JSON;->>обычно используют в конце цепочки;-> ... ->>;#>>;->>всегда текст, даже если в JSON лежит число;(payload ->> 'total')::numeric;NULL;nullчерез->>тоже превращается в SQLNULL;?;Короткий вывод
->>— один из самых полезных операторов PostgreSQL для работы сjsonb.Он нужен, когда вы хотите достать из JSONB обычное скалярное значение и работать с ним как с текстом.
Например:
payload ->> 'channel'достаёт канал заказа как текст.
Для вложенных полей:
payload -> 'customer' ->> 'email'или:
payload #>> '{customer,email}'Главная мысль:
Если значение нужно сравнивать как число, дату или boolean, приводите тип явно:
(payload ->> 'total')::numericИ всегда помните: если ключа нет,
->>не упадёт, а вернётNULL. Это удобно для гибких данных, но требует аккуратности в фильтрах, отчётах и приведении типов.