Arrays and Lists in SQL Server

An SQL text by Erland Sommarskog, SQL Server MVP. Latest Revision 2026-07-05.
Copyright applies to this text. See here for font conventions used in this article.

Introduction

This is a short article directed to readers with a limited experience of SQL Server programming that discusses how to handle a list of values delimited by commas or some other separator. Many years ago, this was a bigger challenge, but these days there are built-in functions in SQL Server that you can use in the vast majority of situations, particularly if you are on SQL 2025 or Azure SQL Database or Managed Instance. In this article, I will cover the built-in functions as well as some alternatives for situations where you are not able to use them, because you are on older versions of SQL Server.

Table of Contents

Introduction

The IN Misconception

How to Handle the List

Table-Valued Parameters

The Built-in Solutions

Two Simple Multi-Statement Functions

What If You Cannot Use a Function?

An Anti-Pattern

Delimited Lists in a Table Column

Performance Tip

Revision History

The IN Misconception

Before we come to the solution, I like to first cover a common misunderstanding. Over the years, I have frequently seen people in SQL forums asking: why does this not work?

DECLARE @list varchar(23) = '1,2,3,4'
SELECT ... FROM tbl WHERE col IN (@list)

The answer is that it does work: just look at this:

CREATE TABLE #test (id   int         NOT NULL,
                    col varchar(23)  NOT NULL)
INSERT #test(id, col)
   VALUES(1, 'Something'), (2, '1,2,3,4'), (3, 'Anything')
DECLARE @list varchar(23) = '1,2,3,4'
SELECT id FROM #test WHERE col IN (@list)

The SELECT returns the row with id = 2, not the others.

People who ask why IN does not work have a misconception about IN. IN is not a function. IN is an operator and the expression

col IN (val1, val2, val3, ...) 

is simply a shortcut for:

col = val1 OR col = val2 OR col = val3 OR ...

val1 etc here can be table columns, variables or constants. The parser rewrites the IN expression to a list of OR as soon as it sees it. (This explains why you get multiple error messages when you happen to misspell the name of the column left of IN.) There is no magical expansion of the value in the variable. The value '1,2,3,4' means exactly that string, not the numbers 1, 2, 3 and 4.

How to Handle the List

Now you know why IN (@list) does not work as you hoped for, but if you have a comma-separated list you still need to know how to work with it. That is what you will learn in this chapter.

Table-Valued Parameters

Before I go on and present the built-in solutions, let me ask you this: Must it be a delimited list? After all, you are in a relational database, so why not use a table? More precisely pass the list of values as a table-valued parameter (TVP). If you have never used TVPs before, I have an article, Using Table-Valued Parameters in SQL Server and .NET, where I give a tutorial of passing TVPs from .NET to SQL Server., The article includes a detailed description of passing a comma-separated list to a TVP. You will find that it is astonishingly simple.

Unfortunately, not all environments support TVPs, so using a TVP is not always an option. In that case you need to split the list into table format, and that is what we will look at in the rest of this chapter.

The Built-in Solutions

In SQL 2025 and later, as well as in Azure SQL Database and Azure SQL Managed Instance, there are no less than two built-in functions for the task, string_split and regexp_split_to_table. The latter is more versatile, but the former is easier to type. :-)

Let's first look at string_split. This function was introduced in SQL 2016, and your database must be on compatibility level 130 or later for the function to be available. For the example above, string_split permits for a very simple solution:

SELECT ...
FROM   tbl 
WHERE  col IN (SELECT convert(int, value) FROM string_split('1,2,3,4', ','))

In this example, string_split accepts two parameters. The first is a delimited list, and the second is the delimiter. The delimited list can be any string data type, including (n)varchar(MAX). The delimiter, on the other hand, can only be char(1) or nchar(1). That is, multi-character delimiters are not supported.

Used as in the example above, it returns a result set with a single column value. Take this query:

SELECT * FROM string_split ('a|b|v', '|')

You get back this result set:

value

-----

a

b

v

