Ordered Comma Lists With STRING_AGG and WITHIN GROUP

The same customer list arrives in a different order on the next refresh. WITHIN GROUP gives STRING_AGG an explicit ordering rule. Without that rule, the display order is an accident of the execution plan.

A string of dried chili peppers hung in order from smallest to largest, hands adding one fresh red pepper in place.

Give the Aggregate an Order With WITHIN GROUP

STRING_AGG combines values with a separator. SQL Server introduced it in 2017. Its ordering clause requires database compatibility level 110 or higher. The ORDER BY inside WITHIN GROUP controls the aggregate's input order.

An ORDER BY after GROUP BY controls the result rows instead. One doesn't substitute for the other. Keep that distinction visible when reviewing a list-building query.

I ask whether the list is a display or a data exchange format. A comma-separated display is useful. A machine-readable payload needs escaping rules when values contain commas.

Don't assume a separator makes arbitrary strings unambiguous. If another application must recover individual values, use a structured format or pass rows. The list should serve the consumer's actual contract.

Build a List for Each Customer

The sample table deliberately includes duplicates and a missing product name. It is a session-local temporary table, so use one query window. The first aggregate orders product names within each customer.

It also orders the customer result rows separately. Those two clauses work at different levels. Inspect the results before changing the data or adding other joins.

Cast the input expression to nvarchar(max), not the result after aggregation. The input type determines the aggregate's output type and limit. The Unicode separator matches the Unicode expression.

An outer cast cannot rescue an aggregate that already exceeded its fixed-length limit. I make that choice explicit before the list grows into a production error at an inconvenient time.

CREATE TABLE #CustomerProducts
(
    CustomerId int NOT NULL,
    ProductName nvarchar(100) NULL
);
INSERT #CustomerProducts VALUES
(1,N'Wheel'),(1,N'Frame'),(1,N'Wheel'),(1,NULL),(2,N'Brake');
SELECT CustomerId,
       STRING_AGG(CONVERT(nvarchar(max),ProductName),N', ')
       WITHIN GROUP (ORDER BY ProductName) AS ProductList
FROM #CustomerProducts
GROUP BY CustomerId
ORDER BY CustomerId;

Remove Duplicates Before Aggregating

STRING_AGG doesn't accept a DISTINCT keyword inside its expression in SQL Server. Deduplicate in a subquery or CTE first. The grouping identity belongs in that deduplication.

Removing duplicates across the whole input without CustomerId merges unrelated customers. Keep the business key beside the displayed value when preparing the aggregate input.

Also check why duplicates exist. Multiple purchases of one product can be legitimate. A faulty join multiplying rows is a different problem. DISTINCT is appropriate for a unique product catalog per customer.

It doesn't repair an incorrect join when quantities or amounts matter. Review the source grain before choosing a deduplication step. The query should express the list's intended meaning.

WITH UniqueProducts AS
(
    SELECT DISTINCT CustomerId, ProductName
    FROM #CustomerProducts
)
SELECT CustomerId,
       STRING_AGG(CONVERT(nvarchar(max),ProductName),N', ')
       WITHIN GROUP (ORDER BY ProductName) AS ProductList
FROM UniqueProducts
GROUP BY CustomerId;
From raw rows to one ordered list: a diagram about the WITHIN GROUP

Place Missing Values Inside WITHIN GROUP

The aggregate skips NULL inputs and doesn't add a separator for them. An empty string is different. It remains an input and can produce an empty-looking item.

Normalize blank names deliberately if that is the business rule. Don't treat every missing representation as identical without inspecting the source. That distinction explains several mysterious extra separators in real reports.

Use ISNULL before aggregation to display a placeholder for a NULL value. Cast first so the placeholder isn't truncated to the source expression's narrow width. Decide where that placeholder sorts.

The ORDER BY expression can include a null-ranking rule and the product name. A user-visible list needs a predictable position for missing data, not only a predictable separator.

SELECT CustomerId,
       STRING_AGG(ISNULL(CONVERT(nvarchar(max),ProductName),N'(unnamed)'),N', ')
       WITHIN GROUP
       (ORDER BY CASE WHEN ProductName IS NULL THEN 1 ELSE 0 END, ProductName)
       AS ProductList
FROM #CustomerProducts
GROUP BY CustomerId;

Understand the Fixed-Length Limits

A varchar input without max produces a varchar result limited to 8,000 bytes. An nvarchar input without max produces an nvarchar result limited to 4,000 characters. That Unicode limit occupies 8,000 bytes.

Other nonstring inputs convert to a fixed nvarchar result. These are type rules, not observed list sizes from your server. Choose the expression type before aggregation.

The max type supports longer values, but it doesn't make unlimited output a good interface. A huge list uses memory and network bandwidth. Consider whether the reader can use it.

Return detail rows for drilling into a long catalog. A comma list containing the entire warehouse is technically a list, although it has stopped being a helpful report.

Compare the Older XML Pattern

FOR XML PATH supplied list concatenation before STRING_AGG. The TYPE directive and value method decode XML escaping in the final text. Omitting that decoding leaves entities in names containing special characters.

STUFF removes the leading separator. The ordering belongs inside the correlated subquery. This is another case where an outer ORDER BY cannot govern the assembled string.

Keep the older pattern when supporting an engine without STRING_AGG. On supported versions, the newer aggregate states the intention more clearly. Compare results using names containing ampersands, commas, blanks, and NULL.

The migration should preserve the agreed behavior. Shorter SQL is useful only when the output contract survives the change, including its less attractive edge cases.

SELECT c.CustomerId,
       STUFF((SELECT N', ' + p.ProductName
              FROM #CustomerProducts AS p
              WHERE p.CustomerId = c.CustomerId
              ORDER BY p.ProductName
              FOR XML PATH(''), TYPE).value('.','nvarchar(max)'),1,2,N'') AS ProductList
FROM (SELECT DISTINCT CustomerId FROM #CustomerProducts) AS c;

Keep the WITHIN GROUP Rule Stable

Collation affects alphabetical sorting. Case and accents follow that collation's rules. Add a stable tie-breaker when the consumer needs a specific order among equivalent displayed names.

Sorting by the same label alone doesn't establish a distinction the collation considers equal. Document whether the business wants alphabetical order, purchase order, or another sequence. Each needs its own expression.

What should this customer see first in the list? Start with that answer and use WITHIN GROUP to encode it. Verify one result per customer after every join.

Test long names and repeated products before deployment. The finished query should explain both membership and order. That makes a refresh repeat the business rule, instead of exposing whatever row order the plan delivered.

Check a customer with no eligible products too. A grouped query returns no row for that customer unless you start from the customer table. Decide whether the report needs NULL, an empty string, or a separate message. That choice belongs outside the concatenation step and should survive changes to the product filter.

Related reading on this blog: STRING_AGG Function to Concatenate Strings and NULL Values and CONCAT Function.

What STRING_AGG does for you: a checklist on the WITHIN GROUP

An ordered list is not a lucky scan sequence, it is an explicit aggregate rule.

Published by Pinal Dave on SQLAuthority. More of my work at pinaldave.com.

SQL Function, SQL NULL, SQL Server, SQL String
Previous Post
SQL SERVER – Cryptography in SQL Server 2008
Next Post
A Minimal BI Stack on SQL Server

Related Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.