Собрать значения группы в одну строку через разделитель можно одной агрегатной функцией: в PostgreSQL это STRING_AGG, в MySQL и SQLite — GROUP_CONCAT, в ClickHouse — arrayStringConcat(groupArray(...)). Все три схлопывают набор строк внутри группы в одно текстовое поле — например, список товаров в заказе или теги пользователя. Дальше разберём синтаксис в каждой СУБД, порядок сортировки внутри агрегата, DISTINCT, ограничение длины и обратную операцию — как разбить склеенную строку назад.
Как одним запросом склеить значения группы в строку?
Задача звучит так: есть таблица order_items, где на один заказ приходится много строк — по одной на товар. А хочется получить одну строку на заказ со списком товаров через запятую. Обычный GROUP BY схлопывает строки, но не умеет сам объединять текст — для этого и нужны строковые агрегаты.
В PostgreSQL:
SELECT
order_id,
STRING_AGG(product_name, ', ') AS products
FROM order_items
GROUP BY order_id;
Результат — одна строка на заказ: 1042 | Кофемолка, Фильтры, Чайник. Первый аргумент — что склеиваем, второй — разделитель. Разделитель обязателен, даже если это просто запятая или перенос строки E'\n'.
Это ровно тот случай, который постоянно встречается в реальных отчётах: показать состав заказа в выгрузке для менеджера, собрать список email в рассылку, слепить категории, к которым относится товар. Ручного цикла в приложении не нужно — база отдаёт готовую строку.
Если хочется потренироваться на живой базе прямо в браузере, задачи на группировку удобно гонять в SQL-тренажёре — там настоящий PostgreSQL 16, и STRING_AGG работает как на проде.
Чем STRING_AGG в PostgreSQL отличается от GROUP_CONCAT в MySQL и SQLite?
Функционально они делают одно и то же, но синтаксис и дефолты разные — и на собеседовании любят спросить именно про различия.
PostgreSQL — STRING_AGG(expr, delimiter), разделитель задаётся явно:
SELECT user_id, STRING_AGG(tag, ',') AS tags
FROM user_tags
GROUP BY user_id;
MySQL и SQLite — GROUP_CONCAT(expr), и у него разделитель по умолчанию уже стоит — запятая:
-- SQLite: разделитель по умолчанию ','
SELECT user_id, GROUP_CONCAT(tag) AS tags
FROM user_tags
GROUP BY user_id;
-- MySQL: свой разделитель через SEPARATOR
SELECT user_id, GROUP_CONCAT(tag SEPARATOR ' | ') AS tags
FROM user_tags
GROUP BY user_id;
Здесь три ловушки. Первая: в SQLite нельзя менять разделитель ключевым словом SEPARATOR — там второй аргумент функции, GROUP_CONCAT(tag, ' | '). В MySQL наоборот — только через SEPARATOR. Вторая: в MySQL GROUP_CONCAT молча обрезает результат по системной переменной group_concat_max_len (об этом отдельный раздел ниже). Третья: NULL-значения все три функции просто пропускают, а не превращают в пустую строку — если в группе одни NULL, результат будет NULL, а не пустая строка.
Чтобы не держать эти отличия в голове, я обычно держу под рукой SQL-справочник — быстрее подсмотреть сигнатуру, чем угадывать.
Как склеивать строки в ClickHouse?
В ClickHouse нет прямого аналога STRING_AGG, зато есть два шага, которые вместе дают то же самое: сначала собрать значения в массив через groupArray, потом склеить массив в строку через arrayStringConcat.
SELECT
order_id,
arrayStringConcat(groupArray(product_name), ', ') AS products
FROM order_items
GROUP BY order_id;
groupArray(product_name) внутри группы отдаёт массив ['Кофемолка', 'Фильтры', 'Чайник'], а arrayStringConcat(arr, ', ') превращает его в строку. Такой двухступенчатый подход даже удобнее: массив можно по дороге отфильтровать, отсортировать через arraySort, взять уникальные через arrayDistinct или обрезать arraySlice(arr, 1, 5) — и только потом склеить. По сути ClickHouse просто разбивает операцию на «собрать» и «склеить», давая полный контроль над промежуточным массивом.
Для аналитика, который живёт в ClickHouse (а это добрая половина продуктовой аналитики в РФ), это основной способ собрать, скажем, список событий сессии или перечень площадок, где крутилась реклама.
Как задать порядок значений внутри STRING_AGG?
По умолчанию порядок элементов в склеенной строке не гарантирован — база отдаёт их в том порядке, в каком ей удобно читать. Если нужен предсказуемый порядок (по алфавиту, по дате, по цене), сортировку задают прямо внутри агрегата через ORDER BY.
PostgreSQL:
SELECT
order_id,
STRING_AGG(product_name, ', ' ORDER BY product_name) AS products
FROM order_items
GROUP BY order_id;
ORDER BY пишется внутри скобок агрегатной функции, после разделителя. Можно сортировать и по другому полю — например, собрать товары в порядке добавления в корзину:
STRING_AGG(product_name, ', ' ORDER BY added_at)
В MySQL и SQLite сортировка внутри GROUP_CONCAT тоже поддерживается:
-- MySQL
GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', ')
В ClickHouse порядок задаётся на уровне массива: либо groupArraySorted, либо arraySort(groupArray(product_name)) перед склейкой. Это важный момент для воспроизводимости отчётов — без явной сортировки два запуска одного запроса могут вернуть строки с элементами в разном порядке, и diff выгрузок покажет ложные изменения.
Как убрать повторы через DISTINCT?
Часто в группе одно и то же значение встречается несколько раз — например, пользователь купил один товар в трёх разных заказах, а мы собираем все его категории. Дубликаты в списке не нужны. Убираются они через DISTINCT внутри агрегата.
PostgreSQL:
SELECT
user_id,
STRING_AGG(DISTINCT category, ', ' ORDER BY category) AS categories
FROM purchases
GROUP BY user_id;
Тут есть техническое ограничение, о котором спотыкаются: в PostgreSQL, если используешь и DISTINCT, и ORDER BY внутри одного STRING_AGG, сортировать можно только по тому же выражению, что стоит в DISTINCT. То есть STRING_AGG(DISTINCT category, ',' ORDER BY category) — ок, а ORDER BY added_at при DISTINCT category выдаст ошибку.
В MySQL/SQLite всё проще — GROUP_CONCAT(DISTINCT tag). В ClickHouse уникальность даёт arrayDistinct(groupArray(tag)) или сразу groupUniqArray(tag) — вторая функция и собирает, и дедуплицирует за один проход.
Такие запросы отлично ложатся на разбор пользовательских сегментов — например, когда считаешь метрики вовлечённости вроде DAU и заодно хочешь приложить к пользователю список фич, которыми он пользовался.
Собираем товары заказа и теги пользователя — как на практике?
Соберём два самых частых сценария целиком, чтобы было видно как это выглядит в боевом запросе с джойнами.
Список товаров по каждому заказу с ценой и сортировкой по убыванию суммы:
SELECT
o.order_id,
o.created_at,
STRING_AGG(
oi.product_name || ' x' || oi.qty,
'; ' ORDER BY oi.qty * oi.price DESC
) AS basket
FROM orders o
JOIN order_items oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.created_at;
На выходе получаем читаемую корзину: Ноутбук x1; Мышь x2; Коврик x1. Конкатенация || внутри агрегата позволяет собрать не голое название, а форматированную строку.
Теги пользователя, собранные из разных источников без дубликатов:
SELECT
u.user_id,
u.email,
STRING_AGG(DISTINCT t.tag, ', ' ORDER BY t.tag) AS all_tags
FROM users u
LEFT JOIN user_tags t ON t.user_id = u.user_id
GROUP BY u.user_id, u.email;
LEFT JOIN здесь важен: пользователи без единого тега всё равно попадут в результат, а all_tags у них будет NULL (агрегат пропускает NULL-строки). Если хочется вместо NULL видеть пустую строку или прочерк, оберни в COALESCE(STRING_AGG(...), '—').
Такие «собери список на группу» — классика тестовых заданий. Похожие формулировки постоянно всплывают в реальных кейсах аналитика и в разборах на вопросах для собеседования, так что руку на них стоит набить заранее.
Что делать, когда результат обрезается по длине?
Самая коварная ошибка со строковыми агрегатами — тихое обрезание. В MySQL GROUP_CONCAT по умолчанию ограничен переменной group_concat_max_len, которая часто стоит в 1024 байта. Если склеенная строка длиннее — MySQL молча её обрежет, без ошибки и без предупреждения. В отчёт попадут не все товары, а ты об этом не узнаешь, пока кто-то не пожалуется.
Лечится это поднятием лимита на уровне сессии:
SET SESSION group_concat_max_len = 1000000;
SELECT order_id, GROUP_CONCAT(product_name)
FROM order_items
GROUP BY order_id;
В PostgreSQL жёсткого лимита на STRING_AGG нет — строка может быть хоть до предела размера поля text (около 1 ГБ), так что обрезания не будет. В SQLite тоже фиксированного лимита нет, но есть общий предел длины строки. В ClickHouse ограничений на длину результата arrayStringConcat на практике не встретишь.
Отдельно держи в голове: очень длинные склеенные строки — это сигнал, что, возможно, тебе вообще не нужна склейка, а нужна нормальная таблица «строка на элемент». Строковый агрегат хорош для человекочитаемых выгрузок и превью, но плохо подходит как формат хранения — по нему нельзя нормально фильтровать и джойнить.
Как выполнить обратную операцию и разбить строку обратно?
Рано или поздно приходит обратная задача: есть поле tags = 'sql,python,ab-test', а нужно превратить его в набор строк — по одной на тег. Это разбиение (split), и в каждой СУБД свой инструмент.
PostgreSQL — string_to_array плюс unnest, либо сразу regexp_split_to_table:
-- строку в набор строк (по одной на тег)
SELECT
user_id,
unnest(string_to_array(tags, ',')) AS tag
FROM user_profiles;
-- или регуляркой, если разделители разношёрстные
SELECT user_id, regexp_split_to_table(tags, '\s*,\s*') AS tag
FROM user_profiles;
ClickHouse — splitByChar(',', tags) возвращает массив, дальше arrayJoin разворачивает его в строки:
SELECT user_id, arrayJoin(splitByChar(',', tags)) AS tag
FROM user_profiles;
MySQL до версии 8 честного split нет — приходится извращаться через SUBSTRING_INDEX или рекурсивные CTE; в MySQL 8+ проще прогнать через JSON_TABLE. SQLite — рекурсивный CTE или расширение. Именно из-за этой асимметрии (склеить легко везде, разбить — по-разному) хранить списки строкой в одной ячейке считается плохой практикой: положить 'a,b,c' — минута, а вытащить обратно — целый запрос.
Если разбор строки нужен не в базе, а в датафрейме, в Python это одна строчка df['tags'].str.split(','), и на большом объёме это бывает удобнее — потренироваться можно в Python-тренажёре, а синтаксис подсмотреть в Python-справочнике.
Какие грабли ждут на собеседовании?
Соберу то, на чём чаще всего заваливаются, в один чек-лист. Первое — путают синтаксис между СУБД: пишут STRING_AGG в MySQL или SEPARATOR в SQLite. Уточни, о какой базе речь, до того как писать. Второе — забывают про ORDER BY внутри агрегата и получают непредсказуемый порядок, а потом удивляются, почему выгрузка «прыгает». Третье — не знают про молчаливое обрезание в MySQL и сдают отчёт с потерянными данными.
Четвёртое, любимый вопрос senior-интервьюеров: «а что вернёт агрегат, если все значения в группе NULL?» Правильный ответ — NULL, а не пустая строка, и надо обернуть в COALESCE. Пятое — на вопрос «когда НЕ стоит использовать строковую склейку» хороший ответ звучит так: когда результат нужен для дальнейшей обработки, а не для показа человеку — тогда лучше оставить нормализованные строки или собрать массив/JSON, по которым можно фильтровать.
Если хочешь довести эти конструкции до автоматизма, разбери набор задач на группировку и агрегацию в практических заданиях и пройди базовый блок SQL с нуля в курсе по SQL — там группировка идёт ровно перед оконными функциями, что логично: сначала учишься схлопывать группы, потом — считать по ним без схлопывания.
Строковые агрегаты — из тех вещей, которые кажутся мелочью, пока не спотыкаешься на них в проде или на собесе. STRING_AGG, GROUP_CONCAT и arrayStringConcat делают одно и то же, но дьявол в деталях: разделитель, порядок, DISTINCT, лимит длины и то, как разбирать строку обратно. Держи эти пять пунктов в голове — и списки в группе перестанут быть проблемой.
Хочешь тренироваться на настоящих задачах из собеседований Яндекса, Ozon и Сбера, с проверкой на реальном PostgreSQL и разбором решений? Полный доступ ко всем 425 SQL-задачам, кейсам и AI-мок-собесам открывает Pro — можно начать бесплатно и посмотреть, как это устроено.