The type of the value column is the same as the type of the input string. So if you pass a varchar(200) string, you get varchar(200) back. And if you pass nvarchar(MAX), you get nvarchar(MAX) back. The latter is slightly impractical, since you rarely have that long list elements, and at the same time the MAX types incur some performance overhead.

There are situations where you want to know the order of the elements in the list. For instance, you may have two lists that you want to keep in sync. (We will see an example of this at the end of the article.) To achieve this, you can add a third parameter which you specify as 1. This will add a second column, ordinal, to the result set. Here is an example:

For instance:

SELECT * FROM string_split('a|b|v', '|', 1)

This is the output:

value ordinal

----- ----------

a     1

b     2

v     3

This parameter was introduced in SQL 2022, so it is not available in SQL 2019 and earlier. However, it is not dependent on the compatibility level, so if you are on SQL 2022 with compatibility level 130, you can still use this parameter.

The other function, regexp_split_to_table, was introduced in SQL 2025 and the compatibility level of the database must be at least 170. For the task of finding rows in a table, you use it in the same way as string_split:

SELECT ...
FROM   tbl 
WHERE  col IN (SELECT convert(int, value) FROM regexp_split_to_table('1,2,3,4', ','))

In contrast to string_split, the delimiter is not restricted to one single character, but it can be any length up to 8000 bytes. As you may guess from the name, the delimiter is actually a regular expression. Here is an example with a multi-string delimiter:

SELECT * FROM regexp_split_to_table('ABC<->DEF<->XYZ', '<->')

This is the result set:

value           ordinal

--------------- --------------------

ABC             1

DEF             2

XYZ             3

Another difference to string_split, is that regexp_split_to_table always returns the column ordinal; no need for any extra parameter. When it comes to the data type of the value column, the rules are the same as for string_split. That is, the type of value agrees with the type of the input string. There are two observations to make about using (n)varchar(MAX) for the input:

Here is an example that makes use of the power of regular expressions. We have a comma-separated string , where there sometimes are spaces after the comma and sometimes not. We run this example with both string_split and regexp_split_to_table. To show any spacing in the return values, we wrap the values in angle brackets:

DECLARE @list varchar(200) = 'Alpha, Beta,Gamma, Delta'
SELECT '<' + value + '>' AS stringsplit FROM string_split(@list, ',')
SELECT '<' + value + '>' AS regexp FROM regexp_split_to_table(@list, ', *')

This is the result:

stringsplit

--------------

<Alpha>

< Beta>

<Gamma>

< Delta>

 

(4 rows affected)

 

regexp

--------------

<Alpha>

<Beta>

<Gamma>

<Delta>

 

(4 rows affected)

As you can see, string_split does not strip off the leading spaces, but we need to take care of this ourselves. We don't need to do this with regexp_split_to_table since we specified a pattern of a comma followed by zero or more spaces.

If you have never worked with regular expressions before, the last example may have gone a little over your head. Don't worry, you don't have to use regular expressions. Most delimited lists are well-dressed and consistent, and you can use a constant string as a delimiter. However, when you use regexp_split_to_table you need to be aware that quite a few ASCII punctuation characters have a special meaning which can cause surprises. Here is one example:

DECLARE @list varchar(200) = 'Alpha.Beta.Gamma.Delta'
SELECT value FROM regexp_split_to_table(@list, '.')

The output consists of 23 blank lines. This happens because the dot is a wildcard which means "any character". To avoid this accident, you need to escape the dot to have it to be interpreted at face value:

DECLARE @list varchar(200) = 'Alpha.Beta.Gamma.Delta'
SELECT value FROM regexp_split_to_table(@list, '\.')

Rather than learning exactly which characters that have special meaning, it is a good idea to escape all ASCII punctuation characters with a backslash to have the character to be taken at face value. Going back to the examples I posted above and following this advice, they should read:

SELECT * FROM regexp_split_to_table('ABC<->DEF<->XYZ', '\<\-\>')
SELECT '<' + value + '>' AS regexp FROM regexp_split_to_table(@list, '\, *')

