Reading a CSV File in C Sharp With CsvHelper Correctly
Read CSV in C# with CsvHelper 33: case-sensitive headers, typed ZIP codes, silent ragged rows, and a de-DE culture that made a total 91 times too big.

CsvHelper is the standard answer to "how do I read a CSV in C#", and the ergonomics are good: you point it at a class and it fills the class. The traps are in the defaults it chooses for you. It matches column names exactly, it takes its number format from a culture you pass it, and it treats several kinds of malformed file as not an error. This article reads the series' test files with CsvHelper 33.1.0, shows each failure, and times the read of 2,000,000 rows. The comparison with other languages is in the guide.
Reading fields by hand
The lowest-level use is Read() to move to the next row and GetField to take a value:
case "people":
{
using var reader = new StreamReader(path);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
csv.Read(); csv.ReadHeader();
Console.WriteLine("headers: " + string.Join(" | ", csv.HeaderRecord!));
while (csv.Read())
Console.WriteLine($"row {csv.Parser.Row}: " + string.Join(" | ", Enumerable.Range(0, csv.Parser.Count).Select(i => csv.GetField(i)!.Replace("\n", "\\n"))));
break;dotnet --version; dotnet build -c Release -o /tmp/out >/dev/null 2>&1; grep -o '"CsvHelper/[0-9.]*"' obj/project.assets.json | head -n 19.0.320
"CsvHelper/33.1.0"dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll people ../data/people.csvheaders: id | name | city | note
row 2: 1 | Ana Silva | Lisbon | likes tea
row 3: 2 | Smith, Jane | Boston | said "hello"
row 4: 3 | Bo Chen | Oslo | line one\nline two
row 5: 4 | Cy Dubois | Lyon | The first block prints the SDK and library versions. The second shows the records: Smith, Jane is one field, said "hello" has its quotes resolved, the third note contains a line break (I escaped it as \n for the display), and the empty note is an empty string. csv.Parser.Row counts lines, so the third record is on row 4 and the next on row 5 even though row 5 is the end of the record. As in the other typed readers, everything arrives as a string until you ask for a type.
Headers are case-sensitive
The convenient way is to map rows onto a class. The class and the file used for the next test:
public class Zip { public int Id { get; set; } [CsvHelper.Configuration.Attributes.Name("zip")] public string ZipCode { get; set; } = ""; public string State { get; set; } = ""; }The file has the header id,zip,state. The class has properties Id, ZipCode (named zip by an attribute) and State. Reading it:
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll typed ../data/zips.csvHeaderValidationException: Header with name 'Id'[0] was not found. Header with name 'State'[0] was not found.The result is an exception: HeaderValidationException: Header with name 'Id'[0] was not found. Header with name 'State'[0] was not found. CsvHelper matches the property name Id to a header called exactly Id, and the file says id. The same failure happens the other way round, with a file from a vendor that capitalizes. There are three fixes. Put [Name("id")] on the property, as I did for zip. Set cfg.PrepareHeaderForMatch = args => args.Header.ToLowerInvariant(), which compares lowercased headers with the property names, also lowercased by the library. Or turn the validation off with HeaderValidated = null, which I do not recommend: a column that is renamed upstream then silently becomes the type's default. Use the first two.
Types, and the leading zero
With the case fixed, the ZIP property can be a string or an int:
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll typed-ci ../data/zips.csv; dotnet /tmp/out/csvlab.dll typed-int ../data/zips.csvstring: 1 02134 MA
string: 2 90210 CA
string: 3 00501 NY
string: 4 10001 NY
int: 1 2134 MA
int: 2 90210 CA
int: 3 501 NY
int: 4 10001 NYThe string version printed 02134, 90210, 00501, 10001. The int version printed 2134, 90210, 501, 10001. The leading zeros are gone, once more, because int.Parse("02134") is 2134. Keep identifiers as string. An empty cell is not quietly defaulted, which is better than some libraries:
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll typed-int ../data/zips-blank.csvint: 1 2134 MA
TypeConverterException: The conversion cannot be performed. Text: ''The first record parsed; the second raised TypeConverterException: The conversion cannot be performed. Text: ''. If blanks are legal, make the property nullable (int?) or a string. The exception message carries the text and, when you catch it, the context with the row number.
Ragged rows, bad data and an unclosed quote
CsvHelper reports problems through callbacks on the configuration, and many of its checks are off by default:
case "errors":
{
var bad = new List<string>();
var cfg = new CsvConfiguration(CultureInfo.InvariantCulture)
{
MissingFieldFound = a => bad.Add($"missing field at index {a.Index}, row {a.Context!.Parser!.Row}"),
BadDataFound = a => bad.Add($"bad data on row {a.Context!.Parser!.Row}: {a.RawRecord.Trim()}"),
};
using var reader = new StreamReader(path);
using var csv = new CsvReader(reader, cfg);
int n = 0;
try { foreach (var r in csv.GetRecords<dynamic>()) n++; }
catch (Exception e) { Console.WriteLine($"{e.GetType().Name}: {e.Message.Split('\n')[0]}"); }
Console.WriteLine($"records read: {n}");
foreach (var b in bad) Console.WriteLine(" " + b);
break;
}
case "strict":
{
using var reader = new StreamReader(path);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
try { foreach (var r in csv.GetRecords<dynamic>()) { } Console.WriteLine("no exception"); }
catch (Exception e) { Console.WriteLine($"{e.GetType().Name}: {e.Message.Split('\n')[0]}"); }
break;dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll errors ../data/ragged2.csvrecords read: 8
missing field at index 3, row 6
missing field at index 1, row 8
missing field at index 2, row 8
missing field at index 3, row 8dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll strict ../data/ragged2.csv; dotnet /tmp/out/csvlab.dll strict ../data/unterminated.csvno exception
no exceptionOn the ragged file, with MissingFieldFound and BadDataFound set to record what they see, CsvHelper read 8 records and called MissingFieldFound four times, for the short records (index 3 on row 6, and indexes 1, 2, 3 on row 8). The long record, with an extra field, raised nothing at all. With the default configuration, the second run prints no exception for both the ragged file and the unclosed-quote file. The unclosed quote is read as a field running to the end of the input, the same as in Rust and PHP. If you want a failure, set MissingFieldFound to throw or collect, BadDataFound to throw, and check each record's field count against the header (csv.Parser.Count). Silence is the default, so you have to configure the alarm.
Encoding and the BOM
StreamReader detects a byte order mark, and CsvHelper reads through it:
case "bom":
{
using var reader = new StreamReader(path);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
csv.Read(); csv.ReadHeader();
Console.WriteLine("header: " + string.Join(" | ", csv.HeaderRecord!.Select(h => $"\"{h}\"")));
using var raw = new StreamReader(path, new System.Text.UTF8Encoding(false), detectEncodingFromByteOrderMarks: false);
using var csv2 = new CsvReader(raw, CultureInfo.InvariantCulture);
csv2.Read(); csv2.ReadHeader();
Console.WriteLine("without BOM detection: " + string.Join(" | ", csv2.HeaderRecord!.Select(h => $"\"{h}\"")));
break;dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll bom ../data/bom.csvheader: "id" | "name"
without BOM detection: "id" | "name"The default reader returned the header "id" | "name" without the BOM. When I opened the same file with detectEncodingFromByteOrderMarks: false, the first header was "id" with the invisible character. So the BOM is handled for you as long as you give CsvHelper a StreamReader with the default detection. A file in Windows-1252 needs Encoding.GetEncoding(1252), which on .NET Core requires registering CodePagesEncodingProvider first.
The culture trap
The CultureInfo you pass to CsvReader decides how 1,234.50 and 103.1 are parsed. On a developer's machine in Germany, CultureInfo.CurrentCulture is de-DE, where the period is the thousands separator and the comma is the decimal separator. I summed the amount column of the first 200,000 orders under two cultures:
case "sum":
{
var culture = CultureInfo.GetCultureInfo(args[2]);
var sw = Stopwatch.StartNew();
using var reader = new StreamReader(path);
using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
double total = 0; long rows = 0;
csv.Read(); csv.ReadHeader();
if (args[3] == "typed")
{
foreach (var o in csv.GetRecords<Order>()) { total += o.Amount; rows++; }
}
else if (args[3] == "fields")
{
while (csv.Read()) { total += double.Parse(csv.GetField(3)!, culture); rows++; }
}
else
{
while (csv.Read()) { total += csv.GetField<double>(3); rows++; }
}
Console.WriteLine($"{args[3],-7} culture={args[2],-6} rows={rows} total={total:F2} time={sw.Elapsed.TotalSeconds:F2}s peak_rss={PeakRssMb()}MB");
break;dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll sum ../data/orders-200k.csv de-DE fields; dotnet /tmp/out/csvlab.dll sum ../data/orders-200k.csv en-US fieldsfields culture=de-DE rows=200000 total=1457269235.00 time=0.31s peak_rss=40MB
fields culture=en-US rows=200000 total=16018429.85 time=0.29s peak_rss=40MBWith en-US, the total was 16,018,429.85. With de-DE, it was 1,457,269,235.00. The value 103.1 parsed as 1031, 31.59 as 3159, and the sum came out about 91 times too large. No exception was raised, because both strings are valid numbers in German. The rule is to pass CultureInfo.InvariantCulture to CsvReader unless the file is known to be in a locale format, and then pass that locale deliberately. Never use CurrentCulture for a file: the code will give different answers on different machines.
Speed
Three ways to sum the 2,000,000 row file with en-US:
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; for m in typed fields getfield; do dotnet /tmp/out/csvlab.dll sum ../data/orders-2m.csv en-US $m; donetyped culture=en-US rows=2000000 total=160174646.36 time=1.66s peak_rss=45MB
fields culture=en-US rows=2000000 total=160174646.36 time=1.47s peak_rss=41MB
getfield culture=en-US rows=2000000 total=160174646.36 time=1.43s peak_rss=39MBThe typed GetRecords<Order>() took 1.66 s, reading a field and calling double.Parse took 1.47 s, and GetField<double>(3) took 1.43 s. Memory stayed at 39 to 45 MB, since the library streams. All three produced 160174646.36. The typed route costs about 15% for the convenience, which is a good price. For comparison, Rust took 0.24 to 0.42 s, Go 0.98 s, Java 1.13 s and Python 1.52 s on the same file.
Writing
CsvWriter.WriteRecords takes a sequence of objects and uses the property names as headers:
case "write":
{
using var writer = new StreamWriter(Console.OpenStandardOutput()) { NewLine = "\n" };
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture) { NewLine = "\n" });
csv.WriteRecords(new[] {
new Out { Id = 1, Name = "Smith, Jane", Note = "said \"hi\"", When = new DateTime(2025, 3, 14, 10, 15, 0), Price = 9.5m, Ok = true },
new Out { Id = 2, Name = "Zoë", Note = "line one\nline two", When = new DateTime(2025, 3, 15), Price = 0.1m + 0.2m, Ok = false },
new Out { Id = 3, Name = "=SUM(A1)", Note = null, When = null, Price = 1234.5m, Ok = true },
});
break;dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll writeId,Name,Note,When,Price,Ok
1,"Smith, Jane","said ""hi""",03/14/2025 10:15:00,9.5,True
2,Zoë,"line one
line two",03/15/2025 00:00:00,0.3,False
3,=SUM(A1),,,1234.5,TrueThe header is Id,Name,Note,When,Price,Ok, the property names. Smith, Jane and said ""hi"" are quoted, the line break forces quotes. The DateTime was written as 03/14/2025 10:15:00 and the boolean as True: formats you probably do not want in a file for another system, and set per type through TypeConverterOptions or a class map. The decimal 0.1m + 0.2m printed 0.3, which is why decimal is the right type for money. And =SUM(A1) was written as it is: there is no formula guard, so escape it yourself for files opened in a spreadsheet.
How to tell it worked
Count the records and compare with another tool. Print the header record. Read a file with a differently cased header, a blank cell, a short row and a BOM. Run the same code with a different CurrentCulture set in the environment and confirm the totals do not change. And for typed classes, add a unit test with the awkward rows from the shell guide or the Python guide.
What CsvHelper does not check
By default, not the number of fields, not the closing of a quote, not the case of a header you meant to match, and not the culture. Each is a setting, and the first week of any import should be spent turning the ones that matter into exceptions. The format reference is RFC 4180; for a one-off look at a big file, a SQL query is quicker than a console app.


