Skip to content
datahacks.dev
CSV

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.

10 min read
Summarize in AI
Flat illustration of a spreadsheet grid with a selected cell and a formula bar, with the GNU Bash logo.

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:

Program.csC#
    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;
Shell
dotnet --version; dotnet build -c Release -o /tmp/out >/dev/null 2>&1; grep -o '"CsvHelper/[0-9.]*"' obj/project.assets.json | head -n 1
Text
9.0.320
"CsvHelper/33.1.0"
Shell
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll people ../data/people.csv
Text
headers: 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:

Program.csC#
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:

Shell
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll typed ../data/zips.csv
Text
HeaderValidationException: 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:

Shell
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.csv
Text
string: 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 NY

The 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:

Shell
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll typed-int ../data/zips-blank.csv
Text
int:    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:

Program.csC#
    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;
Shell
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll errors ../data/ragged2.csv
Text
records 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 8
Shell
dotnet 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.csv
Text
no exception
no exception

On 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:

Program.csC#
    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;
Shell
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll bom ../data/bom.csv
Text
header: "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:

Program.csC#
    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;
Shell
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 fields
Text
fields  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=40MB

With 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:

Shell
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; done
Text
typed   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=39MB

The 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:

Program.csC#
    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;
Shell
dotnet build -c Release -o /tmp/out >/dev/null 2>&1; dotnet /tmp/out/csvlab.dll write
Text
Id,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,True

The 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.

Also inScripting
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.