Use the aggregate string_agg(name, ', ') with a GROUP BY: SELECT dept, string_agg(name, ', ') FROM staff GROUP BY dept gives one row per department with its names joined, like Maya, Liam, Ella. Add ORDER BY name inside the call to sort the list. MySQL spells it GROUP_CONCAT(name SEPARATOR ', '), and SQL Server 2017+ uses STRING_AGG(name, ', ').
Joining the values of many rows into one string is an aggregate, just like count or sum: it collapses a group of rows into one value, except the value is text. Every major database has one, under two main names. string_agg is the one PostgreSQL, SQL Server and SQLite (3.44+) share; group_concat is MySQL's and SQLite's older name. The examples below run on SQLite, so both work here, and the table in section 6 gives the exact PostgreSQL, MySQL and SQL Server forms. Each one runs on this page: hit Run, then edit the query and run it again.
1string_agg with GROUP BY: one string per groupRecommended
string_agg(expression, separator) takes the values of every row in a group and joins them with the separator, which can be any text: ', ', ' | ' or a newline. GROUP BY dept makes one group per department, so you get one row each, and you can put other aggregates such as count(*) next to it. group_concat does the same job; with no second argument it separates with a bare comma.
Output
Sales prints Maya, Liam, Ella with string_agg and Maya,Liam,Ella with group_concat, whose default separator has no space. The names came out in the order they were inserted, but nothing promises that: without an explicit order, the database joins the rows in whatever order it reads them, which can change with an index or a join (section 5 shows it happening). string_agg needs SQLite 3.44 or later and always takes the separator; group_concat works in every SQLite version. Grouping on more than one column works as usual; see GROUP BY multiple columns.
2Sort the values inside the list
To control the order of the joined values, put an ORDER BY inside the aggregate's parentheses, after the separator. It can sort by a column you don't output, such as a hire date. The ORDER BY at the end of the query is a different thing: it sorts the result rows, that is the groups, and doesn't touch the order inside each string.
Output
The first query sorts each list by name (Ella, Liam, Maya). The second sorts by hired DESC, so Sales becomes Liam, Ella, Maya: Liam was hired in 2025, Maya in 2021. The third uses group_concat with a ' / ' separator and sorts the groups by size, so Sales comes first. The ORDER BY inside an aggregate needs SQLite 3.44 or later, and the syntax is the same in PostgreSQL. MySQL takes the same clause inside GROUP_CONCAT, while SQL Server moves it outside the call as WITHIN GROUP (ORDER BY ...).
3Only distinct values
A customer who ordered tea twice shows tea twice in the list, because the aggregate sees every row. DISTINCT inside the call removes the repeats, but SQLite allows it only when the aggregate has a single argument, so it works with group_concat(DISTINCT item) and its plain comma, not with a custom separator. For any separator, take the distinct rows in a subquery first and aggregate those.
Output
Without DISTINCT, Ana's list is tea, cake, tea. group_concat(DISTINCT item ORDER BY item) gives cake,tea, and the subquery version gives cake, tea with the separator you chose. The last statement fails on purpose with DISTINCT aggregates must have exactly one argument. PostgreSQL and MySQL do accept DISTINCT with a separator; the subquery form works everywhere. To find or remove the duplicate rows themselves, see find and delete duplicate rows.
4NULLs are skipped
Like other aggregates, string_agg ignores NULL values: they add neither a value nor a separator. When every value in a group is NULL, or the group has no rows to join, the result is NULL rather than an empty string. Wrap the aggregate in coalesce to show something else, or wrap the column inside it to keep a placeholder for each missing value.
Output
Site has three tasks but lists Maya, Omar, with no empty slot for the NULL row, and App, whose two owners are both NULL, gets NULL. coalesce(string_agg(...), '-') turns that into -, while string_agg(coalesce(owner, '?'), ', ') keeps a marker for each gap: Maya, ?, Omar. The last query is the trap to watch for when you build each item from several columns: project || '=' || owner is NULL whenever owner is, so the three unowned tasks drop out of Site=Maya, Site=Omar, Docs=Ava without any warning.
5One string for the whole table, or per row of another table
Leave out GROUP BY and the whole table is one group, so you get exactly one row with one string. That is handy for building a quoted list to paste into an IN (...) filter. To list the related rows of a second table next to each row of the first, such as the items each customer ordered, join the tables and group by the first one, or run the aggregate in a subquery inside the SELECT list.
Output
The whole table becomes Ana, Ben, Cy, and the quoted version 'Cy', 'Ana', 'Ben' (four quotes in a row, '''', is a string holding one quote; see escape a single quote). Both per-customer queries give Cy NULL, since Cy has no orders, and the LEFT JOIN is what keeps that row in the result at all. Look at Ana, though: the join prints cake, tea and the subquery tea, cake, from the same rows. Neither query sorts the items, so each one joins them in the order its plan happens to read them. Add ORDER BY item inside string_agg whenever the order matters.
6Other databases: PostgreSQL, MySQL, SQL Server
The idea is the same everywhere; the name, the separator syntax and where the ORDER BY goes are not. Each form below was run on PostgreSQL 18, MySQL 8.4 and SQL Server's T-SQL (through Babelfish) against a small staff table. Oracle uses LISTAGG(name, ', ') WITHIN GROUP (ORDER BY name).
The MySQL limit is the one that bites in production: once a list passes group_concat_max_len (check it with SELECT @@group_concat_max_len, which printed 1024), MySQL truncates the string and only raises the warning Row 4 was cut by GROUP_CONCAT(). After SET SESSION group_concat_max_len = 1000000 the same six 300-character values joined to their full 1805 characters. The full SQL Server fallback is a correlated subquery: STUFF((SELECT ', ' + s2.name FROM staff s2 WHERE s2.dept = s.dept ORDER BY s2.name FOR XML PATH('')), 1, 2, ''); on a value like R&D it returned R&D, so use STRING_AGG on any version that has it.
| Syntax | Database | Notes |
|---|---|---|
| string_agg(name, ', ' ORDER BY name) | PostgreSQL, SQLite 3.44+ | Same call in both. PostgreSQL wants text: cast numbers with id::text |
| string_agg(DISTINCT name, ', ' ORDER BY name) | PostgreSQL | DISTINCT with any separator; the ORDER BY must sort by the aggregated value |
| GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') | MySQL | Default separator is ','; DISTINCT goes first: GROUP_CONCAT(DISTINCT name ...) |
| SET SESSION group_concat_max_len = 1000000 | MySQL | Results are cut at 1024 by default, with only a warning (code 1260) |
| STRING_AGG(name, ', ') WITHIN GROUP (ORDER BY name) | SQL Server 2017+ | The ORDER BY goes in WITHIN GROUP, after the call |
| STUFF((SELECT ', ' + name ... FOR XML PATH('')), 1, 2, '') | SQL Server 2016 and older | Builds ", Ava, Omar" and cuts the first 2 characters; escapes & as & |
Frequently asked questions
How do I concatenate text from multiple rows into one string in SQL?
Use the string aggregate with a GROUP BY: SELECT dept, string_agg(name, ', ') FROM staff GROUP BY dept in PostgreSQL, SQL Server 2017+ and SQLite 3.44+, or GROUP_CONCAT(name SEPARATOR ', ') in MySQL. Leave out GROUP BY to get one string for the whole table.
How do I sort the values inside string_agg or GROUP_CONCAT?
Put the ORDER BY inside the call: string_agg(name, ', ' ORDER BY name) in PostgreSQL and SQLite 3.44+, GROUP_CONCAT(name ORDER BY name SEPARATOR ', ') in MySQL. SQL Server writes it after the call: STRING_AGG(name, ', ') WITHIN GROUP (ORDER BY name). The ORDER BY at the end of the query only sorts the groups.
Why is my GROUP_CONCAT result cut off in MySQL?
MySQL truncates GROUP_CONCAT at group_concat_max_len, which is 1024 by default (SELECT @@group_concat_max_len shows it), and only raises a warning such as Row 4 was cut by GROUP_CONCAT(). Raise it for your session with SET SESSION group_concat_max_len = 1000000; before the query.
How do I get only distinct values in the combined string?
PostgreSQL and MySQL accept DISTINCT inside the call: string_agg(DISTINCT name, ', ' ORDER BY name) and GROUP_CONCAT(DISTINCT name SEPARATOR ', '). SQLite allows DISTINCT only without a separator, as group_concat(DISTINCT name). The form that works everywhere selects the distinct rows in a subquery first and aggregates those.
What happens to NULL values in string_agg?
They are skipped: no value and no extra separator. If every value in a group is NULL, the result is NULL, not an empty string; wrap it as coalesce(string_agg(owner, ', '), '-'). To keep a placeholder for each NULL row, write string_agg(coalesce(owner, '?'), ', ').
How did you concatenate rows in SQL Server before STRING_AGG?
With FOR XML PATH and STUFF: STUFF((SELECT ', ' + name FROM staff s2 WHERE s2.dept = s.dept FOR XML PATH('')), 1, 2, ''). The subquery builds a string that starts with ', ' and STUFF removes those first two characters. It XML-escapes special characters (R&D came back as R&D), so prefer STRING_AGG on SQL Server 2017 and later.
Run it yourself
Open any of these in the SQLite playground to change the data, run and share.
SQLite playground