Ez a cikk jelenleg oroszul van — az angol fordítás folyamatban van.
В SQL мы обычно получаем результат в виде таблицы: одна строка за другой.
Например, если у пользователя несколько заказов, обычный запрос вернёт несколько строк:
user_id | order_id | amount
--------+----------+--------
1 | 101 | 1500
1 | 102 | 2300
1 | 103 | 900
Это нормальный табличный формат. Он удобен для фильтрации, соединений и расчётов.
Но иногда наружу хочется отдать данные иначе: не три строки на одного пользователя, а одну строку, внутри которой будет список его заказов.
Например:
user_id | order_ids
--------+---------------
1 | {101,102,103}
Вот для этого в PostgreSQL есть агрегатная функция ARRAY_AGG.
Она собирает значения из нескольких строк в один массив.
ARRAY_AGG удобно использовать, когда у одной сущности есть много связанных значений:
- у пользователя много заказов;
- у статьи много тегов;
- у менеджера много подчинённых;
- у товара много категорий;
- у отдела много сотрудников;
- у клиента много статусов или событий.
То есть ARRAY_AGG помогает превратить плоский результат в более компактный и вложенный.
Что делает ARRAY_AGG простыми словами
ARRAY_AGG берёт значения из строк внутри группы и складывает их в массив.
Допустим, есть таблица orders:
id | user_id | amount
----+---------+--------
101 | 1 | 1500
102 | 1 | 2300
103 | 1 | 900
201 | 2 | 700
202 | 2 | 1200
Если мы хотим собрать суммы заказов по каждому пользователю в массив, можно написать:
SELECT
user_id,
ARRAY_AGG(amount) AS amounts
FROM orders
GROUP BY user_id;
Результат:
user_id | amounts
--------+----------------
1 | {1500,2300,900}
2 | {700,1200}
Что произошло:
GROUP BY user_id собрал строки по пользователям.
ARRAY_AGG(amount) взял все amount внутри каждой группы.
- PostgreSQL сложил эти значения в массив.
То есть вместо нескольких строк на одного пользователя мы получили одну строку и массив значений внутри неё.
Базовый синтаксис ARRAY_AGG
Синтаксис простой:
ARRAY_AGG(expression)
Где expression — это значение, которое нужно собрать в массив.
Например:
ARRAY_AGG(amount)
ARRAY_AGG(id)
ARRAY_AGG(email)
ARRAY_AGG(status)
ARRAY_AGG(created_at)
Как и другие агрегаты, ARRAY_AGG обычно используется вместе с GROUP BY.
Пример:
SELECT
user_id,
ARRAY_AGG(id) AS order_ids
FROM orders
GROUP BY user_id;
Результат:
user_id | order_ids
--------+---------------
1 | {101,102,103}
2 | {201,202}
Здесь для каждого пользователя мы собрали список его заказов.
Тип массива зависит от типа значения
ARRAY_AGG возвращает массив того типа, который вы собираете.
Если собираете числа:
ARRAY_AGG(amount)
результат будет массивом чисел, например:
{1500,2300,900}
Если собираете текст:
ARRAY_AGG(status)
результат будет массивом строк:
{paid,failed,refunded}
Если собираете даты:
ARRAY_AGG(created_at)
результат будет массивом дат или timestamp-значений.
Это важно понимать: ARRAY_AGG не превращает всё автоматически в текст. Он сохраняет тип агрегируемого выражения.
Почему ORDER BY внутри ARRAY_AGG очень важен
Самая частая ошибка с ARRAY_AGG — забыть про порядок.
Например:
SELECT
user_id,
ARRAY_AGG(amount) AS amounts
FROM orders
GROUP BY user_id;
Такой запрос соберёт суммы заказов в массив, но порядок элементов внутри массива не гарантирован.
Сегодня вы можете увидеть:
{1500,2300,900}
А после изменения плана запроса, индекса или объёма данных порядок может стать другим:
{900,1500,2300}
Для отчёта или API это может быть проблемой.
Поэтому в реальном коде лучше почти всегда задавать порядок явно:
SELECT
user_id,
ARRAY_AGG(amount ORDER BY created_at) AS amounts
FROM orders
GROUP BY user_id;
Теперь суммы будут собраны в порядке создания заказов.
Если нужны последние заказы первыми:
SELECT
user_id,
ARRAY_AGG(amount ORDER BY created_at DESC) AS amounts
FROM orders
GROUP BY user_id;
Главное правило:
Если порядок элементов в массиве важен, всегда пишите ORDER BY внутри ARRAY_AGG.
Не снаружи запроса, а именно внутри агрегата.
ORDER BY снаружи и внутри — это разные вещи
Важно не путать два разных ORDER BY.
Вот такой ORDER BY сортирует строки результата:
SELECT
user_id,
ARRAY_AGG(amount) AS amounts
FROM orders
GROUP BY user_id
ORDER BY user_id;
Он влияет на порядок пользователей в итоговой таблице.
Например:
user_id | amounts
--------+----------------
1 | {1500,2300,900}
2 | {700,1200}
Но он не гарантирует порядок элементов внутри массива.
А вот такой ORDER BY влияет именно на порядок элементов в массиве:
SELECT
user_id,
ARRAY_AGG(amount ORDER BY created_at) AS amounts
FROM orders
GROUP BY user_id
ORDER BY user_id;
Здесь:
ORDER BY created_at
внутри ARRAY_AGG сортирует заказы внутри массива.
А:
ORDER BY user_id
в конце запроса сортирует строки результата по пользователям.
Можно запомнить так:
ORDER BY внутри ARRAY_AGG сортирует элементы массива.
ORDER BY в конце запроса сортирует строки результата.
Пример: история заказов пользователя
Допустим, есть таблица orders:
id | user_id | amount | status | created_at
----+---------+--------+--------+---------------------
101 | 1 | 1500 | paid | 2026-06-01 10:00:00
102 | 1 | 2300 | paid | 2026-06-03 12:00:00
103 | 1 | 900 | failed | 2026-06-02 09:00:00
201 | 2 | 700 | paid | 2026-06-01 11:00:00
Хотим получить по каждому пользователю список заказов от новых к старым.
SELECT
user_id,
ARRAY_AGG(id ORDER BY created_at DESC) AS order_ids
FROM orders
GROUP BY user_id;
Результат:
user_id | order_ids
--------+---------------
1 | {102,103,101}
2 | {201}
У пользователя 1 заказ 102 идёт первым, потому что он самый новый.
Такой результат уже удобно отдавать в API или использовать в отчёте.
FILTER внутри ARRAY_AGG
ARRAY_AGG можно сочетать с FILTER (WHERE ...).
Это позволяет собирать в массив не все значения, а только те, которые подходят под условие.
Например, собрать только оплаченные заказы:
SELECT
user_id,
ARRAY_AGG(id ORDER BY created_at)
FILTER (WHERE status = 'paid') AS paid_order_ids
FROM orders
GROUP BY user_id;
Результат может быть таким:
user_id | paid_order_ids
--------+----------------
1 | {101,102}
2 | {201}
Заказ 103 не попал в массив, потому что у него статус failed.
Это читается очень понятно:
собери id заказов по времени,
но только там, где status = 'paid'
Такой подход удобнее, чем сначала делать подзапрос, а потом агрегировать уже отфильтрованные строки.
NULL внутри ARRAY_AGG
ARRAY_AGG собирает NULL как обычный элемент массива.
Например, есть данные:
user_id | status
--------+---------
1 | paid
1 | NULL
1 | failed
Запрос:
SELECT
user_id,
ARRAY_AGG(status) AS statuses
FROM orders
GROUP BY user_id;
может вернуть:
user_id | statuses
--------+--------------------
1 | {paid,NULL,failed}
Если NULL в массиве не нужен, добавьте FILTER:
SELECT
user_id,
ARRAY_AGG(status)
FILTER (WHERE status IS NOT NULL) AS statuses
FROM orders
GROUP BY user_id;
Теперь результат будет:
user_id | statuses
--------+---------------
1 | {paid,failed}
Правило простое:
Если не хотите видеть NULL внутри массива, явно отфильтруйте его через FILTER.
ARRAY_AGG и LEFT JOIN: почему иногда получается {NULL}
Есть важная ловушка, которая часто встречается в реальных запросах.
Допустим, мы хотим получить всех пользователей и массив их заказов.
SELECT
u.id,
u.email,
ARRAY_AGG(o.id ORDER BY o.created_at) AS order_ids
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;
Если у пользователя есть заказы, всё хорошо.
Но если заказов нет, LEFT JOIN всё равно создаёт одну строку для пользователя, просто колонки заказа будут NULL.
В результате ARRAY_AGG(o.id) может вернуть не пустой массив, а массив с одним NULL:
{NULL}
Для API это обычно неудобно. Клиент ждёт пустой список заказов, а получает список из одного пустого значения.
Лучше писать так:
SELECT
u.id,
u.email,
COALESCE(
ARRAY_AGG(o.id ORDER BY o.created_at)
FILTER (WHERE o.id IS NOT NULL),
ARRAY[]::integer[]
) AS order_ids
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;
Что здесь происходит:
FILTER (WHERE o.id IS NOT NULL)
убирает пустую строку, которая появилась из-за LEFT JOIN.
А:
COALESCE(..., ARRAY[]::integer[])
заменяет NULL на пустой массив.
Результат для пользователя без заказов будет:
{}
Это обычно именно то, что нужно для отчёта или API.
Почему нужен ARRAY[]::integer[]
В примере выше мы написали:
ARRAY[]::integer[]
Почему нельзя просто написать пустой массив?
Потому что PostgreSQL должен понимать тип массива.
Пустой массив сам по себе не содержит элементов, поэтому база не всегда может догадаться, какой это массив:
- массив чисел;
- массив текста;
- массив дат;
- массив UUID.
Поэтому мы явно говорим:
ARRAY[]::integer[]
Это пустой массив целых чисел.
Если собираете текстовые значения, будет так:
ARRAY[]::text[]
Если собираете UUID:
ARRAY[]::uuid[]
Если собираете numeric:
ARRAY[]::numeric[]
Для новичка можно запомнить:
Если через COALESCE подставляете пустой массив, укажите его тип явно.
ARRAY_AGG(DISTINCT ...): убрать повторы
Иногда в массиве нужны только уникальные значения.
Например, хотим получить список статусов заказов по каждому пользователю.
Таблица:
user_id | status
--------+---------
1 | paid
1 | paid
1 | failed
1 | paid
2 | refunded
2 | paid
Обычный ARRAY_AGG(status) вернёт повторы:
{paid,paid,failed,paid}
Чтобы убрать дубликаты, используем DISTINCT:
SELECT
user_id,
ARRAY_AGG(DISTINCT status) AS statuses
FROM orders
GROUP BY user_id;
Результат:
user_id | statuses
--------+-----------------
1 | {failed,paid}
2 | {paid,refunded}
Если важен порядок, можно добавить сортировку по тому же значению:
SELECT
user_id,
ARRAY_AGG(DISTINCT status ORDER BY status) AS statuses
FROM orders
GROUP BY user_id;
Теперь статусы будут отсортированы.
Ловушка DISTINCT + ORDER BY
С ARRAY_AGG(DISTINCT ...) есть важное ограничение.
Такой запрос может не сработать:
SELECT
user_id,
ARRAY_AGG(DISTINCT amount ORDER BY created_at) AS amounts
FROM orders
GROUP BY user_id;
Почему?
Потому что мы говорим:
собери уникальные amount
Но сортировать пытаемся по created_at.
После DISTINCT amount у PostgreSQL остаются уникальные суммы, а не отдельные строки заказов с датами. Поэтому непонятно, какую дату использовать для сортировки одинаковой суммы.
Правильнее сортировать по тому же выражению, которое агрегируем:
SELECT
user_id,
ARRAY_AGG(DISTINCT amount ORDER BY amount) AS amounts
FROM orders
GROUP BY user_id;
Если нужна более сложная логика, например уникальные суммы в порядке первого появления, лучше сначала подготовить данные в подзапросе, а потом уже агрегировать.
Пример: статусы заказов по странам
Допустим, есть таблицы users и orders.
Хотим узнать, какие статусы заказов встречались в каждой стране.
SELECT
u.country,
ARRAY_AGG(DISTINCT o.status ORDER BY o.status) AS seen_statuses
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.country;
Результат может быть таким:
country | seen_statuses
--------+-------------------------
Germany | {paid,refunded}
Vietnam | {failed,paid,refunded}
Такой отчёт удобно использовать для быстрой проверки данных: видно, какие состояния вообще встречались в каждой группе.
Несколько массивов в одной строке
Иногда хочется собрать несколько массивов одновременно.
Например, по каждому пользователю:
- массив
order_ids;
- массив
amounts;
- массив
statuses.
SELECT
u.id,
u.email,
ARRAY_AGG(o.id ORDER BY o.created_at DESC) AS order_ids,
ARRAY_AGG(o.amount ORDER BY o.created_at DESC) AS amounts,
ARRAY_AGG(o.status ORDER BY o.created_at DESC) AS statuses
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;
Это может вернуть:
id | email | order_ids | amounts | statuses
---+---------------+---------------+----------------+---------------------
1 | anna@mail.com | {103,102,101} | {900,2300,1500}| {failed,paid,paid}
Здесь очень важно использовать одинаковый ORDER BY во всех массивах.
Почему?
Потому что связь между массивами держится только на позиции элемента.
Например:
order_ids[1] соответствует amounts[1] и statuses[1]
Если в одном массиве сортировка будет по created_at DESC, а в другом по amount, элементы перестанут соответствовать друг другу.
Это опасная ошибка.
Правило:
Если собираете несколько связанных массивов, используйте одинаковый ORDER BY в каждом ARRAY_AGG.
Но ещё лучше для таких случаев часто использовать JSON или массив составных значений, чтобы не держать связь только на позиции.
Лучше не злоупотреблять параллельными массивами
Параллельные массивы выглядят удобно:
order_ids = {103,102,101}
amounts = {900,2300,1500}
statuses = {failed,paid,paid}
Но они хрупкие.
Если порядок где-то изменился, связь между заказом, суммой и статусом ломается.
Более надёжный вариант — собрать каждый заказ как отдельный объект. Например, через JSON:
SELECT
u.id,
u.email,
JSONB_AGG(
JSONB_BUILD_OBJECT(
'id', o.id,
'amount', o.amount,
'status', o.status,
'created_at', o.created_at
)
ORDER BY o.created_at DESC
) AS orders
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;
Результат будет похож на список объектов:
[
{"id": 103, "amount": 900, "status": "failed", "created_at": "2026-06-03"},
{"id": 102, "amount": 2300, "status": "paid", "created_at": "2026-06-02"},
{"id": 101, "amount": 1500, "status": "paid", "created_at": "2026-06-01"}
]
Для API такой формат часто удобнее, чем несколько отдельных массивов.
Но если вам нужен именно простой список значений, ARRAY_AGG остаётся отличным решением.
Пример: подчинённые менеджера
ARRAY_AGG хорошо подходит для иерархических данных.
Допустим, есть таблица employees:
id | name | manager_id | salary
---+-------+------------+--------
1 | Anna | NULL | 250000
2 | Bob | 1 | 180000
3 | Kate | 1 | 170000
4 | Tom | 2 | 120000
Хотим собрать список подчинённых для каждого менеджера:
SELECT
manager_id,
ARRAY_AGG(name ORDER BY salary DESC) AS reports
FROM employees
WHERE manager_id IS NOT NULL
GROUP BY manager_id;
Результат:
manager_id | reports
-----------+-------------
1 | {Bob,Kate}
2 | {Tom}
Здесь сотрудники внутри массива отсортированы по зарплате от большей к меньшей.
Это хороший пример, где массив делает результат компактнее и удобнее для чтения.
Пример: теги статьи
Допустим, есть статьи и теги.
Таблица article_tags:
article_id | tag
-----------+------------
1 | sql
1 | postgresql
1 | arrays
2 | mysql
2 | json
Хотим получить одну строку на статью и массив тегов:
SELECT
article_id,
ARRAY_AGG(tag ORDER BY tag) AS tags
FROM article_tags
GROUP BY article_id;
Результат:
article_id | tags
-----------+-------------------------
1 | {arrays,postgresql,sql}
2 | {json,mysql}
Это удобно для блога, каталога, карточек товаров и любых сущностей, где есть набор меток.
UNNEST: развернуть массив обратно в строки
Обратная операция к ARRAY_AGG — UNNEST.
Если ARRAY_AGG собирает строки в массив, то UNNEST разворачивает массив обратно в строки.
Пример:
SELECT UNNEST(ARRAY['sql', 'postgresql', 'arrays']) AS tag;
Результат:
tag
----------
sql
postgresql
arrays
Если в таблице articles есть колонка tags с массивом тегов:
id | title | tags
---+-------------------+-------------------------
1 | PostgreSQL arrays | {sql,postgresql,arrays}
можно развернуть теги так:
SELECT
id,
UNNEST(tags) AS tag
FROM articles;
Результат:
id | tag
---+------------
1 | sql
1 | postgresql
1 | arrays
Теперь с тегами снова можно работать как со строками: фильтровать, группировать, соединять с другими таблицами.
WITH ORDINALITY: сохранить позицию элемента
Иногда важно не только развернуть массив, но и знать позицию элемента.
Для этого используется WITH ORDINALITY.
SELECT
a.id,
t.tag,
t.pos
FROM articles a,
UNNEST(a.tags) WITH ORDINALITY AS t(tag, pos);
Результат:
id | tag | pos
---+------------+-----
1 | sql | 1
1 | postgresql | 2
1 | arrays | 3
pos показывает номер элемента в массиве.
Это полезно, если порядок тегов, шагов, статусов или событий имеет смысл.
ARRAY_AGG как финальный слой выдачи
Важно понимать, где ARRAY_AGG особенно полезен.
Внутри базы данные часто лучше хранить нормально, в отдельных строках.
Например, теги статьи лучше хранить так:
article_id | tag
-----------+----------
1 | sql
1 | postgres
1 | arrays
Так их проще:
- индексировать;
- фильтровать;
- обновлять;
- удалять;
- соединять с другими таблицами;
- проверять на дубли.
А вот на выходе, например для API или отчёта, можно собрать их в массив:
SELECT
article_id,
ARRAY_AGG(tag ORDER BY tag) AS tags
FROM article_tags
GROUP BY article_id;
То есть хороший паттерн такой:
Хранить данные строками, а собирать массив на чтении.
ARRAY_AGG отлично подходит как финальный слой представления данных.
Когда массив в колонке — плохая идея
PostgreSQL умеет хранить массивы прямо в колонках.
Например:
id | tags
---+-------------------------
1 | {sql,postgresql,arrays}
Иногда это удобно.
Но не стоит использовать массивы как способ спрятать плохо продуманную связь.
Если по элементам массива нужно часто:
- искать;
- фильтровать;
- джойнить;
- обновлять отдельные элементы;
- считать статистику;
- проверять уникальность;
- строить связи с другими таблицами,
то отдельная дочерняя таблица почти всегда лучше.
Например, вместо массива тегов в колонке:
articles(id, title, tags)
часто лучше сделать таблицу:
article_tags(article_id, tag)
А массив собирать уже в запросе через ARRAY_AGG.
Можно запомнить так:
Массив хорош для выдачи.
Отдельные строки хороши для хранения и анализа.
ARRAY_AGG и пустые группы
Если агрегату нечего собрать, результатом будет NULL, а не пустой массив.
Это часто всплывает в запросах с FILTER или подзапросами.
Например:
SELECT
user_id,
ARRAY_AGG(id) FILTER (WHERE status = 'paid') AS paid_order_ids
FROM orders
GROUP BY user_id;
Если у пользователя есть заказы, но ни один не оплачен, paid_order_ids может быть NULL.
Если для API нужен пустой массив, используйте COALESCE:
SELECT
user_id,
COALESCE(
ARRAY_AGG(id ORDER BY created_at)
FILTER (WHERE status = 'paid'),
ARRAY[]::integer[]
) AS paid_order_ids
FROM orders
GROUP BY user_id;
Теперь вместо NULL будет пустой массив:
{}
Для клиентского кода это часто удобнее: можно всегда ожидать список, а не проверять отдельно null.
ARRAY_AGG в подзапросе для каждого пользователя
Иногда удобно собрать массив через подзапрос.
Например, получить всех пользователей, а рядом — массив их заказов.
SELECT
u.id,
u.email,
COALESCE(o.order_ids, ARRAY[]::integer[]) AS order_ids
FROM users u
LEFT JOIN (
SELECT
user_id,
ARRAY_AGG(id ORDER BY created_at DESC) AS order_ids
FROM orders
GROUP BY user_id
) o ON o.user_id = u.id;
Такой подход иногда читается проще, чем большой запрос с LEFT JOIN и GROUP BY по всем колонкам пользователя.
Логика такая:
- во внутреннем запросе собираем заказы по
user_id;
- потом присоединяем готовый массив к пользователю;
- если заказов нет, через
COALESCE возвращаем пустой массив.
ARRAY_AGG в PostgreSQL и JSON в MySQL
В PostgreSQL есть настоящий тип массива, поэтому ARRAY_AGG возвращает именно массив.
В MySQL настоящих массивов как отдельного типа нет. Поэтому похожую задачу часто решают через JSON.
Например:
SELECT
user_id,
JSON_ARRAYAGG(amount) AS amounts
FROM orders
GROUP BY user_id;
Результатом будет JSON-массив.
Например:
[1500, 2300, 900]
Это похоже по смыслу, но это уже не SQL-массив PostgreSQL, а JSON-значение.
Также в MySQL иногда используют GROUP_CONCAT.
SELECT
user_id,
GROUP_CONCAT(amount ORDER BY created_at SEPARATOR ',') AS amounts
FROM orders
GROUP BY user_id;
Но GROUP_CONCAT возвращает строку:
1500,2300,900
Это не массив. С такой строкой сложнее безопасно работать дальше, особенно если значения сами могут содержать разделители.
Поэтому если нужен именно структурированный список, в MySQL чаще лучше смотреть в сторону JSON.
Аналог в ClickHouse
В ClickHouse похожую задачу решает функция groupArray.
Например:
SELECT
user_id,
groupArray(amount) AS amounts
FROM orders
GROUP BY user_id;
Она собирает значения группы в массив.
Для сортировки можно использовать функции вроде arraySort или специальные варианты в зависимости от задачи и версии ClickHouse.
Пример:
SELECT
user_id,
arraySort(groupArray(amount)) AS amounts
FROM orders
GROUP BY user_id;
Общая идея такая же:
собрать несколько строк одной группы в один массив.
Но синтаксис отличается от PostgreSQL.
Практические шаблоны
Собрать id заказов по пользователю
SELECT
user_id,
ARRAY_AGG(id ORDER BY created_at) AS order_ids
FROM orders
GROUP BY user_id;
Собрать суммы заказов по пользователю
SELECT
user_id,
ARRAY_AGG(amount ORDER BY created_at) AS amounts
FROM orders
GROUP BY user_id;
Собрать только оплаченные заказы
SELECT
user_id,
ARRAY_AGG(id ORDER BY created_at)
FILTER (WHERE status = 'paid') AS paid_order_ids
FROM orders
GROUP BY user_id;
Убрать NULL из массива
SELECT
user_id,
ARRAY_AGG(status)
FILTER (WHERE status IS NOT NULL) AS statuses
FROM orders
GROUP BY user_id;
Вернуть пустой массив вместо NULL
SELECT
user_id,
COALESCE(
ARRAY_AGG(id ORDER BY created_at)
FILTER (WHERE status = 'paid'),
ARRAY[]::integer[]
) AS paid_order_ids
FROM orders
GROUP BY user_id;
Собрать уникальные статусы
SELECT
user_id,
ARRAY_AGG(DISTINCT status ORDER BY status) AS statuses
FROM orders
GROUP BY user_id;
Собрать теги статьи
SELECT
article_id,
ARRAY_AGG(tag ORDER BY tag) AS tags
FROM article_tags
GROUP BY article_id;
Собрать подчинённых менеджера
SELECT
manager_id,
ARRAY_AGG(name ORDER BY salary DESC) AS reports
FROM employees
WHERE manager_id IS NOT NULL
GROUP BY manager_id;
Развернуть массив обратно в строки
SELECT
id,
UNNEST(tags) AS tag
FROM articles;
Развернуть массив с позицией элемента
SELECT
a.id,
t.tag,
t.pos
FROM articles a,
UNNEST(a.tags) WITH ORDINALITY AS t(tag, pos);
Что важно запомнить
ARRAY_AGG собирает значения из нескольких строк в массив.
Пример:
SELECT
user_id,
ARRAY_AGG(id ORDER BY created_at) AS order_ids
FROM orders
GROUP BY user_id;
Так можно получить одну строку на пользователя и список его заказов внутри массива.
Главные правила:
ARRAY_AGG используется как агрегатная функция;
- чаще всего он работает вместе с
GROUP BY;
- без
ORDER BY порядок элементов массива не гарантирован;
ORDER BY лучше писать внутри ARRAY_AGG;
NULL попадает в массив, если его не отфильтровать;
- если агрегату нечего собрать, результат может быть
NULL;
- для пустого массива используйте
COALESCE(..., ARRAY[]::type[]);
ARRAY_AGG(DISTINCT ...) убирает повторы;
- при нескольких связанных массивах используйте одинаковый порядок;
- если нужна вложенная структура для API, иногда лучше использовать
JSONB_AGG;
- для хранения связей отдельная таблица часто лучше массива в колонке.
Короткий вывод
ARRAY_AGG — это удобный способ собрать несколько строк в один массив.
Он особенно полезен, когда в базе данные хранятся нормально, строками, но на выходе хочется получить компактный список.
Например:
SELECT
user_id,
ARRAY_AGG(id ORDER BY created_at DESC) AS order_ids
FROM orders
GROUP BY user_id;
Такой запрос превращает много заказов пользователя в один массив заказов.
Главная мысль:
храните данные строками, а массив собирайте на чтении.
И почти всегда задавайте порядок:
ARRAY_AGG(value ORDER BY created_at)
Так результат будет стабильным, понятным и безопасным для отчётов, API и дальнейшей обработки.
В SQL мы обычно получаем результат в виде таблицы: одна строка за другой.
Например, если у пользователя несколько заказов, обычный запрос вернёт несколько строк:
Это нормальный табличный формат. Он удобен для фильтрации, соединений и расчётов.
Но иногда наружу хочется отдать данные иначе: не три строки на одного пользователя, а одну строку, внутри которой будет список его заказов.
Например:
Вот для этого в PostgreSQL есть агрегатная функция
ARRAY_AGG.Она собирает значения из нескольких строк в один массив.
ARRAY_AGGудобно использовать, когда у одной сущности есть много связанных значений:То есть
ARRAY_AGGпомогает превратить плоский результат в более компактный и вложенный.Что делает ARRAY_AGG простыми словами
ARRAY_AGGберёт значения из строк внутри группы и складывает их в массив.Допустим, есть таблица
orders:Если мы хотим собрать суммы заказов по каждому пользователю в массив, можно написать:
SELECT user_id, ARRAY_AGG(amount) AS amounts FROM orders GROUP BY user_id;Результат:
Что произошло:
GROUP BY user_idсобрал строки по пользователям.ARRAY_AGG(amount)взял всеamountвнутри каждой группы.То есть вместо нескольких строк на одного пользователя мы получили одну строку и массив значений внутри неё.
Базовый синтаксис ARRAY_AGG
Синтаксис простой:
ARRAY_AGG(expression)Где
expression— это значение, которое нужно собрать в массив.Например:
ARRAY_AGG(amount) ARRAY_AGG(id) ARRAY_AGG(email) ARRAY_AGG(status) ARRAY_AGG(created_at)Как и другие агрегаты,
ARRAY_AGGобычно используется вместе сGROUP BY.Пример:
SELECT user_id, ARRAY_AGG(id) AS order_ids FROM orders GROUP BY user_id;Результат:
Здесь для каждого пользователя мы собрали список его заказов.
Тип массива зависит от типа значения
ARRAY_AGGвозвращает массив того типа, который вы собираете.Если собираете числа:
ARRAY_AGG(amount)результат будет массивом чисел, например:
Если собираете текст:
ARRAY_AGG(status)результат будет массивом строк:
Если собираете даты:
ARRAY_AGG(created_at)результат будет массивом дат или timestamp-значений.
Это важно понимать:
ARRAY_AGGне превращает всё автоматически в текст. Он сохраняет тип агрегируемого выражения.Почему ORDER BY внутри ARRAY_AGG очень важен
Самая частая ошибка с
ARRAY_AGG— забыть про порядок.Например:
SELECT user_id, ARRAY_AGG(amount) AS amounts FROM orders GROUP BY user_id;Такой запрос соберёт суммы заказов в массив, но порядок элементов внутри массива не гарантирован.
Сегодня вы можете увидеть:
А после изменения плана запроса, индекса или объёма данных порядок может стать другим:
Для отчёта или API это может быть проблемой.
Поэтому в реальном коде лучше почти всегда задавать порядок явно:
SELECT user_id, ARRAY_AGG(amount ORDER BY created_at) AS amounts FROM orders GROUP BY user_id;Теперь суммы будут собраны в порядке создания заказов.
Если нужны последние заказы первыми:
SELECT user_id, ARRAY_AGG(amount ORDER BY created_at DESC) AS amounts FROM orders GROUP BY user_id;Главное правило:
Не снаружи запроса, а именно внутри агрегата.
ORDER BY снаружи и внутри — это разные вещи
Важно не путать два разных
ORDER BY.Вот такой
ORDER BYсортирует строки результата:SELECT user_id, ARRAY_AGG(amount) AS amounts FROM orders GROUP BY user_id ORDER BY user_id;Он влияет на порядок пользователей в итоговой таблице.
Например:
Но он не гарантирует порядок элементов внутри массива.
А вот такой
ORDER BYвлияет именно на порядок элементов в массиве:SELECT user_id, ARRAY_AGG(amount ORDER BY created_at) AS amounts FROM orders GROUP BY user_id ORDER BY user_id;Здесь:
ORDER BY created_atвнутри
ARRAY_AGGсортирует заказы внутри массива.А:
ORDER BY user_idв конце запроса сортирует строки результата по пользователям.
Можно запомнить так:
Пример: история заказов пользователя
Допустим, есть таблица
orders:Хотим получить по каждому пользователю список заказов от новых к старым.
SELECT user_id, ARRAY_AGG(id ORDER BY created_at DESC) AS order_ids FROM orders GROUP BY user_id;Результат:
У пользователя
1заказ102идёт первым, потому что он самый новый.Такой результат уже удобно отдавать в API или использовать в отчёте.
FILTER внутри ARRAY_AGG
ARRAY_AGGможно сочетать сFILTER (WHERE ...).Это позволяет собирать в массив не все значения, а только те, которые подходят под условие.
Например, собрать только оплаченные заказы:
SELECT user_id, ARRAY_AGG(id ORDER BY created_at) FILTER (WHERE status = 'paid') AS paid_order_ids FROM orders GROUP BY user_id;Результат может быть таким:
Заказ
103не попал в массив, потому что у него статусfailed.Это читается очень понятно:
Такой подход удобнее, чем сначала делать подзапрос, а потом агрегировать уже отфильтрованные строки.
NULL внутри ARRAY_AGG
ARRAY_AGGсобираетNULLкак обычный элемент массива.Например, есть данные:
Запрос:
SELECT user_id, ARRAY_AGG(status) AS statuses FROM orders GROUP BY user_id;может вернуть:
Если
NULLв массиве не нужен, добавьтеFILTER:SELECT user_id, ARRAY_AGG(status) FILTER (WHERE status IS NOT NULL) AS statuses FROM orders GROUP BY user_id;Теперь результат будет:
Правило простое:
ARRAY_AGG и LEFT JOIN: почему иногда получается {NULL}
Есть важная ловушка, которая часто встречается в реальных запросах.
Допустим, мы хотим получить всех пользователей и массив их заказов.
SELECT u.id, u.email, ARRAY_AGG(o.id ORDER BY o.created_at) AS order_ids FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email;Если у пользователя есть заказы, всё хорошо.
Но если заказов нет,
LEFT JOINвсё равно создаёт одну строку для пользователя, просто колонки заказа будутNULL.В результате
ARRAY_AGG(o.id)может вернуть не пустой массив, а массив с однимNULL:Для API это обычно неудобно. Клиент ждёт пустой список заказов, а получает список из одного пустого значения.
Лучше писать так:
SELECT u.id, u.email, COALESCE( ARRAY_AGG(o.id ORDER BY o.created_at) FILTER (WHERE o.id IS NOT NULL), ARRAY[]::integer[] ) AS order_ids FROM users u LEFT JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email;Что здесь происходит:
FILTER (WHERE o.id IS NOT NULL)убирает пустую строку, которая появилась из-за
LEFT JOIN.А:
COALESCE(..., ARRAY[]::integer[])заменяет
NULLна пустой массив.Результат для пользователя без заказов будет:
Это обычно именно то, что нужно для отчёта или API.
Почему нужен ARRAY[]::integer[]
В примере выше мы написали:
ARRAY[]::integer[]Почему нельзя просто написать пустой массив?
Потому что PostgreSQL должен понимать тип массива.
Пустой массив сам по себе не содержит элементов, поэтому база не всегда может догадаться, какой это массив:
Поэтому мы явно говорим:
ARRAY[]::integer[]Это пустой массив целых чисел.
Если собираете текстовые значения, будет так:
ARRAY[]::text[]Если собираете UUID:
ARRAY[]::uuid[]Если собираете numeric:
ARRAY[]::numeric[]Для новичка можно запомнить:
ARRAY_AGG(DISTINCT ...): убрать повторы
Иногда в массиве нужны только уникальные значения.
Например, хотим получить список статусов заказов по каждому пользователю.
Таблица:
Обычный
ARRAY_AGG(status)вернёт повторы:Чтобы убрать дубликаты, используем
DISTINCT:SELECT user_id, ARRAY_AGG(DISTINCT status) AS statuses FROM orders GROUP BY user_id;Результат:
Если важен порядок, можно добавить сортировку по тому же значению:
SELECT user_id, ARRAY_AGG(DISTINCT status ORDER BY status) AS statuses FROM orders GROUP BY user_id;Теперь статусы будут отсортированы.
Ловушка DISTINCT + ORDER BY
С
ARRAY_AGG(DISTINCT ...)есть важное ограничение.Такой запрос может не сработать:
SELECT user_id, ARRAY_AGG(DISTINCT amount ORDER BY created_at) AS amounts FROM orders GROUP BY user_id;Почему?
Потому что мы говорим:
Но сортировать пытаемся по
created_at.После
DISTINCT amountу PostgreSQL остаются уникальные суммы, а не отдельные строки заказов с датами. Поэтому непонятно, какую дату использовать для сортировки одинаковой суммы.Правильнее сортировать по тому же выражению, которое агрегируем:
SELECT user_id, ARRAY_AGG(DISTINCT amount ORDER BY amount) AS amounts FROM orders GROUP BY user_id;Если нужна более сложная логика, например уникальные суммы в порядке первого появления, лучше сначала подготовить данные в подзапросе, а потом уже агрегировать.
Пример: статусы заказов по странам
Допустим, есть таблицы
usersиorders.Хотим узнать, какие статусы заказов встречались в каждой стране.
SELECT u.country, ARRAY_AGG(DISTINCT o.status ORDER BY o.status) AS seen_statuses FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.country;Результат может быть таким:
Такой отчёт удобно использовать для быстрой проверки данных: видно, какие состояния вообще встречались в каждой группе.
Несколько массивов в одной строке
Иногда хочется собрать несколько массивов одновременно.
Например, по каждому пользователю:
order_ids;amounts;statuses.SELECT u.id, u.email, ARRAY_AGG(o.id ORDER BY o.created_at DESC) AS order_ids, ARRAY_AGG(o.amount ORDER BY o.created_at DESC) AS amounts, ARRAY_AGG(o.status ORDER BY o.created_at DESC) AS statuses FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email;Это может вернуть:
Здесь очень важно использовать одинаковый
ORDER BYво всех массивах.Почему?
Потому что связь между массивами держится только на позиции элемента.
Например:
Если в одном массиве сортировка будет по
created_at DESC, а в другом поamount, элементы перестанут соответствовать друг другу.Это опасная ошибка.
Правило:
Но ещё лучше для таких случаев часто использовать JSON или массив составных значений, чтобы не держать связь только на позиции.
Лучше не злоупотреблять параллельными массивами
Параллельные массивы выглядят удобно:
Но они хрупкие.
Если порядок где-то изменился, связь между заказом, суммой и статусом ломается.
Более надёжный вариант — собрать каждый заказ как отдельный объект. Например, через JSON:
SELECT u.id, u.email, JSONB_AGG( JSONB_BUILD_OBJECT( 'id', o.id, 'amount', o.amount, 'status', o.status, 'created_at', o.created_at ) ORDER BY o.created_at DESC ) AS orders FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.id, u.email;Результат будет похож на список объектов:
[ {"id": 103, "amount": 900, "status": "failed", "created_at": "2026-06-03"}, {"id": 102, "amount": 2300, "status": "paid", "created_at": "2026-06-02"}, {"id": 101, "amount": 1500, "status": "paid", "created_at": "2026-06-01"} ]Для API такой формат часто удобнее, чем несколько отдельных массивов.
Но если вам нужен именно простой список значений,
ARRAY_AGGостаётся отличным решением.Пример: подчинённые менеджера
ARRAY_AGGхорошо подходит для иерархических данных.Допустим, есть таблица
employees:Хотим собрать список подчинённых для каждого менеджера:
SELECT manager_id, ARRAY_AGG(name ORDER BY salary DESC) AS reports FROM employees WHERE manager_id IS NOT NULL GROUP BY manager_id;Результат:
Здесь сотрудники внутри массива отсортированы по зарплате от большей к меньшей.
Это хороший пример, где массив делает результат компактнее и удобнее для чтения.
Пример: теги статьи
Допустим, есть статьи и теги.
Таблица
article_tags:Хотим получить одну строку на статью и массив тегов:
SELECT article_id, ARRAY_AGG(tag ORDER BY tag) AS tags FROM article_tags GROUP BY article_id;Результат:
Это удобно для блога, каталога, карточек товаров и любых сущностей, где есть набор меток.
UNNEST: развернуть массив обратно в строки
Обратная операция к
ARRAY_AGG—UNNEST.Если
ARRAY_AGGсобирает строки в массив, тоUNNESTразворачивает массив обратно в строки.Пример:
SELECT UNNEST(ARRAY['sql', 'postgresql', 'arrays']) AS tag;Результат:
Если в таблице
articlesесть колонкаtagsс массивом тегов:можно развернуть теги так:
SELECT id, UNNEST(tags) AS tag FROM articles;Результат:
Теперь с тегами снова можно работать как со строками: фильтровать, группировать, соединять с другими таблицами.
WITH ORDINALITY: сохранить позицию элемента
Иногда важно не только развернуть массив, но и знать позицию элемента.
Для этого используется
WITH ORDINALITY.SELECT a.id, t.tag, t.pos FROM articles a, UNNEST(a.tags) WITH ORDINALITY AS t(tag, pos);Результат:
posпоказывает номер элемента в массиве.Это полезно, если порядок тегов, шагов, статусов или событий имеет смысл.
ARRAY_AGG как финальный слой выдачи
Важно понимать, где
ARRAY_AGGособенно полезен.Внутри базы данные часто лучше хранить нормально, в отдельных строках.
Например, теги статьи лучше хранить так:
Так их проще:
А вот на выходе, например для API или отчёта, можно собрать их в массив:
SELECT article_id, ARRAY_AGG(tag ORDER BY tag) AS tags FROM article_tags GROUP BY article_id;То есть хороший паттерн такой:
ARRAY_AGGотлично подходит как финальный слой представления данных.Когда массив в колонке — плохая идея
PostgreSQL умеет хранить массивы прямо в колонках.
Например:
Иногда это удобно.
Но не стоит использовать массивы как способ спрятать плохо продуманную связь.
Если по элементам массива нужно часто:
то отдельная дочерняя таблица почти всегда лучше.
Например, вместо массива тегов в колонке:
часто лучше сделать таблицу:
А массив собирать уже в запросе через
ARRAY_AGG.Можно запомнить так:
ARRAY_AGG и пустые группы
Если агрегату нечего собрать, результатом будет
NULL, а не пустой массив.Это часто всплывает в запросах с
FILTERили подзапросами.Например:
SELECT user_id, ARRAY_AGG(id) FILTER (WHERE status = 'paid') AS paid_order_ids FROM orders GROUP BY user_id;Если у пользователя есть заказы, но ни один не оплачен,
paid_order_idsможет бытьNULL.Если для API нужен пустой массив, используйте
COALESCE:SELECT user_id, COALESCE( ARRAY_AGG(id ORDER BY created_at) FILTER (WHERE status = 'paid'), ARRAY[]::integer[] ) AS paid_order_ids FROM orders GROUP BY user_id;Теперь вместо
NULLбудет пустой массив:Для клиентского кода это часто удобнее: можно всегда ожидать список, а не проверять отдельно
null.ARRAY_AGG в подзапросе для каждого пользователя
Иногда удобно собрать массив через подзапрос.
Например, получить всех пользователей, а рядом — массив их заказов.
SELECT u.id, u.email, COALESCE(o.order_ids, ARRAY[]::integer[]) AS order_ids FROM users u LEFT JOIN ( SELECT user_id, ARRAY_AGG(id ORDER BY created_at DESC) AS order_ids FROM orders GROUP BY user_id ) o ON o.user_id = u.id;Такой подход иногда читается проще, чем большой запрос с
LEFT JOINиGROUP BYпо всем колонкам пользователя.Логика такая:
user_id;COALESCEвозвращаем пустой массив.ARRAY_AGG в PostgreSQL и JSON в MySQL
В PostgreSQL есть настоящий тип массива, поэтому
ARRAY_AGGвозвращает именно массив.В MySQL настоящих массивов как отдельного типа нет. Поэтому похожую задачу часто решают через JSON.
Например:
SELECT user_id, JSON_ARRAYAGG(amount) AS amounts FROM orders GROUP BY user_id;Результатом будет JSON-массив.
Например:
[1500, 2300, 900]Это похоже по смыслу, но это уже не SQL-массив PostgreSQL, а JSON-значение.
Также в MySQL иногда используют
GROUP_CONCAT.SELECT user_id, GROUP_CONCAT(amount ORDER BY created_at SEPARATOR ',') AS amounts FROM orders GROUP BY user_id;Но
GROUP_CONCATвозвращает строку:Это не массив. С такой строкой сложнее безопасно работать дальше, особенно если значения сами могут содержать разделители.
Поэтому если нужен именно структурированный список, в MySQL чаще лучше смотреть в сторону JSON.
Аналог в ClickHouse
В ClickHouse похожую задачу решает функция
groupArray.Например:
SELECT user_id, groupArray(amount) AS amounts FROM orders GROUP BY user_id;Она собирает значения группы в массив.
Для сортировки можно использовать функции вроде
arraySortили специальные варианты в зависимости от задачи и версии ClickHouse.Пример:
SELECT user_id, arraySort(groupArray(amount)) AS amounts FROM orders GROUP BY user_id;Общая идея такая же:
Но синтаксис отличается от PostgreSQL.
Практические шаблоны
Собрать id заказов по пользователю
SELECT user_id, ARRAY_AGG(id ORDER BY created_at) AS order_ids FROM orders GROUP BY user_id;Собрать суммы заказов по пользователю
SELECT user_id, ARRAY_AGG(amount ORDER BY created_at) AS amounts FROM orders GROUP BY user_id;Собрать только оплаченные заказы
SELECT user_id, ARRAY_AGG(id ORDER BY created_at) FILTER (WHERE status = 'paid') AS paid_order_ids FROM orders GROUP BY user_id;Убрать NULL из массива
SELECT user_id, ARRAY_AGG(status) FILTER (WHERE status IS NOT NULL) AS statuses FROM orders GROUP BY user_id;Вернуть пустой массив вместо NULL
SELECT user_id, COALESCE( ARRAY_AGG(id ORDER BY created_at) FILTER (WHERE status = 'paid'), ARRAY[]::integer[] ) AS paid_order_ids FROM orders GROUP BY user_id;Собрать уникальные статусы
SELECT user_id, ARRAY_AGG(DISTINCT status ORDER BY status) AS statuses FROM orders GROUP BY user_id;Собрать теги статьи
SELECT article_id, ARRAY_AGG(tag ORDER BY tag) AS tags FROM article_tags GROUP BY article_id;Собрать подчинённых менеджера
SELECT manager_id, ARRAY_AGG(name ORDER BY salary DESC) AS reports FROM employees WHERE manager_id IS NOT NULL GROUP BY manager_id;Развернуть массив обратно в строки
SELECT id, UNNEST(tags) AS tag FROM articles;Развернуть массив с позицией элемента
SELECT a.id, t.tag, t.pos FROM articles a, UNNEST(a.tags) WITH ORDINALITY AS t(tag, pos);Что важно запомнить
ARRAY_AGGсобирает значения из нескольких строк в массив.Пример:
SELECT user_id, ARRAY_AGG(id ORDER BY created_at) AS order_ids FROM orders GROUP BY user_id;Так можно получить одну строку на пользователя и список его заказов внутри массива.
Главные правила:
ARRAY_AGGиспользуется как агрегатная функция;GROUP BY;ORDER BYпорядок элементов массива не гарантирован;ORDER BYлучше писать внутриARRAY_AGG;NULLпопадает в массив, если его не отфильтровать;NULL;COALESCE(..., ARRAY[]::type[]);ARRAY_AGG(DISTINCT ...)убирает повторы;JSONB_AGG;Короткий вывод
ARRAY_AGG— это удобный способ собрать несколько строк в один массив.Он особенно полезен, когда в базе данные хранятся нормально, строками, но на выходе хочется получить компактный список.
Например:
SELECT user_id, ARRAY_AGG(id ORDER BY created_at DESC) AS order_ids FROM orders GROUP BY user_id;Такой запрос превращает много заказов пользователя в один массив заказов.
Главная мысль:
И почти всегда задавайте порядок:
ARRAY_AGG(value ORDER BY created_at)Так результат будет стабильным, понятным и безопасным для отчётов, API и дальнейшей обработки.