Recipe

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 #

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 #

Loading SQL editor...

Common variants #

sql
-- 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 / DuckDB

Dialect comparison #

EngineSyntaxGotcha
PostgreSQL / DuckDBSTRING_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 / SQLiteGROUP_CONCAT(col ORDER BY col SEPARATOR ', ')MySQL truncates at 1024 bytes by default — raise via group_concat_max_len
OracleLISTAGG(col, ', ') WITHIN GROUP (ORDER BY col)4000-byte default limit; use ON OVERFLOW TRUNCATE to handle gracefully
SnowflakeLISTAGG(col, ', ') WITHIN GROUP (ORDER BY col) or STRING_AGG(...)no DISTINCT inside LISTAGG — use a sub-aggregate
BigQuerySTRING_AGG(col, ', ' ORDER BY col LIMIT n)native LIMIT clause; no WITHIN GROUP
RedshiftLISTAGG(col, ', ') WITHIN GROUP (ORDER BY col)result limited to 65,535 bytes per group
  • Postgres / DuckDB: STRING_AGG(expr, separator [ORDER BY ...]). Also ARRAY_AGG if you want an array instead of a delimited string. For a JSON array, Postgres has JSON_AGG; DuckDB's equivalent is json_group_array. All accept DISTINCT.
  • SQL Server (2017+): STRING_AGG(expr, separator) WITHIN GROUP (ORDER BY ...). Pre-2017: hack with FOR XML PATH or STUFF (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 — set group_concat_max_len to raise. SQLite has no documented limit.
  • Oracle: LISTAGG(expr, separator) WITHIN GROUP (ORDER BY ...). Has a 4000-byte limit by default; ON OVERFLOW TRUNCATE clause to handle gracefully.
  • Snowflake: both LISTAGG and STRING_AGG work, though LISTAGG is more idiomatic. No DISTINCT inside LISTAGG; use a sub-aggregate.
  • BigQuery: STRING_AGG(expr, separator ORDER BY ... LIMIT n) supports a LIMIT clause natively. No WITHIN GROUP.
  • Redshift: LISTAGG(expr, separator) WITHIN GROUP (ORDER BY ...). Result is limited to 65,535 bytes per group.
Warning

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.