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.
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
Two Simple Multi-Statement Functions
What If You Cannot Use a Function?
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.
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.
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.
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.
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.
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.)
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!
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...
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.