As you can see, regexp_split_to_table can do anything that string_split can do (save for the limitation of 2 MB for the input). Yet, you may prefer to use string_split for the simple and common cases. After all, the name is shorter to type, and you don't need to add those extra backslashes that I discussed above.

Even if string_split and regexp_split_to_table can take you a long way, you may encounter situations where you have requirements that they are not able to meet. Or you may be on an older version of SQL Server where you only have access to string_split, and you run into one of its limitations. For this reason, we will look at alternatives to the built-in functions in the following sections.

Note: There is one quite special situation when string_split exhibits very poor performance: 1) The input is of the varchar data type. 2) The input has UTF8 collation. 3) The input is long, 10 000 bytes or more and the execution times grow exponentially with the length of the input. See this bug report for more details. This issue does not apply to regexp_split_to_table.

Two Simple Multi-Statement Functions

If none of the built-in functions work for you, either because you have some special need or the functionality you need is not available on your version of SQL Server, you can write your own function. Here I will present two functions for splitting lists into table format, one for a list of strings and one for a list of numbers. While simple, these functions have a few advantages over string_split:

My main purpose with sharing these functions is to give you a starting point if you have some special list format that the built-in functions are not able to cope with. I should warn you that these functions are not the most efficient and therefore not suitable if you have long lists with thousands of elements, but there should not be any issues if you have only 10-20 or even 50 elements. If you work with large lists, and you cannot use string_split or regexp_split_to_table, and you want something which is more efficient, I have an old and long article where I describe several methods that are faster than these functions. They all require extra setup than just a function, and some of them may be more difficult to adapt to special needs.

Here is the first function. It splits a delimited list of integers. The function accepts a parameter for the delimiter which can be up to 10 characters long. The function returns the list position for the elements. An empty element is returned as NULL. If there is a non-numeric value in the list, there will be a conversion error.

CREATE FUNCTION intlist_to_tbl (@list  nvarchar(MAX),
                                @delim nvarchar(10))
   RETURNS @tbl TABLE (listpos int NOT NULL IDENTITY(1,1),
                       n       int NULL) AS
BEGIN
   DECLARE @pos       int = 1,
           @nextpos   int = 1,
           @valuelen  int,
           @delimlen  int = datalength(@delim) / 2

   WHILE @nextpos > 0
   BEGIN
      SELECT @nextpos = charindex(@delim COLLATE Czech_BIN2, @list, @pos)
      SELECT @valuelen = CASE WHEN @nextpos > 0 THEN @nextpos
                              ELSE len(@list) + 1
                         END - @pos
      INSERT @tbl (n)
         VALUES (convert(int, nullif(substring(@list, @pos, @valuelen), '')))
      SELECT @pos = @nextpos + @delimlen
   END
   RETURN
END

You are likely to be puzzled by the COLLATE clause. This is a small speed booster. By forcing a binary collation, we avoid that SQL Server employs the full Unicode rules when searching for the delimiter. This pays off when scanning long strings. Why Czech? The language does not matter here, so I just picked one with a short name.

And why is datalength divided by 2 and not len? datalength returns the length in bytes, whence the division. len counts characters but does not count trailing spaces, so it does not work if the delimiter is a space.

Here are two examples:

SELECT * FROM intlist_to_tbl('1,2,3, 677,7 , ,-1', ',')
SELECT * FROM intlist_to_tbl('1<->2<->3<-> 677<->7<-><->-1', '<->')

Since the values are the same in both lists, the output is the same:

listpos     n

----------- -----------

1           1

2           2

3           3

4           677

5           7

6           NULL

7           -1

Here is an example of how you would use it in a simple query:

SELECT ...
FROM   tbl 
WHERE  col IN (SELECT n FROM intlist_to_tbl('1,2,3,4', ','))

If you find that you are only using comma-separated lists, you may grow tired of having to specify the delimiter every time. To that end, this wrapper can be handy:

CREATE FUNCTION intlisttotbl (@list nvarchar(MAX)) RETURNS TABLE AS
RETURN (
   SELECT listpos, n FROM intlist_to_tbl(@list, ',')
)

