sqlpostgresqlmysqlclickhouse

STRING_AGG в SQL: как собрать несколько строк в одну

Как собрать значения из множества строк в одну строку с разделителем и нужным порядком — на PostgreSQL STRING_AGG, MySQL GROUP_CONCAT и ClickHouse arrayStringConcat(groupArray()).

9 мин чтенияСправочникsql · postgresql · mysql · clickhouse · aggregation

Иногда в 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;

Результат может быть таким:

all_emails
anna@example.com, boris@example.com, maria@example.com

Разделитель ', ' означает: между значениями поставь запятую и пробел.

Если поставить другой разделитель, результат тоже изменится:

SELECT STRING_AGG(email, ' | ') AS all_emails
FROM users;

Результат:

all_emails
anna@example.com | boris@example.com | maria@example.com

Главная идея простая: STRING_AGG превращает столбец из нескольких строк в один текстовый список.

STRING_AGG и нестроковые значения

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

Например, соберём id пользователей:

SELECT STRING_AGG(id::text, ', ') AS user_ids
FROM users;

Результат:

user_ids
1, 2, 3, 4

То же самое можно написать через 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;

Что здесь происходит:

  1. В подзапросе pr мы заранее собираем товары по каждому заказу.
  2. Получаем одну строку на заказ.
  3. Потом соединяем эту уже готовую строку с заказами и платежами.

Так мы не даём товарам размножиться из-за платежей.

Это очень важный приём: если разные части запроса имеют разную детализацию, агрегируйте каждую часть на нужном уровне отдельно.

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 обычно не используют. Вместо этого задачу раскладывают на два шага:

  1. groupArray собирает значения группы в массив.
  2. 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-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр