Concatenate rows into a single string
Short answer: the function names differ across engines but the operation is the same: collapse a column from many rows into a single delimited string per group. STRING_AGG(col, ',') on Postgres / SQL Server / DuckDB / Snowflake / BigQuery; LISTAGG(col, ',') on Oracle / Snowflake / Redshift; GROUP_CONCAT(col SEPARATOR ',') on MySQL / SQLite. All accept an ORDER BY and pair naturally with GROUP BY.
Canonical SQL #
-- Postgres / SQL Server (2017+) / DuckDB / Snowflake / BigQuery.
SELECT user_id, STRING_AGG(product_name, ', ' ORDER BY product_name) AS products
FROM orders
GROUP BY user_id;
-- Oracle / Snowflake / Redshift.
SELECT user_id, LISTAGG(product_name, ', ') WITHIN GROUP (ORDER BY product_name) AS products
FROM orders
GROUP BY user_id;
-- MySQL / SQLite.
SELECT user_id, GROUP_CONCAT(product_name ORDER BY product_name SEPARATOR ', ') AS products
FROM orders
GROUP BY user_id;Example #
Common variants #
-- Top 3 products per user (use a window function + filter, then aggregate).
WITH user_product_counts AS (
SELECT o.user_id, p.name AS product_name, COUNT(*) AS order_count
FROM orders o
JOIN products p ON p.id = o.product_id
GROUP BY o.user_id, p.name
),
ranked AS (
SELECT user_id, product_name,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY order_count DESC) AS rn
FROM user_product_counts
)
SELECT user_id,
STRING_AGG(product_name, ', ' ORDER BY rn) AS top_products
FROM ranked
WHERE rn <= 3
GROUP BY user_id;
-- Distinct + sorted (de-dupe and order).
WITH user_tags(user_id, tag) AS (
VALUES (1, 'vip'), (1, 'beta'), (1, 'vip'), (2, 'beta')
)
SELECT
user_id,
STRING_AGG(DISTINCT tag, ',' ORDER BY tag) AS tags
FROM user_tags
GROUP BY user_id;
-- Build an array instead of a delimited string (often a better contract).
-- (JSON array: JSON_AGG on Postgres, json_group_array on DuckDB.)
SELECT o.user_id, ARRAY_AGG(p.name ORDER BY p.name) AS products_arr
FROM orders o
JOIN products p ON p.id = o.product_id
GROUP BY o.user_id; -- Postgres / DuckDBDialect comparison #
| Engine | Syntax | Gotcha |
|---|---|---|
| PostgreSQL / DuckDB | STRING_AGG(col, ', ' ORDER BY col) | accepts DISTINCT; ARRAY_AGG for arrays (JSON: JSON_AGG on Postgres, json_group_array on DuckDB) |
| SQL Server 2017+ | STRING_AGG(col, ', ') WITHIN GROUP (ORDER BY col) | pre-2017: gnarly FOR XML PATH / STUFF hack |
| MySQL / SQLite | GROUP_CONCAT(col ORDER BY col SEPARATOR ', ') | MySQL truncates at 1024 bytes by default — raise via group_concat_max_len |
| Oracle | LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col) | 4000-byte default limit; use ON OVERFLOW TRUNCATE to handle gracefully |
| Snowflake | LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col) or STRING_AGG(...) | no DISTINCT inside LISTAGG — use a sub-aggregate |
| BigQuery | STRING_AGG(col, ', ' ORDER BY col LIMIT n) | native LIMIT clause; no WITHIN GROUP |
| Redshift | LISTAGG(col, ', ') WITHIN GROUP (ORDER BY col) | result limited to 65,535 bytes per group |
- Postgres / DuckDB:
STRING_AGG(expr, separator [ORDER BY ...]). AlsoARRAY_AGGif you want an array instead of a delimited string. For a JSON array, Postgres hasJSON_AGG; DuckDB's equivalent isjson_group_array. All acceptDISTINCT. - SQL Server (2017+):
STRING_AGG(expr, separator) WITHIN GROUP (ORDER BY ...). Pre-2017: hack withFOR XML PATHorSTUFF(gnarly; avoid if you're on a supported version). - MySQL / SQLite:
GROUP_CONCAT(expr ORDER BY ... SEPARATOR ','). Default truncation: MySQL caps at 1024 bytes by default — setgroup_concat_max_lento raise. SQLite has no documented limit. - Oracle:
LISTAGG(expr, separator) WITHIN GROUP (ORDER BY ...). Has a 4000-byte limit by default;ON OVERFLOW TRUNCATEclause to handle gracefully. - Snowflake: both
LISTAGGandSTRING_AGGwork, thoughLISTAGGis more idiomatic. NoDISTINCTinsideLISTAGG; use a sub-aggregate. - BigQuery:
STRING_AGG(expr, separator ORDER BY ... LIMIT n)supports aLIMITclause natively. NoWITHIN GROUP. - Redshift:
LISTAGG(expr, separator) WITHIN GROUP (ORDER BY ...). Result is limited to 65,535 bytes per group.
Default truncation limits will eventually bite you. MySQL's 1024-byte default on GROUP_CONCAT and Oracle's 4000-byte default on LISTAGG mean a "list of products per customer" report works perfectly in dev and silently truncates in prod once a customer crosses the threshold. Symptom: dashboard values that look complete but end mid-word. Set the limits explicitly (SET SESSION group_concat_max_len = 1000000 on MySQL; ON OVERFLOW clause on Oracle) — or, better, use JSON_AGG / ARRAY_AGG and let the consumer format the display, since arrays don't have an artificial size cap.