I leave it as an exercise to the reader to come up with a better name.

In passing, such wrapper could also use string_split:

CREATE FUNCTION intlisttotbl (@list nvarchar(MAX)) RETURNS TABLE AS
RETURN (
   SELECT listpos = ordinal, 
          n = CASE WHEN len(value) > 0 THEN convert(int, value) END 
   FROM string_split(@list, ',', 1)
)
go
SELECT n FROM intlisttotbl('1,2,3, 677,7 , ,-1')

Here is a function for splitting a list of strings. It accepts an input parameter of the type nvarchar(MAX), and the return table has both a varchar and an nvarchar column. I will return to why in a second. Like intlist_to_tbl, it returns the list position. It trims leading and trailing spaces. In contrast to intlist_to_tbl, empty elements are returned as empty strings and not as NULL.

CREATE FUNCTION strlist_to_tbl (@list  nvarchar(MAX),
                                @delim nvarchar(10))
   RETURNS @tbl TABLE (listpos int NOT NULL IDENTITY(1,1),
                       str     varchar(4000) NOT NULL,
                       nstr    nvarchar(4000) NOT NULL) AS
BEGIN
   DECLARE @pos       int = 1,
           @nextpos   int = 1,
           @valuelen  int,
           @nstr      nvarchar(4000),
           @delimlen  int = datalength(@delim) / 2

   WHILE @nextpos > 0
   BEGIN
      SELECT @nextpos = charindex(@delim COLLATE Czech_BIN2, @list, @pos)
      SELECT @valuelen = CASE WHEN @nextpos > 0 THEN @nextpos
                              ELSE len(@list) + 1
                         END - @pos
      SELECT @nstr = ltrim(rtrim(substring(@list, @pos, @valuelen)))
      INSERT @tbl (str, nstr)
         VALUES (@nstr, @nstr)
      SELECT @pos = @nextpos + @delimlen
   END
   RETURN
END

Here are two examples:

SELECT * FROM strlist_to_tbl(N'Alpha (α) | Beta (β)|Gamma (γ)|Delta (δ)|', '|')
SELECT * FROM strlist_to_tbl(N'a///b///c///v///x', '///')

Here is the output:

listpos     str        nstr

----------- ---------- ---------- 

1           Alpha (a)  Alpha (α)

2           Beta (ß)   Beta (β)

3           Gamma (?)  Gamma (γ)

4           Delta (d)  Delta (δ)

5                      

 

listpos     str        nstr

----------- ---------- -----------

1           a          a

2           b          b

3           c          c

4           v          v

5           x          x

In the first result set, the Greek characters have been replaced by fallback characters in the str column, while they are unchanged in the nstr column. The reason I got this output is because I ran this with a Finnish_Swedish collation, where there are no Greek characters available for varchar. (If you would run the examples with a Greek collation or a UTF‑8 collation, the two columns will have identical values, though.)

Here are two examples of using this function:

SELECT ...
FROM   tbl 
WHERE  varcharcol IN (SELECT str FROM strlist_to_tbl('a,b,c', ','))

SELECT ...
FROM   tbl 
WHERE  nvarcharcol IN (SELECT nstr FROM strlist_to_tbl('a,b,c', ','))

These examples illustrate why there are two columns. If you are going to use the list against a varchar column, you need to use the str column. This is important because of the type-conversion rules in SQL Server. If you mistakenly compare varcharcol to nstr, varcharcol will be converted to nvarchar, and depending on your collation this can render any index on varcharcol ineligible for the query, leading to a performance disaster as the table must be scanned. And conversely, if you have a nvarchar column, you need to compare it to the nvarchar value, since else the result can be incorrect because of the character replacements with the conversion to varchar.

What If You Cannot Use a Function?

If you are in the unfortunate situation that string_split does not work for you and you don't have the permission or authorisation to create functions, what can you do? One option is of course to incorporate the function body in your code, but that is not really appealing.

An alternative that is popular with some people is to convert the list into an XML document. This works on all versions of SQL Server from SQL 2005 and up:

