Иногда в SQL нужно получить не много строк, а одну красивую строку со списком.
Например:
- все товары в заказе;
- все теги у статьи;
- все email-адреса участников проекта;
- все роли пользователя;
- все города, где были покупки.
Без агрегации результат выглядит так:
| order_id |
product_name |
| 101 |
Mouse |
| 101 |
Keyboard |
| 101 |
Monitor |
А хочется так:
| order_id |
products |
| 101 |
Mouse, Keyboard, Monitor |
То есть мы берём несколько значений внутри одной группы и склеиваем их в одну строку через разделитель.
В PostgreSQL для этого есть функция STRING_AGG. В MySQL похожую задачу решает GROUP_CONCAT. В ClickHouse обычно используют связку arrayStringConcat(groupArray(...)).
Разберём всё спокойно: базовый синтаксис, сортировку, удаление дублей, работу с NULL, отличия между СУБД и главную ловушку — дубли из-за JOIN.
Что такое STRING_AGG
STRING_AGG — это агрегатная функция PostgreSQL, которая склеивает значения из нескольких строк в одну строку.
Синтаксис:
STRING_AGG(expression, delimiter)
Где:
expression — значение, которое нужно склеить;
delimiter — разделитель между значениями.
Например, если в таблице users есть колонка email, можно собрать все email-адреса в одну строку:
SELECT STRING_AGG(email, ', ') AS all_emails
FROM users;
Результат может быть таким:
Разделитель ', ' означает: между значениями поставь запятую и пробел.
Если поставить другой разделитель, результат тоже изменится:
SELECT STRING_AGG(email, ' | ') AS all_emails
FROM users;
Результат:
Главная идея простая: STRING_AGG превращает столбец из нескольких строк в один текстовый список.
STRING_AGG и нестроковые значения
STRING_AGG работает со строками. Если нужно склеить числа, даты или другие типы, их лучше явно привести к тексту.
Например, соберём id пользователей:
SELECT STRING_AGG(id::text, ', ') AS user_ids
FROM users;
Результат:
То же самое можно написать через CAST:
SELECT STRING_AGG(CAST(id AS text), ', ') AS user_ids
FROM users;
Оба варианта делают одно и то же: превращают число в текст, чтобы его можно было склеить.
STRING_AGG вместе с GROUP BY
Чаще всего STRING_AGG используют не по всей таблице сразу, а по группам.
Представим интернет-магазин. Есть таблица заказов:
CREATE TABLE orders (
id integer,
customer_email text
);
И есть таблица товаров в заказах:
CREATE TABLE order_items (
id integer,
order_id integer,
product_name text,
added_at timestamp
);
В одном заказе может быть несколько товаров. Если сделать обычный JOIN, мы получим несколько строк на один заказ:
SELECT
o.id AS order_id,
oi.product_name
FROM orders o
JOIN order_items oi ON oi.order_id = o.id;
Результат:
| order_id |
product_name |
| 101 |
Mouse |
| 101 |
Keyboard |
| 101 |
Monitor |
| 102 |
Webcam |
| 102 |
USB Cable |
Теперь соберём товары каждого заказа в одну строку:
SELECT
o.id AS order_id,
STRING_AGG(oi.product_name, ', ') AS products
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
Результат:
| order_id |
products |
| 101 |
Mouse, Keyboard, Monitor |
| 102 |
Webcam, USB Cable |
Здесь GROUP BY o.id говорит: «собери строки по заказам». А STRING_AGG внутри каждой группы склеивает названия товаров.
То есть COUNT считает строки, SUM складывает числа, а STRING_AGG склеивает текст.
Как STRING_AGG работает с NULL
STRING_AGG, как и многие агрегатные функции, пропускает NULL.
Например, если в заказе есть такие товары:
| order_id |
product_name |
| 101 |
Mouse |
| 101 |
NULL |
| 101 |
Keyboard |
Запрос:
SELECT
order_id,
STRING_AGG(product_name, ', ') AS products
FROM order_items
GROUP BY order_id;
вернёт:
| order_id |
products |
| 101 |
Mouse, Keyboard |
NULL не попадёт в строку. И лишнего разделителя тоже не будет.
Это удобно, потому что результат остаётся аккуратным. Но есть и обратная сторона: пропуски в данных можно случайно не заметить.
Если вам важно показать, что значение отсутствовало, используйте COALESCE:
SELECT
order_id,
STRING_AGG(COALESCE(product_name, 'Unknown product'), ', ') AS products
FROM order_items
GROUP BY order_id;
Теперь вместо NULL в списке появится понятная заглушка:
| order_id |
products |
| 101 |
Mouse, Unknown product, Keyboard |
Такой подход полезен в отчётах, где пропуск сам по себе важен.
Почему нужен ORDER BY внутри STRING_AGG
Очень частая ошибка новичков — думать, что значения склеятся «в нормальном порядке».
Например, запрос:
SELECT
o.id AS order_id,
STRING_AGG(oi.product_name, ', ') AS products
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
может вернуть:
| order_id |
products |
| 101 |
Keyboard, Mouse, Monitor |
А в следующий раз порядок может оказаться другим. SQL не обязан сохранять порядок строк, если вы явно его не указали.
Обычный ORDER BY в конце запроса сортирует уже готовые строки результата. Он не управляет порядком значений внутри STRING_AGG.
Правильный способ — писать ORDER BY внутри агрегатной функции:
SELECT
o.id AS order_id,
STRING_AGG(oi.product_name, ', ' ORDER BY oi.product_name) AS products
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
Теперь товары внутри строки будут отсортированы по названию:
| order_id |
products |
| 101 |
Keyboard, Monitor, Mouse |
Сортировать можно не только по тому значению, которое склеиваем.
Например, если нужно показать товары в порядке добавления в заказ:
SELECT
o.id AS order_id,
STRING_AGG(oi.product_name, ', ' ORDER BY oi.added_at) AS products
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
А если сначала нужны самые новые добавленные товары:
SELECT
o.id AS order_id,
STRING_AGG(oi.product_name, ', ' ORDER BY oi.added_at DESC) AS products
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
Запомните правило: если список будет читать человек, почти всегда нужен порядок.
Как убрать дубликаты через DISTINCT
Иногда в списке появляются повторы.
Например, нужно собрать валюты заказов по странам:
| customer_country |
currency |
| Germany |
EUR |
| Germany |
EUR |
| Germany |
USD |
| Brazil |
BRL |
| Brazil |
USD |
| Brazil |
USD |
Если написать обычный STRING_AGG, повторы попадут в результат:
SELECT
customer_country,
STRING_AGG(currency, ', ' ORDER BY currency) AS currencies
FROM orders
GROUP BY customer_country;
Результат:
| customer_country |
currencies |
| Brazil |
BRL, USD, USD |
| Germany |
EUR, EUR, USD |
Чтобы оставить только уникальные значения, добавьте DISTINCT:
SELECT
customer_country,
STRING_AGG(DISTINCT currency, ', ' ORDER BY currency) AS currencies
FROM orders
GROUP BY customer_country;
Результат:
| customer_country |
currencies |
| Brazil |
BRL, USD |
| Germany |
EUR, USD |
DISTINCT ставится прямо перед выражением, которое мы склеиваем.
Но есть важное ограничение PostgreSQL: если вы используете DISTINCT внутри агрегата, то сортировать можно только по выражениям, которые участвуют в агрегате.
Вот такой вариант нормальный:
STRING_AGG(DISTINCT currency, ', ' ORDER BY currency)
А вот такой может не сработать:
STRING_AGG(DISTINCT currency, ', ' ORDER BY created_at)
Почему? Потому что после DISTINCT currency у одного значения currency может быть много разных created_at. PostgreSQL не может сам решить, какую дату брать для сортировки.
Если нужна сложная логика — сначала подготовьте данные в подзапросе, а потом агрегируйте.
Пример: список тегов у статьи
Возьмём более жизненный пример: блог со статьями и тегами.
Есть таблица статей:
CREATE TABLE articles (
id integer,
title text
);
Есть таблица тегов:
CREATE TABLE article_tags (
article_id integer,
tag text
);
Нужно вывести по одной строке на статью и рядом список тегов.
SELECT
a.id,
a.title,
STRING_AGG(at.tag, ', ' ORDER BY at.tag) AS tags
FROM articles a
JOIN article_tags at ON at.article_id = a.id
GROUP BY a.id, a.title;
Результат:
| id |
title |
tags |
| 1 |
SQL basics |
aggregate, select, where |
| 2 |
PostgreSQL JSONB |
jsonb, postgres, sql |
Такой формат удобен для админок, отчётов, экспорта и быстрых сводных таблиц.
Как сохранить строки без значений
Если использовать обычный JOIN, статьи без тегов пропадут:
SELECT
a.id,
a.title,
STRING_AGG(at.tag, ', ' ORDER BY at.tag) AS tags
FROM articles a
JOIN article_tags at ON at.article_id = a.id
GROUP BY a.id, a.title;
Если у статьи нет ни одного тега, ей не с чем соединиться — и она исчезнет из результата.
Чтобы сохранить все статьи, используйте LEFT JOIN:
SELECT
a.id,
a.title,
STRING_AGG(at.tag, ', ' ORDER BY at.tag) AS tags
FROM articles a
LEFT JOIN article_tags at ON at.article_id = a.id
GROUP BY a.id, a.title;
Теперь статья без тегов останется, а в колонке tags будет NULL.
Если хочется показать пустую строку или понятную подпись, используйте COALESCE:
SELECT
a.id,
a.title,
COALESCE(STRING_AGG(at.tag, ', ' ORDER BY at.tag), 'No tags') AS tags
FROM articles a
LEFT JOIN article_tags at ON at.article_id = a.id
GROUP BY a.id, a.title;
Результат:
| id |
title |
tags |
| 1 |
SQL basics |
aggregate, select, where |
| 2 |
PostgreSQL JSONB |
jsonb, postgres, sql |
| 3 |
Empty draft |
No tags |
Это хороший шаблон для отчётов: не теряем основные сущности, даже если связанных строк нет.
Главная ловушка: дубли из-за JOIN
Самая неприятная проблема со строковой агрегацией — не сама функция, а неправильная гранулярность данных.
Гранулярность — это уровень детализации строки.
Например:
- одна строка на заказ;
- одна строка на товар в заказе;
- одна строка на платёж;
- одна строка на доставку.
Проблемы начинаются, когда в одном запросе соединяются несколько таблиц «один ко многим».
Представим заказ 101. В нём 3 товара и 2 платежа.
Если соединить orders, order_items и payments, товары размножатся на платежи:
| order_id |
product_name |
payment_id |
| 101 |
Mouse |
1 |
| 101 |
Keyboard |
1 |
| 101 |
Monitor |
1 |
| 101 |
Mouse |
2 |
| 101 |
Keyboard |
2 |
| 101 |
Monitor |
2 |
Теперь если сделать STRING_AGG(product_name, ', '), получится:
Mouse, Keyboard, Monitor, Mouse, Keyboard, Monitor
Товары повторились не потому, что STRING_AGG ошибся. Он честно склеил те строки, которые вы ему дали. Ошибка возникла раньше — на этапе JOIN.
Как лечить дубли из-за JOIN
Есть два основных способа.
Первый способ — использовать DISTINCT, если повторы действительно можно просто убрать:
SELECT
o.id AS order_id,
STRING_AGG(DISTINCT oi.product_name, ', ' ORDER BY oi.product_name) AS products,
SUM(p.amount) AS paid
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN payments p ON p.order_id = o.id
GROUP BY o.id;
Но это не всегда правильно. Если в заказе реально было два одинаковых товара, DISTINCT склеит их в один и может исказить смысл.
Второй способ — сначала агрегировать товары отдельно, а потом соединять результат с остальными таблицами.
SELECT
o.id AS order_id,
pr.products,
SUM(p.amount) AS paid
FROM orders o
JOIN payments p ON p.order_id = o.id
JOIN (
SELECT
order_id,
STRING_AGG(product_name, ', ' ORDER BY product_name) AS products
FROM order_items
GROUP BY order_id
) pr ON pr.order_id = o.id
GROUP BY o.id, pr.products;
Что здесь происходит:
- В подзапросе
pr мы заранее собираем товары по каждому заказу.
- Получаем одну строку на заказ.
- Потом соединяем эту уже готовую строку с заказами и платежами.
Так мы не даём товарам размножиться из-за платежей.
Это очень важный приём: если разные части запроса имеют разную детализацию, агрегируйте каждую часть на нужном уровне отдельно.
MySQL: GROUP_CONCAT
В MySQL аналог STRING_AGG называется GROUP_CONCAT.
Базовый пример:
SELECT GROUP_CONCAT(email SEPARATOR ', ') AS all_emails
FROM users;
С GROUP BY:
SELECT
o.id AS order_id,
GROUP_CONCAT(oi.product_name SEPARATOR ', ') AS products
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
Сортировка пишется внутри функции:
SELECT
o.id AS order_id,
GROUP_CONCAT(oi.product_name ORDER BY oi.product_name SEPARATOR ', ') AS products
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;
DISTINCT тоже поддерживается:
SELECT
customer_country,
GROUP_CONCAT(DISTINCT currency ORDER BY currency SEPARATOR ', ') AS currencies
FROM orders
GROUP BY customer_country;
Если разделитель — обычная запятая, SEPARATOR можно не указывать. Но в реальных отчётах чаще используют ', ', потому что так результат читается приятнее.
Важная ловушка MySQL: group_concat_max_len
У MySQL есть особенность: результат GROUP_CONCAT ограничен настройкой group_concat_max_len.
По умолчанию лимит может быть небольшим. Если список длинный, MySQL может обрезать результат.
Это особенно неприятно, потому что вы можете получить не ошибку, а просто укороченную строку.
Например, ожидали список из 500 email-адресов, а получили только начало списка.
Для текущей сессии лимит можно увеличить так:
SET SESSION group_concat_max_len = 1000000;
После этого длинные списки будут помещаться в результат лучше.
Важная мысль: если в MySQL вы используете GROUP_CONCAT для отчётов с потенциально длинными строками, всегда помните про group_concat_max_len.
ClickHouse: arrayStringConcat и groupArray
В ClickHouse прямого аналога STRING_AGG обычно не используют. Вместо этого задачу раскладывают на два шага:
groupArray собирает значения группы в массив.
arrayStringConcat склеивает элементы массива в строку.
Пример:
SELECT
order_id,
arrayStringConcat(groupArray(product_name), ', ') AS products
FROM order_items
GROUP BY order_id;
Результат:
| order_id |
products |
| 101 |
Mouse, Keyboard, Monitor |
| 102 |
Webcam, USB Cable |
Если нужен отсортированный список, используйте arraySort:
SELECT
order_id,
arrayStringConcat(arraySort(groupArray(product_name)), ', ') AS products
FROM order_items
GROUP BY order_id;
Если нужны только уникальные значения, используйте groupUniqArray:
SELECT
customer_country,
arrayStringConcat(arraySort(groupUniqArray(currency)), ', ') AS currencies
FROM orders
GROUP BY customer_country;
Если значения не строковые, их нужно привести к строке заранее:
SELECT
order_id,
arrayStringConcat(groupArray(toString(product_id)), ', ') AS product_ids
FROM order_items
GROUP BY order_id;
Иначе можно получить ошибку типов, потому что arrayStringConcat склеивает именно строки.
PostgreSQL, MySQL и ClickHouse: короткое сравнение
| СУБД |
Как склеить строки |
| PostgreSQL |
STRING_AGG(value, ', ' ORDER BY value) |
| MySQL |
GROUP_CONCAT(value ORDER BY value SEPARATOR ', ') |
| ClickHouse |
arrayStringConcat(arraySort(groupArray(value)), ', ') |
По смыслу это одна и та же задача: взять много значений внутри группы и превратить их в один читаемый список.
Но синтаксис отличается, поэтому при переходе между СУБД не копируйте запрос механически.
Практические правила
Если собираете строки в список, держите в голове несколько правил.
Первое: всегда думайте о порядке.
Плохо:
SELECT
order_id,
STRING_AGG(product_name, ', ') AS products
FROM order_items
GROUP BY order_id;
Лучше:
SELECT
order_id,
STRING_AGG(product_name, ', ' ORDER BY product_name) AS products
FROM order_items
GROUP BY order_id;
Второе: помните про NULL.
Если пропуски можно игнорировать, обычный STRING_AGG подходит. Если пропуски важны, используйте COALESCE.
SELECT
order_id,
STRING_AGG(COALESCE(product_name, 'Unknown product'), ', ' ORDER BY product_name) AS products
FROM order_items
GROUP BY order_id;
Третье: проверяйте дубли после JOIN.
Если список внезапно стал в два или три раза длиннее, почти всегда виновато размножение строк при соединении таблиц.
Четвёртое: не лечите всё подряд через DISTINCT.
DISTINCT полезен, когда вам действительно нужен список уникальных значений. Но если повтор означает реальное количество, DISTINCT может спрятать важную информацию.
Пятое: в MySQL помните про group_concat_max_len.
Для длинных отчётов лучше заранее увеличить лимит.
Главное
STRING_AGG в PostgreSQL собирает значения из нескольких строк в одну строку с разделителем.
Базовый пример:
SELECT STRING_AGG(email, ', ') AS all_emails
FROM users;
Пример по группам:
SELECT
order_id,
STRING_AGG(product_name, ', ' ORDER BY product_name) AS products
FROM order_items
GROUP BY order_id;
Для MySQL используется GROUP_CONCAT:
SELECT
order_id,
GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', ') AS products
FROM order_items
GROUP BY order_id;
Для ClickHouse — связка groupArray и arrayStringConcat:
SELECT
order_id,
arrayStringConcat(arraySort(groupArray(product_name)), ', ') AS products
FROM order_items
GROUP BY order_id;
Главные вещи, которые нужно запомнить:
- без
ORDER BY порядок внутри склеенной строки не гарантирован;
NULL обычно пропускается;
DISTINCT убирает повторы, но может скрыть важный смысл;
- после сложных
JOIN значения часто дублируются из-за размножения строк;
- в MySQL длинный результат может упереться в
group_concat_max_len.
Строковая агрегация — это не просто «склеить текст». Это способ превратить подробные табличные данные в понятный человеческий список: товары в заказе, теги у статьи, роли пользователя, участников проекта или любые другие значения, которые удобнее читать в одной ячейке.
Иногда в SQL нужно получить не много строк, а одну красивую строку со списком.
Например:
Без агрегации результат выглядит так:
А хочется так:
То есть мы берём несколько значений внутри одной группы и склеиваем их в одну строку через разделитель.
В PostgreSQL для этого есть функция
STRING_AGG. В MySQL похожую задачу решаетGROUP_CONCAT. В ClickHouse обычно используют связкуarrayStringConcat(groupArray(...)).Разберём всё спокойно: базовый синтаксис, сортировку, удаление дублей, работу с
NULL, отличия между СУБД и главную ловушку — дубли из-заJOIN.Что такое STRING_AGG
STRING_AGG— это агрегатная функция PostgreSQL, которая склеивает значения из нескольких строк в одну строку.Синтаксис:
Где:
expression— значение, которое нужно склеить;delimiter— разделитель между значениями.Например, если в таблице
usersесть колонкаemail, можно собрать все email-адреса в одну строку:SELECT STRING_AGG(email, ', ') AS all_emails FROM users;Результат может быть таким:
Разделитель
', 'означает: между значениями поставь запятую и пробел.Если поставить другой разделитель, результат тоже изменится:
SELECT STRING_AGG(email, ' | ') AS all_emails FROM users;Результат:
Главная идея простая:
STRING_AGGпревращает столбец из нескольких строк в один текстовый список.STRING_AGG и нестроковые значения
STRING_AGGработает со строками. Если нужно склеить числа, даты или другие типы, их лучше явно привести к тексту.Например, соберём id пользователей:
SELECT STRING_AGG(id::text, ', ') AS user_ids FROM users;Результат:
То же самое можно написать через
CAST:SELECT STRING_AGG(CAST(id AS text), ', ') AS user_ids FROM users;Оба варианта делают одно и то же: превращают число в текст, чтобы его можно было склеить.
STRING_AGG вместе с GROUP BY
Чаще всего
STRING_AGGиспользуют не по всей таблице сразу, а по группам.Представим интернет-магазин. Есть таблица заказов:
CREATE TABLE orders ( id integer, customer_email text );И есть таблица товаров в заказах:
CREATE TABLE order_items ( id integer, order_id integer, product_name text, added_at timestamp );В одном заказе может быть несколько товаров. Если сделать обычный
JOIN, мы получим несколько строк на один заказ:SELECT o.id AS order_id, oi.product_name FROM orders o JOIN order_items oi ON oi.order_id = o.id;Результат:
Теперь соберём товары каждого заказа в одну строку:
SELECT o.id AS order_id, STRING_AGG(oi.product_name, ', ') AS products FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id;Результат:
Здесь
GROUP BY o.idговорит: «собери строки по заказам». АSTRING_AGGвнутри каждой группы склеивает названия товаров.То есть
COUNTсчитает строки,SUMскладывает числа, аSTRING_AGGсклеивает текст.Как STRING_AGG работает с NULL
STRING_AGG, как и многие агрегатные функции, пропускаетNULL.Например, если в заказе есть такие товары:
Запрос:
SELECT order_id, STRING_AGG(product_name, ', ') AS products FROM order_items GROUP BY order_id;вернёт:
NULLне попадёт в строку. И лишнего разделителя тоже не будет.Это удобно, потому что результат остаётся аккуратным. Но есть и обратная сторона: пропуски в данных можно случайно не заметить.
Если вам важно показать, что значение отсутствовало, используйте
COALESCE:SELECT order_id, STRING_AGG(COALESCE(product_name, 'Unknown product'), ', ') AS products FROM order_items GROUP BY order_id;Теперь вместо
NULLв списке появится понятная заглушка:Такой подход полезен в отчётах, где пропуск сам по себе важен.
Почему нужен ORDER BY внутри STRING_AGG
Очень частая ошибка новичков — думать, что значения склеятся «в нормальном порядке».
Например, запрос:
SELECT o.id AS order_id, STRING_AGG(oi.product_name, ', ') AS products FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id;может вернуть:
А в следующий раз порядок может оказаться другим. SQL не обязан сохранять порядок строк, если вы явно его не указали.
Обычный
ORDER BYв конце запроса сортирует уже готовые строки результата. Он не управляет порядком значений внутриSTRING_AGG.Правильный способ — писать
ORDER BYвнутри агрегатной функции:SELECT o.id AS order_id, STRING_AGG(oi.product_name, ', ' ORDER BY oi.product_name) AS products FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id;Теперь товары внутри строки будут отсортированы по названию:
Сортировать можно не только по тому значению, которое склеиваем.
Например, если нужно показать товары в порядке добавления в заказ:
SELECT o.id AS order_id, STRING_AGG(oi.product_name, ', ' ORDER BY oi.added_at) AS products FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id;А если сначала нужны самые новые добавленные товары:
SELECT o.id AS order_id, STRING_AGG(oi.product_name, ', ' ORDER BY oi.added_at DESC) AS products FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id;Запомните правило: если список будет читать человек, почти всегда нужен порядок.
Как убрать дубликаты через DISTINCT
Иногда в списке появляются повторы.
Например, нужно собрать валюты заказов по странам:
Если написать обычный
STRING_AGG, повторы попадут в результат:SELECT customer_country, STRING_AGG(currency, ', ' ORDER BY currency) AS currencies FROM orders GROUP BY customer_country;Результат:
Чтобы оставить только уникальные значения, добавьте
DISTINCT:SELECT customer_country, STRING_AGG(DISTINCT currency, ', ' ORDER BY currency) AS currencies FROM orders GROUP BY customer_country;Результат:
DISTINCTставится прямо перед выражением, которое мы склеиваем.Но есть важное ограничение PostgreSQL: если вы используете
DISTINCTвнутри агрегата, то сортировать можно только по выражениям, которые участвуют в агрегате.Вот такой вариант нормальный:
STRING_AGG(DISTINCT currency, ', ' ORDER BY currency)А вот такой может не сработать:
STRING_AGG(DISTINCT currency, ', ' ORDER BY created_at)Почему? Потому что после
DISTINCT currencyу одного значенияcurrencyможет быть много разныхcreated_at. PostgreSQL не может сам решить, какую дату брать для сортировки.Если нужна сложная логика — сначала подготовьте данные в подзапросе, а потом агрегируйте.
Пример: список тегов у статьи
Возьмём более жизненный пример: блог со статьями и тегами.
Есть таблица статей:
CREATE TABLE articles ( id integer, title text );Есть таблица тегов:
CREATE TABLE article_tags ( article_id integer, tag text );Нужно вывести по одной строке на статью и рядом список тегов.
SELECT a.id, a.title, STRING_AGG(at.tag, ', ' ORDER BY at.tag) AS tags FROM articles a JOIN article_tags at ON at.article_id = a.id GROUP BY a.id, a.title;Результат:
Такой формат удобен для админок, отчётов, экспорта и быстрых сводных таблиц.
Как сохранить строки без значений
Если использовать обычный
JOIN, статьи без тегов пропадут:SELECT a.id, a.title, STRING_AGG(at.tag, ', ' ORDER BY at.tag) AS tags FROM articles a JOIN article_tags at ON at.article_id = a.id GROUP BY a.id, a.title;Если у статьи нет ни одного тега, ей не с чем соединиться — и она исчезнет из результата.
Чтобы сохранить все статьи, используйте
LEFT JOIN:SELECT a.id, a.title, STRING_AGG(at.tag, ', ' ORDER BY at.tag) AS tags FROM articles a LEFT JOIN article_tags at ON at.article_id = a.id GROUP BY a.id, a.title;Теперь статья без тегов останется, а в колонке
tagsбудетNULL.Если хочется показать пустую строку или понятную подпись, используйте
COALESCE:SELECT a.id, a.title, COALESCE(STRING_AGG(at.tag, ', ' ORDER BY at.tag), 'No tags') AS tags FROM articles a LEFT JOIN article_tags at ON at.article_id = a.id GROUP BY a.id, a.title;Результат:
Это хороший шаблон для отчётов: не теряем основные сущности, даже если связанных строк нет.
Главная ловушка: дубли из-за JOIN
Самая неприятная проблема со строковой агрегацией — не сама функция, а неправильная гранулярность данных.
Гранулярность — это уровень детализации строки.
Например:
Проблемы начинаются, когда в одном запросе соединяются несколько таблиц «один ко многим».
Представим заказ
101. В нём 3 товара и 2 платежа.Если соединить
orders,order_itemsиpayments, товары размножатся на платежи:Теперь если сделать
STRING_AGG(product_name, ', '), получится:Товары повторились не потому, что
STRING_AGGошибся. Он честно склеил те строки, которые вы ему дали. Ошибка возникла раньше — на этапеJOIN.Как лечить дубли из-за JOIN
Есть два основных способа.
Первый способ — использовать
DISTINCT, если повторы действительно можно просто убрать:SELECT o.id AS order_id, STRING_AGG(DISTINCT oi.product_name, ', ' ORDER BY oi.product_name) AS products, SUM(p.amount) AS paid FROM orders o JOIN order_items oi ON oi.order_id = o.id JOIN payments p ON p.order_id = o.id GROUP BY o.id;Но это не всегда правильно. Если в заказе реально было два одинаковых товара,
DISTINCTсклеит их в один и может исказить смысл.Второй способ — сначала агрегировать товары отдельно, а потом соединять результат с остальными таблицами.
SELECT o.id AS order_id, pr.products, SUM(p.amount) AS paid FROM orders o JOIN payments p ON p.order_id = o.id JOIN ( SELECT order_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products FROM order_items GROUP BY order_id ) pr ON pr.order_id = o.id GROUP BY o.id, pr.products;Что здесь происходит:
prмы заранее собираем товары по каждому заказу.Так мы не даём товарам размножиться из-за платежей.
Это очень важный приём: если разные части запроса имеют разную детализацию, агрегируйте каждую часть на нужном уровне отдельно.
MySQL: GROUP_CONCAT
В MySQL аналог
STRING_AGGназываетсяGROUP_CONCAT.Базовый пример:
SELECT GROUP_CONCAT(email SEPARATOR ', ') AS all_emails FROM users;С
GROUP BY:SELECT o.id AS order_id, GROUP_CONCAT(oi.product_name SEPARATOR ', ') AS products FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id;Сортировка пишется внутри функции:
SELECT o.id AS order_id, GROUP_CONCAT(oi.product_name ORDER BY oi.product_name SEPARATOR ', ') AS products FROM orders o JOIN order_items oi ON oi.order_id = o.id GROUP BY o.id;DISTINCTтоже поддерживается:SELECT customer_country, GROUP_CONCAT(DISTINCT currency ORDER BY currency SEPARATOR ', ') AS currencies FROM orders GROUP BY customer_country;Если разделитель — обычная запятая,
SEPARATORможно не указывать. Но в реальных отчётах чаще используют', ', потому что так результат читается приятнее.Важная ловушка MySQL: group_concat_max_len
У MySQL есть особенность: результат
GROUP_CONCATограничен настройкойgroup_concat_max_len.По умолчанию лимит может быть небольшим. Если список длинный, MySQL может обрезать результат.
Это особенно неприятно, потому что вы можете получить не ошибку, а просто укороченную строку.
Например, ожидали список из 500 email-адресов, а получили только начало списка.
Для текущей сессии лимит можно увеличить так:
SET SESSION group_concat_max_len = 1000000;После этого длинные списки будут помещаться в результат лучше.
Важная мысль: если в MySQL вы используете
GROUP_CONCATдля отчётов с потенциально длинными строками, всегда помните проgroup_concat_max_len.ClickHouse: arrayStringConcat и groupArray
В ClickHouse прямого аналога
STRING_AGGобычно не используют. Вместо этого задачу раскладывают на два шага:groupArrayсобирает значения группы в массив.arrayStringConcatсклеивает элементы массива в строку.Пример:
SELECT order_id, arrayStringConcat(groupArray(product_name), ', ') AS products FROM order_items GROUP BY order_id;Результат:
Если нужен отсортированный список, используйте
arraySort:SELECT order_id, arrayStringConcat(arraySort(groupArray(product_name)), ', ') AS products FROM order_items GROUP BY order_id;Если нужны только уникальные значения, используйте
groupUniqArray:SELECT customer_country, arrayStringConcat(arraySort(groupUniqArray(currency)), ', ') AS currencies FROM orders GROUP BY customer_country;Если значения не строковые, их нужно привести к строке заранее:
SELECT order_id, arrayStringConcat(groupArray(toString(product_id)), ', ') AS product_ids FROM order_items GROUP BY order_id;Иначе можно получить ошибку типов, потому что
arrayStringConcatсклеивает именно строки.PostgreSQL, MySQL и ClickHouse: короткое сравнение
STRING_AGG(value, ', ' ORDER BY value)GROUP_CONCAT(value ORDER BY value SEPARATOR ', ')arrayStringConcat(arraySort(groupArray(value)), ', ')По смыслу это одна и та же задача: взять много значений внутри группы и превратить их в один читаемый список.
Но синтаксис отличается, поэтому при переходе между СУБД не копируйте запрос механически.
Практические правила
Если собираете строки в список, держите в голове несколько правил.
Первое: всегда думайте о порядке.
Плохо:
SELECT order_id, STRING_AGG(product_name, ', ') AS products FROM order_items GROUP BY order_id;Лучше:
SELECT order_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products FROM order_items GROUP BY order_id;Второе: помните про
NULL.Если пропуски можно игнорировать, обычный
STRING_AGGподходит. Если пропуски важны, используйтеCOALESCE.SELECT order_id, STRING_AGG(COALESCE(product_name, 'Unknown product'), ', ' ORDER BY product_name) AS products FROM order_items GROUP BY order_id;Третье: проверяйте дубли после
JOIN.Если список внезапно стал в два или три раза длиннее, почти всегда виновато размножение строк при соединении таблиц.
Четвёртое: не лечите всё подряд через
DISTINCT.DISTINCTполезен, когда вам действительно нужен список уникальных значений. Но если повтор означает реальное количество,DISTINCTможет спрятать важную информацию.Пятое: в MySQL помните про
group_concat_max_len.Для длинных отчётов лучше заранее увеличить лимит.
Главное
STRING_AGGв PostgreSQL собирает значения из нескольких строк в одну строку с разделителем.Базовый пример:
SELECT STRING_AGG(email, ', ') AS all_emails FROM users;Пример по группам:
SELECT order_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products FROM order_items GROUP BY order_id;Для MySQL используется
GROUP_CONCAT:SELECT order_id, GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', ') AS products FROM order_items GROUP BY order_id;Для ClickHouse — связка
groupArrayиarrayStringConcat:SELECT order_id, arrayStringConcat(arraySort(groupArray(product_name)), ', ') AS products FROM order_items GROUP BY order_id;Главные вещи, которые нужно запомнить:
ORDER BYпорядок внутри склеенной строки не гарантирован;NULLобычно пропускается;DISTINCTубирает повторы, но может скрыть важный смысл;JOINзначения часто дублируются из-за размножения строк;group_concat_max_len.Строковая агрегация — это не просто «склеить текст». Это способ превратить подробные табличные данные в понятный человеческий список: товары в заказе, теги у статьи, роли пользователя, участников проекта или любые другие значения, которые удобнее читать в одной ячейке.