Use string.Join: string.Join(", ", list) turns ["C#", "Go", "Python"] into C#, Go, Python. It takes any IEnumerable<T>, so a List<int> or a list of records joins the same way, and you can shape the elements first with LINQ: string.Join(", ", nums.Select(n => $"#{n}")).
string.Join has been in .NET since 1.0 and covers almost every case: it puts the separator between the elements, never after the last one, and since .NET 4 it accepts any IEnumerable<string> or IEnumerable<T>, not only a string[]. That's why old answers call .ToArray() before joining and modern code doesn't need to. This page covers string.Join with strings, numbers and objects, what it does with null elements, string.Concat and why LINQ's Aggregate is the wrong tool, the trailing-separator bug in StringBuilder loops and the AppendJoin fix, and the "a, b and c" variation. Each example runs on this page: hit Run, then edit the code and run it again.
1string.JoinRecommended
string.Join(separator, values) takes the separator first and the elements second. The elements can be a List<string>, any other IEnumerable<string> (a set, a LINQ query), a string[], or values written straight into the call. Since .NET Core 2.0 the separator can also be a char, and an overload for arrays joins only a slice of them.
Output
Prints C#, Go, Python, C# | Go | Python, C#GoPython (an empty separator just concatenates) and C#;Go;Python with the char separator. The set joins as apple, fig, pear in the SortedSet's order, then the array gives 2026-10-04, the inline values usr/local/bin, and the slice from index 1, count 2, gives 10, 04. An empty list joins to an empty string, which is why the brackets print as [], and a one-element list comes back as solo with no separator. string.Join follows the collection's enumeration order, so a HashSet joins in no guaranteed order; sort first when the order matters (see sorting a list).
2Numbers and objects: string.Join<T> and Select
Unlike Java's String.join, C#'s accepts elements of any type. The generic overload string.Join<T>(string, IEnumerable<T>) calls ToString() on each element, so a List<int> needs no conversion step. When you want something other than the default ToString(), such as a format string, one property, or only some of the elements, put a LINQ Where, Order or Select inside the call.
Output
Prints 3, 1, 4, 1, 5, then [3, 1, 4, 1, 5], then #3 #4 #5 (the values above 1, sorted and formatted; Order() is .NET 7+, use OrderBy(n => n) on older versions). The prices print as 9.50, 12.00, 0.25; ToString("F2") uses the current culture's decimal separator, so pass CultureInfo.InvariantCulture when the output is for a machine. The records print Point { X = 1, Y = 2 }; Point { X = 3, Y = 4 } from their generated ToString(), and Select(p => p.X) gives 1, 3. A class that doesn't override ToString() prints its type name: Box, Box. The lists here are deliberate: a Box[] array matches two overloads and the call doesn't compile (see the FAQ).
3Null elements: joined as empty, or filtered out
string.Join doesn't throw on a null element and doesn't skip it either: it writes an empty string, so the separators around it stay and you get doubled commas. Filter with Where(n => n is not null), or swap in a default with ??. Empty and blank strings are a separate case: they aren't null, so string.IsNullOrWhiteSpace is the filter that catches all three.
Output
The first line prints Ada, , Grace, with a trailing comma and space where the last null was. Filtering gives Ada, Grace and the default gives Ada, ?, Grace, ?. The raw tag list prints csharp,, ,,linq; with the IsNullOrWhiteSpace filter it's csharp,linq. A null separator counts as an empty string, so the next line is abc. A null list is the one real error: ArgumentNullException: Value cannot be null. (Parameter 'values'). The ! after missing only silences the nullable warning; it doesn't stop the exception.
4With LINQ: string.Concat, and why not Aggregate
To concatenate with no separator at all, call string.Concat, which takes the same kinds of sequences as string.Join. Searching for "concat strings with LINQ" often turns up Aggregate((a, b) => a + ", " + b). It works on a short list, but every step allocates a new string that copies everything joined so far, the total work grows with the square of the output length, and it throws on an empty sequence.
Output
string.Concat prints C#GoPython, and with a Select in front c#gopython. The plain Aggregate prints C#, Go, Python but on an empty list throws InvalidOperationException: Sequence contains no elements. The seeded overload survives an empty list but leaves [C#, Go, Python, ], and TrimEnd(',', ' ') would also eat a comma or space that belongs to the last item. The StringBuilder seed avoids the copying and prints the right C#, Go, Python, and the final True confirms it equals string.Join. It is still more code than string.Join for the same result.
5A StringBuilder loop and AppendJoin
When the joined list is only part of a larger string, or you add items inside a loop with conditions, you build it in a StringBuilder. The classic mistake is appending the separator after every item, which leaves one dangling at the end. Below are the two standard fixes for a hand-written loop, then AppendJoin (.NET Core 2.0+), which joins a sequence straight into the builder so you don't need the loop.
Output
The buggy loop prints [C#, Go, Python, ]; the brackets make the trailing , visible. Both fixes print [C#, Go, Python]. The guard in fix 2 matters, because on an empty builder Length -= 2 throws ArgumentOutOfRangeException: value ('-2') must be a non-negative value. (Parameter 'value'). AppendJoin prints Languages: C#, Go, Python. without building an intermediate string, and the last line prints True: the loop's result is identical to string.Join. Avoid += on a string in a loop for the same reason as Aggregate: strings are immutable, so each += copies everything built so far.
6Variations: "a, b and c", key=value pairs, repeating
Text for people usually wants C#, Go and Python rather than C#, Go, Python: join every item except the last with a comma, then attach the last one with and. The same string.Join call also covers one item per line, a dictionary's entries as key=value pairs, and repeating a string with Enumerable.Repeat. .NET has no string.Repeat method; for a single character, the new string(char, count) constructor does it.
Output
Prints C#, C# and Go, C#, Go and Python, and C#, Go, and Python with the Oxford comma (items[^1] is the last item, C# 8+). Then first and second on separate lines, and debug=0&host=1&port=8080: the OrderBy matters because a Dictionary doesn't guarantee its enumeration order (more in iterating over a dictionary). Last come ababab, a line of 20 dashes, and ?, ?, ?, the usual way to build the placeholders for a SQL IN (...) clause.
7Which should you use?
| Method | Elements | Needs | Best for |
|---|---|---|---|
| string.Join(", ", list) | Strings or any T (ToString) | Any .NET | Almost everything: the default |
| string.Join(", ", list.Select(...)) | Anything | Any .NET | Formatting, filtering or sorting first |
| string.Concat(list) | Strings or any T | Any .NET | Joining with no separator |
| sb.AppendJoin(", ", list) | Strings or any T | .NET Core 2.0+ | A joined list inside a larger StringBuilder |
| StringBuilder loop | Anything | Any .NET | Conditions per item; watch the trailing separator |
| Aggregate((a, b) => a + ", " + b) | Strings | Throws on empty | Nothing: use string.Join |
Frequently asked questions
How do I convert a List<string> to a comma-separated string in C#?
Call string.Join(", ", list). It works on any IEnumerable<string>, including a LINQ query, so there is no need to call .ToArray() first as old .NET 3.5 answers do. The separator goes only between elements: there is no trailing comma, an empty list gives an empty string, and a one-element list comes back unchanged. Use string.Join(Environment.NewLine, list) for one item per line.
Can string.Join join a List<int> or a list of objects?
Yes. The generic overload string.Join<T>(string, IEnumerable<T>) calls ToString() on each element, so string.Join(", ", new List<int> { 3, 1, 4, 1, 5 }) gives 3, 1, 4, 1, 5 and a record list prints each record’s generated ToString(). A class that doesn’t override ToString() prints its type name, so use Select(x => x.Name) to pick a property, or Select(p => p.ToString("F2")) to format numbers.
Why is string.Join ambiguous with an array of my class?
An array of a reference type other than string, such as Box[], converts both to params object?[] and to IEnumerable<Box>, so the compiler stops with error CS0121: The call is ambiguous between the following methods or properties: 'string.Join(string?, params object?[])' and 'string.Join<T>(string?, IEnumerable<T>)'. Pick one overload: string.Join(", ", boxes.AsEnumerable()) or string.Join(", ", boxes.Select(b => b.ToString())). A string[], an int[] and a List<Box> don’t have this problem.
What happens to null elements when joining in C#?
Each null element becomes an empty string, so ["Ada", null, "Grace", null] joins to Ada, , Grace, with the separators still in place. Remove them with .Where(n => n is not null), replace them with .Select(n => n ?? "?"), or use .Where(s => !string.IsNullOrWhiteSpace(s)) to drop empty and blank strings too. A null separator is treated as empty, but a null collection throws ArgumentNullException: Value cannot be null. (Parameter 'values').
How do I concatenate strings with LINQ?
Pass the query to string.Concat (no separator) or string.Join (with one): string.Concat(words.Select(w => w.ToLower())). Avoid words.Aggregate((a, b) => a + ", " + b): each step copies the whole string built so far, and on an empty sequence it throws InvalidOperationException: Sequence contains no elements.
How do I join a Dictionary into key=value pairs?
Format each entry with Select and join the result: string.Join("&", dict.OrderBy(kv => kv.Key).Select(kv => $"{kv.Key}={kv.Value}")). Sort first, because Dictionary doesn’t guarantee its enumeration order; with port=8080, host=1 and debug=0 the sorted version gives debug=0&host=1&port=8080. For URL query strings, also escape keys and values with Uri.EscapeDataString.
How do I repeat a string in C#?
There is no string.Repeat. Use string.Concat(Enumerable.Repeat("ab", 3)), which gives ababab, or string.Join(", ", Enumerable.Repeat("?", 3)) for ?, ?, ? with a separator. For a single character, new string('-', 20) is the shortest. A count of 0 gives an empty string, and a negative count makes Enumerable.Repeat throw ArgumentOutOfRangeException.