DECLARE @list nvarchar(MAX) = '1<->99<->22<->33<->45',
        @xml  xml
SELECT @xml = '<x>' + replace(@list COLLATE Czech_BIN2, '<->', '</x><x>') + '</x>'
SELECT X.x.value('.', 'int') AS val
       --, row_number() OVER(ORDER BY X.x) AS listpos
FROM   @xml.nodes('/x/text()') X(x)

To give you an idea of what is going on, here is the resulting XML:

<x>1</x><x>99</x><x>22</x><x>33</x><x>45</x>

You can use the XML query in your main query directly, but it is probably easier to stick the result in a temp table and work from there.

As you can see, there is a listpos column in the query, but I have commented it out. This is because while it seems to give the desired result, it is to my knowing not something which is documented and you can rely on. That is, it could stop working at some point.

If you on are SQL 2016, SQL 2017 or SQL 2019, and you need the list position but you cannot write your own function, there is an option that is easier to use than XML, to wit JSON:

DECLARE @list nvarchar(MAX) = '1,99,22,33,45'
SELECT convert(int, [key]) + 1 AS listpos, convert(int, value) AS n
FROM   OPENJSON('[' + @list + ']')

That is, just wrap the list in brackets and off you go. Would you have another delimiter than comma, you will need to replace that delimiter with a comma to adhere to the JSON syntax.

OPENJSON returns a result set with three columns, but only key and value are of interest to you. Both are nvarchar(4000), so you need to cast them to int. Note that the values in key are zero-based.

In these examples, I used integer lists. I need to raise a word of warning if you are considering using XML or JSON for lists of strings. If the values are strictly alphanumeric, no sweat. But if there are characters that are special to XML or JSON, the method above will fall apart. It is possible to save the show with help of CDATA sections that protects special characters as in this example I got from Yitzhak Khabinsky:

DECLARE @list nvarchar(MAX),
        @xml  xml
SET @list = 'Dog & [Pony],Always < then,Glenn & Co. > 100';
SELECT @xml = '<x><![CDATA[' + 
                   replace(@list COLLATE Czech_BIN2, ',', ']]></x><x><![CDATA[') + 
                ']]></x>';
SELECT @xml
SELECT X.x.value('.', 'nvarchar(30)') AS val
       --, row_number() OVER(ORDER BY X.x) AS listpos
FROM   @xml.nodes('/x/text()') X(x);

The output is:

val

------------------------------

Dog & [Pony]

Always < then

Glenn & Co. > 100

If you feel that your head is starting to spin at this moment, you have my sympathy. Despite its complexity, it is probably the best solution when you cannot write a function. If you want an alternative, you can look at the CTE method that I describe in my old article that I mentioned above. This method can give you the list position in a guaranteed way.

When it comes to speed, XML and JSON are faster than the functions that I showed you in the previous section, and they should work well with lists with thousands of values. Particularly, pay attention to the addition of the text() function in the .nodes method. Without it, shredding the XML takes about 50 % more time. (I owe this trick to Yitzhak Khabinsky.)

An Anti-Pattern

Amazingly enough, I still occasionally see people who use or propose using dynamic SQL. That is, something like this:

SELECT @sql = 'SELECT ...FROM tbl WHERE col IN (' + @list + ')'

There are all sorts of problems here. Risk for SQL injection. It makes the code more difficult to read and maintain. (Just imagine that this is a large query spanning fifty lines that someone wrapped in dynamic SQL only because of the list). Permissions can be a problem. It leads to cache littering. On top of that, performance is poor. Above I cautioned you that the functions I presented are not good for long lists – but they are certainly better than dynamic SQL. It takes SQL Server a long time to parse a long list of values for IN.

Do absolutely not use this!

Delimited Lists in a Table Column

Sometimes you may encounter a table column which holds a delimited list of values. This is an anti-pattern that appears to have become rather more popular over the years, despite that relational databases are designed from the principle that a cell (that is, a column in a row) is supposed to hold one single atomic value. Storing delimited lists goes against that principle, and if you store data this way, you will have to pay a dear price in terms of performance and complex programming. The proper way is to store the data in the list in a child table.

