Šis raksts pašlaik ir krievu valodā — angļu tulkojums tiek gatavots.
Положили полуструктурированные данные в колонку JSONB — настройки пользователя, метаданные заказа, разнородные атрибуты, которые не хочется разносить по отдельным столбцам, — и теперь к ним нужно как-то подобраться. Любое чтение JSON в PostgreSQL начинается с двух операторов-стрелок: -> и ->>. Это рабочая лошадка JSON-запросов: первый ныряет глубже по структуре и держит вас внутри JSON, второй выныривает наружу с готовым значением. Разница между ними кажется косметической — всего один лишний символ, — но именно она решает, скомпилируется ваш запрос или упадёт с ошибкой типов. Разберём по порядку: чем они отличаются, как доставать поля объекта и элементы массива и как складывать всё это в длинную цепочку, которая читается так же легко, как путь к файлу.
-> возвращает jsonb, ->> возвращает text
Оба оператора достают значение по ключу, но возвращают его в разных типах, и в этом вся соль. -> отдаёт jsonb — объект, массив или скаляр в JSON-форме, со всеми кавычками и структурой, как есть. ->> отдаёт обычный text, уже распакованный из JSON-обёртки. Мнемоника простая: лишняя стрелка снимает кавычки. Запомните это различие — на нём держится всё остальное, от фильтров до агрегатов.
SELECT
prefs -> 'theme' AS as_jsonb,
prefs ->> 'theme' AS as_text
FROM users;
Отсюда простое правило, по которому стрелку выбираешь почти не задумываясь:
-> — когда вы идёте глубже и следом будет ещё один -> или ->>.
->> — на последнем шаге пути, когда нужно скалярное значение для вывода, фильтра или сравнения.
Ключ задаётся строкой — это имя поля объекта. Отсутствующий ключ не роняет запрос, а молча возвращает NULL. Эту тихую снисходительность стоит держать в голове: ниже она ещё аукнется.
Поля объекта и элементы массива
PostgreSQL выбирает, что именно делать, по типу правого операнда — один и тот же оператор обслуживает и объекты, и массивы. Строка справа — значит, ищем поле в объекте по имени. Целое число справа — индексируем массив, причём нумерация идёт с нуля, как в большинстве языков, а не с единицы, как привыкли любители обычного SQL-массива. Отрицательный индекс удобно отсчитывается с конца: -1 — последний элемент, -2 — предпоследний, и не нужно заранее знать длину.
SELECT
meta -> 'tags' -> 0 AS first_tag_json,
meta -> 'tags' ->> 0 AS first_tag_text,
meta -> 'tags' ->> -1 AS last_tag
FROM users;
Ровно тот же приём работает поверх ваших собственных таблиц — никакой особой магии для «игрушечных» примеров тут нет. Пусть в orders лежит payload с массивом позиций заказа, и нам нужен артикул первой:
SELECT
id,
payload -> 'items' -> 0 ->> 'sku' AS first_sku
FROM orders
WHERE status = 'paid';
Цепочки и приведение типов
Длинный путь к вложенному значению — это просто несколько стрелок подряд, читаемых слева направо. Спускаетесь через ->, оставаясь в jsonb на каждом промежуточном шаге, а на самом последнем переключаетесь на ->>, чтобы вынуть text. Поставить ->> в середине цепочки нельзя — он отдаст текст, а к тексту следующую стрелку уже не прицепишь. И поскольку ->> всегда возвращает именно текст, для чисел, дат и булевых значений результат приходится приводить явно через ::.
SELECT
profile -> 'address' ->> 'city' AS city,
(profile ->> 'age')::int AS age_int
FROM users
WHERE (profile ->> 'age')::int >= 18;
Чтобы это не выглядело учебным упражнением, вот живой запрос с агрегацией: вытаскиваем сумму из JSON-метаданных заказа и считаем средний чек по странам. Обратите внимание на скобки вокруг ->> перед ::numeric — без них приоритет операторов сыграет против вас.
SELECT
u.country,
round(avg((o.payload ->> 'amount')::numeric), 2) AS avg_amount
FROM orders o
JOIN users u ON u.id = o.user_id
GROUP BY u.country
ORDER BY avg_amount DESC;
Подводные камни
- Сравнение без приведения — почти всегда баг. Выражение
payload ->> 'amount' > '99' сравнивает строки лексикографически, и в этом мире '9' оказывается больше '100', потому что сравниваются символы, а не числа. Приводите явно: (payload ->> 'amount')::numeric > 99.
-> на скаляре отдаёт jsonb, поэтому prefs -> 'theme' = 'dark' падает с ошибкой типов — слева jsonb, справа text. Для сравнения берите ->> либо пишите prefs -> 'theme' = '"dark"'::jsonb (с двойными кавычками внутри — это валидный JSON-скаляр).
- Несуществующий ключ или индекс за границами массива возвращает
NULL молча. Опечатка в имени поля не вызовет ни ошибки, ни предупреждения — просто тихо испортит результат, и вы будете долго гадать, куда делись данные.
->> на объекте или массиве вернёт его JSON-текст целиком, а не отдельное значение. Текст берите только со скаляров, иначе получите строку вида {"city": ...} вместо самого города.
Стоит помнить и о том, что синтаксис стрелок — не межплатформенный стандарт. В MySQL такого синтаксиса стрелок-цепочек нет: там пользуются JSON_EXTRACT(doc, '$.address.city') или сахаром doc->>'$.address.city' (оператор ->> тоже даёт текст, но путь записывается через JSONPath одной строкой, а не двумя стрелками подряд). В ClickHouse доступа по стрелкам нет вовсе — там работают типизированные функции вроде JSONExtractString(col, 'city') и JSONExtractInt(col, 'age'), сразу возвращающие нужный тип, без отдельного приведения через ::. Идея «достать значение из JSON по пути» универсальна, а вот лаконичные стрелки -> / ->> — фирменная черта PostgreSQL, ради которой его и любят за работу с JSON. Освоите различие между jsonb и text и привычку приводить тип на выходе — и остальной арсенал JSON-функций ляжет поверх него уже без сюрпризов.
Положили полуструктурированные данные в колонку
JSONB— настройки пользователя, метаданные заказа, разнородные атрибуты, которые не хочется разносить по отдельным столбцам, — и теперь к ним нужно как-то подобраться. Любое чтение JSON в PostgreSQL начинается с двух операторов-стрелок:->и->>. Это рабочая лошадка JSON-запросов: первый ныряет глубже по структуре и держит вас внутри JSON, второй выныривает наружу с готовым значением. Разница между ними кажется косметической — всего один лишний символ, — но именно она решает, скомпилируется ваш запрос или упадёт с ошибкой типов. Разберём по порядку: чем они отличаются, как доставать поля объекта и элементы массива и как складывать всё это в длинную цепочку, которая читается так же легко, как путь к файлу.-> возвращает jsonb, ->> возвращает text
Оба оператора достают значение по ключу, но возвращают его в разных типах, и в этом вся соль.
->отдаётjsonb— объект, массив или скаляр в JSON-форме, со всеми кавычками и структурой, как есть.->>отдаёт обычныйtext, уже распакованный из JSON-обёртки. Мнемоника простая: лишняя стрелка снимает кавычки. Запомните это различие — на нём держится всё остальное, от фильтров до агрегатов.-- prefs is a jsonb column on users: {"theme": "dark", "lang": "en"} SELECT prefs -> 'theme' AS as_jsonb, -- "dark" (with quotes, type jsonb) prefs ->> 'theme' AS as_text -- dark (no quotes, type text) FROM users;Отсюда простое правило, по которому стрелку выбираешь почти не задумываясь:
->— когда вы идёте глубже и следом будет ещё один->или->>.->>— на последнем шаге пути, когда нужно скалярное значение для вывода, фильтра или сравнения.Ключ задаётся строкой — это имя поля объекта. Отсутствующий ключ не роняет запрос, а молча возвращает
NULL. Эту тихую снисходительность стоит держать в голове: ниже она ещё аукнется.Поля объекта и элементы массива
PostgreSQL выбирает, что именно делать, по типу правого операнда — один и тот же оператор обслуживает и объекты, и массивы. Строка справа — значит, ищем поле в объекте по имени. Целое число справа — индексируем массив, причём нумерация идёт с нуля, как в большинстве языков, а не с единицы, как привыкли любители обычного SQL-массива. Отрицательный индекс удобно отсчитывается с конца:
-1— последний элемент,-2— предпоследний, и не нужно заранее знать длину.-- meta = {"tags": ["new", "vip", "eu"], "score": 7} SELECT meta -> 'tags' -> 0 AS first_tag_json, -- "new" as jsonb meta -> 'tags' ->> 0 AS first_tag_text, -- new as text meta -> 'tags' ->> -1 AS last_tag -- eu (last element) FROM users;Ровно тот же приём работает поверх ваших собственных таблиц — никакой особой магии для «игрушечных» примеров тут нет. Пусть в
ordersлежитpayloadс массивом позиций заказа, и нам нужен артикул первой:-- payload = {"items": [{"sku": "A1", "qty": 2}, {"sku": "B7", "qty": 1}]} SELECT id, payload -> 'items' -> 0 ->> 'sku' AS first_sku FROM orders WHERE status = 'paid';Цепочки и приведение типов
Длинный путь к вложенному значению — это просто несколько стрелок подряд, читаемых слева направо. Спускаетесь через
->, оставаясь вjsonbна каждом промежуточном шаге, а на самом последнем переключаетесь на->>, чтобы вынутьtext. Поставить->>в середине цепочки нельзя — он отдаст текст, а к тексту следующую стрелку уже не прицепишь. И поскольку->>всегда возвращает именно текст, для чисел, дат и булевых значений результат приходится приводить явно через::.-- profile = {"address": {"city": "Berlin", "zip": "10115"}, "age": "34"} SELECT profile -> 'address' ->> 'city' AS city, (profile ->> 'age')::int AS age_int FROM users WHERE (profile ->> 'age')::int >= 18;Чтобы это не выглядело учебным упражнением, вот живой запрос с агрегацией: вытаскиваем сумму из JSON-метаданных заказа и считаем средний чек по странам. Обратите внимание на скобки вокруг
->>перед::numeric— без них приоритет операторов сыграет против вас.-- payload = {"amount": "149.90", "currency": "EUR"} SELECT u.country, round(avg((o.payload ->> 'amount')::numeric), 2) AS avg_amount FROM orders o JOIN users u ON u.id = o.user_id GROUP BY u.country ORDER BY avg_amount DESC;Подводные камни
payload ->> 'amount' > '99'сравнивает строки лексикографически, и в этом мире'9'оказывается больше'100', потому что сравниваются символы, а не числа. Приводите явно:(payload ->> 'amount')::numeric > 99.->на скаляре отдаётjsonb, поэтомуprefs -> 'theme' = 'dark'падает с ошибкой типов — слеваjsonb, справаtext. Для сравнения берите->>либо пишитеprefs -> 'theme' = '"dark"'::jsonb(с двойными кавычками внутри — это валидный JSON-скаляр).NULLмолча. Опечатка в имени поля не вызовет ни ошибки, ни предупреждения — просто тихо испортит результат, и вы будете долго гадать, куда делись данные.->>на объекте или массиве вернёт его JSON-текст целиком, а не отдельное значение. Текст берите только со скаляров, иначе получите строку вида{"city": ...}вместо самого города.Стоит помнить и о том, что синтаксис стрелок — не межплатформенный стандарт. В MySQL такого синтаксиса стрелок-цепочек нет: там пользуются
JSON_EXTRACT(doc, '$.address.city')или сахаромdoc->>'$.address.city'(оператор->>тоже даёт текст, но путь записывается через JSONPath одной строкой, а не двумя стрелками подряд). В ClickHouse доступа по стрелкам нет вовсе — там работают типизированные функции вродеJSONExtractString(col, 'city')иJSONExtractInt(col, 'age'), сразу возвращающие нужный тип, без отдельного приведения через::. Идея «достать значение из JSON по пути» универсальна, а вот лаконичные стрелки-> / ->>— фирменная черта PostgreSQL, ради которой его и любят за работу с JSON. Освоите различие междуjsonbиtextи привычку приводить тип на выходе — и остальной арсенал JSON-функций ляжет поверх него уже без сюрпризов.