DataTable refuses to die — it arrives from legacy data layers, reporting stored procedures, Excel readers, and third-party SDKs. What modern code needs is CSV for exports, List<T> for LINQ and APIs, and JSON for the wire. This post implements all three conversions in plain .NET 10 — no extra packages — with the edge cases (embedded commas, quotes, typing) handled properly and outputs verified from a real run.

The sample table
Deliberately hostile data — a name with quotes, a name with a comma:
var table = new DataTable("Products");
table.Columns.Add("Id", typeof(int));
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Price", typeof(decimal));
table.Rows.Add(1, "Mechanical keyboard", 89.00m);
table.Rows.Add(2, "4K \"UltraSharp\" monitor", 329.99m);
table.Rows.Add(3, "USB-C dock, 11-in-1", 149.50m);
DataTable → CSV, correctly
Naïve string.Join(",") produces broken files the moment data contains a comma. RFC 4180 rules are simple: quote fields containing commas, quotes, or newlines; double the embedded quotes.
static string ToCsv(DataTable table)
{
static string Escape(object? value)
{
var s = value?.ToString() ?? "";
return s.Contains(',') || s.Contains('"') || s.Contains('\n')
? $"\"{s.Replace("\"", "\"\"")}\"" : s;
}
var sb = new StringBuilder();
sb.AppendLine(string.Join(",", table.Columns.Cast<DataColumn>().Select(c => Escape(c.ColumnName))));
foreach (DataRow row in table.Rows)
sb.AppendLine(string.Join(",", row.ItemArray.Select(Escape)));
return sb.ToString();
}
Verified output — note both hostile rows survive:
Id,Name,Price
1,Mechanical keyboard,89.00
2,"4K ""UltraSharp"" monitor",329.99
3,"USB-C dock, 11-in-1",149.50
Two production notes: write files with File.WriteAllText(path, csv, new UTF8Encoding(true)) if Excel is the consumer (the BOM makes Excel detect UTF-8), and for millions of rows stream with a StreamWriter per line instead of one giant StringBuilder. If your CSV needs get complex (culture-specific separators, type conversion on read), the CsvHelper package is the standard step up — but for exports, twenty lines of correct code beats a dependency.
DataTable → List<T>
The typed list is the gateway conversion — once you have List<Product>, LINQ, serializers, and APIs all work naturally:
record Product(int Id, string Name, decimal Price);
static List<Product> ToList(DataTable table) =>
[.. table.Rows.Cast<DataRow>().Select(r =>
new Product(r.Field<int>("Id"), r.Field<string>("Name")!, r.Field<decimal>("Price")))];
DataRow.Field<T> (from System.Data.DataSetExtensions, built into modern .NET) beats raw indexing because it understands DBNull: r.Field<decimal?>("Price") returns null for database NULLs instead of throwing or handing you a DBNull object to trip over. For nullable columns, make the record property nullable and let Field<T?> do the mapping.
Explicit mapping per column looks like boilerplate until you need reflection-based generic mappers — which fail at runtime on the first type mismatch and cost you the compile-time checking that is the whole point of the typed list. For the handful of shapes a real app converts, explicit wins.
List<Product>: 3 items, first = Product { Id = 1, Name = Mechanical keyboard, Price = 89.00 }
DataTable → JSON
Serialize the typed list, not the DataTable:
var json = JsonSerializer.Serialize(ToList(table),
new JsonSerializerOptions { WriteIndented = true });
[
{ "Id": 1, "Name": "Mechanical keyboard", "Price": 89.00 },
{ "Id": 2, "Name": "4K "UltraSharp" monitor", "Price": 329.99 },
{ "Id": 3, "Name": "USB-C dock, 11-in-1", "Price": 149.50 }
]
Why the detour through List<T>? System.Text.Json doesn't serialize DataTable out of the box (Newtonsoft did, which is how a lot of accidental API contracts were born). That default is a feature: serializing a DataTable directly couples your JSON shape to database column names forever. The record is your contract — rename a column and the compiler shows you every place to fix, while the JSON stays stable. The " escapes are System.Text.Json being conservative; consumers parse them identically, and JavaScriptEncoder.UnsafeRelaxedJsonEscaping prettifies if humans read the output.
Choosing the target format
| Need | Convert to |
|---|---|
| Excel/import file for another system | CSV |
| LINQ, validation, further processing | List<T> |
| API response, message payload, cache entry | JSON (via List<T>) |
| Feeding SqlBulkCopy | keep the DataTable — it's the native source |
The pattern behind all three: get out of DataTable into typed objects as early as possible, and let every downstream concern work with real types. The complete runnable program is in the companion repository, next to the LINQ DataTable comparison sample that picks up where this one ends.
Comments (0)
No comments yet — be the first to share your thoughts.