Nevertheless, you may encounter a comma-separated list that someone else has designed. And even if you have the power to change the design, you still need to know how to handle it. Let's first get an example to work with:

CREATE TABLE orders (orderid    int          NOT NULL,
                     custid     int          NOT NULL,
                     orderdate  date         NOT NULL,
                     products   varchar(MAX) NOT NULL,
                     quantities varchar(MAX) NOT NULL,
                     prices     varchar(MAX) NOT NULL,
                     CONSTRAINT pk_orders PRIMARY KEY (orderid)
)
go
INSERT orders (orderid, custid, orderdate, products, quantities, prices)
   VALUES (1, 108, '20201215', 
           'A16769,B1234,B2679,DL123', '1,2,1,1', '100,123,9000,450'),
          (2, 985, '20201216', 
           'A16769,A8744,B1233,CBGB2,E98767', '3,4,1,1,7', '100,560,400,600,320'),
          (3, 254, '20201217', 
           'X5277', '19', '300')
go
SELECT * FROM orders

This is an unusually bad example with three comma-separated lists that are synchronised with each other. (Thankfully, I rarely see something this crazy in the wild!) To keep it simple, we first ignore the quantities and prices columns and run a query that lists the orders with one product per row:

SELECT o.orderid, o.custid, p.str AS prodid
FROM   orders o
CROSS  APPLY strlist_to_tbl(o.products, ',') AS p
ORDER  BY o.orderid, prodid

The key is the CROSS APPLY operator. APPLY is a kind of a join operator. When you say A JOIN B, you add conditions with ON that correlate A and B, but B itself cannot refer to A. For instance, B cannot be a call to a table-valued function that takes a column from A as parameter. But this is exactly what APPLY permits you. On the other hand, there is no ON clause with APPLY as the relation between A and B is inside B. (B can also be a subquery).

Here is the result set:

orderid     custid      prodid

----------- ----------- ---------

1           108         A16769

1           108         B1234

1           108         B2679

1           108         DL123

2           985         A16769

2           985         A8744

2           985         B1233

2           985         CBGB2

2           985         E98767

3           254         X5277

 

Note: There is also OUTER APPLY. The difference between CROSS APPLY and OUTER APPLY is outside the scope of this article, though.

The normal design is of course to have two tables, orders and orderdetails. Here is a script to create a new table and move the data in the columns products, quantities and prices columns to this new table:

CREATE TABLE orderdetails (orderid   int NOT NULL,
                           prodid    varchar(10) NOT NULL,
                           qty       int         NOT NULL,
                           price     int         NOT NULL,
                           CONSTRAINT pk_orderdetails PRIMARY KEY (orderid, prodid)
)

INSERT orderdetails (orderid, prodid, qty, price)
   SELECT o.orderid, p.str AS prodid, q.n AS qty, c.n AS price
   FROM   orders o
   CROSS  APPLY strlist_to_tbl(o.products, ',') AS p
   CROSS  APPLY intlist_to_tbl(o.quantities, ',') AS q
   CROSS  APPLY intlist_to_tbl(o.prices, ',') AS c 
   WHERE  p.listpos = q.listpos
     AND  p.listpos = c.listpos

ALTER TABLE orders DROP COLUMN products, quantities, prices

To pair the values from the lists, we synchronise them on the listpos column.

Here is the same operation, this time with string_split making use of the third parameter to get the list position. As noted above, this requires SQL 2022 or later.

SELECT o.orderid, p.value AS prodid, 
       convert(int, q.value) AS qty, convert(int, c.value) AS price
FROM   orders o
CROSS  APPLY string_split(o.products, ',', 1) AS p
CROSS  APPLY string_split(o.quantities, ',', 1) AS q
CROSS  APPLY string_split(o.prices, ',', 1) AS c 
WHERE  p.ordinal = q.ordinal
  AND  p.ordinal = c.ordinal

Say now that you need to update one of the values in the list. Well, didn't I tell you: you need to change the design so that the delimited list becomes a child table? But, OK, you are stuck with the design, so what do you do? Answer: you will have to unpack the data into a temp table, perform your updates and then reconstruct the list(s). As I said, relational databases are not designed for this pattern.

Here is how you would rebuild a list if you are on SQL 2017 or later. For brevity, I only show how to build the products column. The other two are left as an exercise to the reader.

SELECT orderid, string_agg(prodid, ',') WITHIN GROUP (ORDER BY prodid)
FROM   orderdetails
GROUP  BY orderid

The string_agg function is an aggregate function like SUM or COUNT and it builds a concatenated list of all the input values delimited by the string you specify in the second parameter. The WITHIN GROUP clause permits you to specify the order of the list.

If you are on SQL 2016 or earlier, you can use FOR XML PATH which is a more roundabout way and the syntax is not very intuitive:

; WITH CTE AS (
   SELECT orderid, p.products.value('.', 'nvarchar(MAX)') AS products
   FROM   orders o
   CROSS  APPLY (SELECT od.prodid + ','
                 FROM   orderdetails od
                 WHERE  o.orderid = od.orderid
                 ORDER  BY od.prodid
                 FOR XML PATH(''), TYPE) AS p(products)
)
SELECT orderid, substring(products, 1, len(products) - 1)
FROM   CTE

I refrain from trying to explain how it works. Just try to mimic the pattern if you need this. Or redesign the table after all...

Performance Tip

As long as you have only a handful of elements, the method you use to crack the list does not have any significant impact on performance. What is more important is how you get the values from the list into the rest of the query. For simplicity's sake, I have shown you examples like this:

SELECT ...
FROM   tbl 
WHERE  col IN (SELECT n FROM intlist_to_tbl('1,2,3,4', ','))

However, the optimizer has a hard time to come up with the best plan, since it does not know much about what is coming out of the function. This gets more pronounced if the query is complex and includes a couple of joins and whatnots. This can result in poor performance, because the optimizer settles on a table scan where it should use an index or vice versa. This applies regardless if you use your own function, any of the built-in functions or something with XML or JSON. In many of these cases, SQL Server makes a blind guess of how many values there are in the list, and even less does it know what values there are in the list.

For this reason, I recommend that you unpack the list of values into a temp table and then use that temp table in your query like this:

CREATE TABLE #values (n int NOT NULL PRIMARY KEY)
INSERT #values(n)
   SELECT number FROM intlist_to_tbl('1,2,3,4', ',')

SELECT ...
FROM   tbl 
WHERE  col IN (SELECT n FROM #values)

Because a temp table has statistics, the optimizer has knowledge about the actual values in the list, and therefore the chances are better that it will come up with a good plan.

Revision History

2026-07-05
Updated the article to cover the new built-in function regexp_split_to_table, added in SQL 2025, which permits you to use a multi-string delimiter and also split the string with help of a regular expression. I've also made a general review of the article to keep up with the times.
2023-03-11
Updated the article to reflect that string_split with the third argument to get the list position is now mainstream since it is available in SQL 2022, and added some more examples using this new parameter. Also changed one of the XML examples to use a multi-string delimiter.
2021-12-04
Extended the section on string_split to describe the new third parameter that is now available in Azure SQL Database.
2021-02-27
Revised the section What If You Cannot Use a Function? after useful input from Yitzhak Khabinsky.
2021-02-15
Fixed an embarrassing bug in the functions intlist_to_tbl and strlist_to_tbl. They would get stuck in an infinite loop if the delimiter was a space.
2021-01-09
Revised the article to be about twice in length to include more example functions and solutions for situations where you cannot create functions. I also added some quick examples of the reverse operation: build comma-separated lists from table values.
2018-08-26
Added a section Performance Tip about the most important performance aspect when using list-to-table functions.
2016-08-21
Previously this article was just a contents holder that pointed to the various other articles I have on the topic. It is now a short introduction for people who are in need of a quick solution and don't need (and nor should) read the long article with all the performance considerations etc.

Back to my home page.