Every data import job starts the same way: a 200 MB sample file parses in two seconds, so the parser choice looks irrelevant. Then the real export arrives — 4 GB, quoted fields with embedded commas, a UTF-8 BOM from Excel, and decimal separators that depend on which region the customer’s finance team sits in. The job allocates 30 GB, the container gets killed, and the “simple” part of the pipeline becomes the hardest part.

CsvHelper (5,214 stars) is the library most .NET developers reach for first, and for good reason: class maps, type converters, and a decade of edge-case handling. Sep (1,481 stars) is a zero-allocation parser built on Span<char> that claims the fastest throughput in .NET. Sylvan.Data.Csv (456 stars, part of the Sylvan library collection) takes a different route entirely — it exposes CSV as an ADO.NET DbDataReader, which means one line of code can pipe a file straight into SqlBulkCopy.

These are not three implementations of the same idea. They are three different answers to “what should a CSV parser be?”, and picking the wrong one costs you either throughput or a week of debugging.

TL;DR — The 30-Second Verdict

Use CsvHelper for anything with a schema you must validate. Class maps, converters, and readable error messages make it the right tool for imports where correctness matters more than raw speed. It allocates per field — that is the price of convenience.

Use Sep when throughput and allocations are the constraint. Zero-allocation row access, Span-based parsing, and the fastest published numbers of the three. The cost is a lower-level API and a ref struct reader that cannot cross await boundaries.

Use Sylvan.Data.Csv when the destination is a database. It is a DbDataReader, so SqlBulkCopy/NpgsqlBinaryImporter consumes it directly — no intermediate object graph, no List<T> of a million rows.

Side-by-Side Comparison

DimensionCsvHelperSepSylvan.Data.Csv
GitHub stars5,2141,481456 (Sylvan monorepo)
LicenseApache 2.0 / MS-PLMITMIT
Last push2026-10-282026-10-192026-04-07
API modelCsvReader + class mapsref struct row/column spansDbDataReader
Allocation profilePer-field stringsNear-zero (spans)Low (buffer reuse)
Column accessBy name, typedBy name, Parse<T>()By ordinal, typed getters
Header/name mappingFull (maps, converters)Header row awareOptional schema
Quoted newlinesYesYes (configurable)Yes
Multiline + encoding edge casesMost battle-testedGood, opinionatedGood
Async-friendlyYesNo (ref struct)Yes (ADO.NET surface)
Best atValidated importsMaximum throughputBulk loading into a DB

Decision Matrix

Your situationPickWhy
Import a customer file with 40 columns and validation rulesCsvHelperClass maps + converters give per-column errors
Parse 10 GB of telemetry CSVs nightlySepAllocation-free; GC pressure is the real bottleneck
Load a CSV into PostgreSQL/SQL ServerSylvan.Data.CsvDbDataReader → native bulk copy
Mixed delimiters and European decimalsCsvHelperCulture and delimiter config are explicit
Library must work on .NET Framework 4.8CsvHelper or SylvanSep’s fast path targets modern .NET
You need to report the exact bad row and columnCsvHelperBadDataFound gives you parser context

CsvHelper — The One You Already Know

CsvHelper’s strength is that it treats a CSV as a typed data source rather than a stream of strings. A class map declares how columns become properties, and every mismatch becomes a diagnosable error.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
using CsvHelper;
using CsvHelper.Configuration;
using System.Globalization;

var config = new CsvConfiguration(CultureInfo.InvariantCulture)
{
    Delimiter = ";",
    HasHeaderRecord = true,
    TrimOptions = TrimOptions.Trim,
    BadDataFound = args =>
        Console.Error.WriteLine($"Bad data row {args.Context.Parser.Row}: {args.Field}"),
    MissingFieldFound = null
};

using var reader = new StreamReader("orders.csv");
using var csv = new CsvReader(reader, config);
csv.Context.RegisterClassMap<OrderMap>();

var orders = csv.GetRecords<Order>().ToList();

public sealed class OrderMap : ClassMap<Order>
{
    public OrderMap()
    {
        Map(m => m.Id).Name("order_id");
        Map(m => m.Customer).Name("customer_name");
        Map(m => m.Total).Name("total");
        Map(m => m.CreatedAt).Name("created_at")
            .TypeConverterOption.Format("yyyy-MM-dd HH:mm:ss");
    }
}

Where it wins: culture handling is explicit rather than accidental, BadDataFound gives you the row number, and PrepareHeaderForMatch fixes the “column named Order ID with a trailing space” problem that breaks naive parsers. Errors point at a column, not at “something went wrong on line 47,283”.

Where it costs you: GetRecords<T>() builds objects and strings per row. On a 10-million-row file that is tens of millions of short-lived allocations, and Gen0 collections become the bottleneck rather than I/O. Also note that GetRecords<T>() is lazy — if you return it from a method that disposes the reader, you get ObjectDisposedException at enumeration time. Materialize it or yield from inside the using scope.

Sep — Zero Allocation, Maximum Throughput

Sep’s premise is that CSV parsing should not allocate. Rows are slices of a reused buffer, columns are ReadOnlySpan<char>, and numbers are parsed directly from spans rather than from strings.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
using var reader = Sep.New(',').Reader().FromFile("orders.csv");

foreach (var row in reader)
{
    if (!row.TryParse<int>("order_id", out var id)) continue;

    var customer = row["customer_name"].ToString();
    var total = row["total"].Parse<decimal>();

    // process without materializing a record object
}

For header-driven pipelines Sep is convenient: row["column"] resolves by name once and then by index, and Parse<T> reads directly from the span.

Where it wins: allocations stay flat as file size grows. Where CsvHelper’s GC time scales with row count, Sep’s does not — which is exactly what you want in a nightly batch job that runs alongside other workloads in the same container.

Where it costs you: SepReader is a ref struct. It cannot be stored in a field, captured by a lambda that outlives the loop, or held across an await. That means your parsing loop must be synchronous — push results to a Channel<T> and let an async consumer handle the database writes. Sep also takes a firm stance on malformed input (see its RFC-4180 notes), so files with inconsistent column counts need explicit options rather than silent tolerance.

Sylvan.Data.Csv — CSV as a Data Reader

Sylvan’s design decision is the most interesting of the three: a CSV file is a table, so expose it as DbDataReader. Everything that accepts a data reader — bulk copy, Dapper, schema inference, ETL frameworks — then accepts a CSV file for free.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
using Sylvan.Data.Csv;

using var textReader = File.OpenText("orders.csv");
using var csv = CsvDataReader.Create(textReader);

// Direct bulk load: no POCO, no List<T>, no intermediate copy
using var connection = new NpgsqlConnection(connectionString);
await connection.OpenAsync();

using var importer = await connection.BeginBinaryImportAsync(
    "COPY orders (id, customer, total) FROM STDIN (FORMAT BINARY)");

while (await csv.ReadAsync())
{
    await importer.StartRowAsync();
    await importer.WriteAsync(csv.GetInt32(0));
    await importer.WriteAsync(csv.GetString(1));
    await importer.WriteAsync(csv.GetDecimal(2));
}

await importer.CompleteAsync();

You can also supply a schema when the file has no header, or when types need to be coerced:

1
2
var schema = CsvSchema.Nullable;
using var typed = CsvDataReader.Create(textReader, new CsvDataReaderOptions { Schema = schema });

Where it wins: memory stays bounded regardless of file size, and the ADO.NET surface means SqlBulkCopy.WriteToServer(csv) — the fastest path into SQL Server — works with zero glue code. For “load this file into the warehouse” jobs, this is the shortest correct implementation.

Where it costs you: positional column access (GetInt32(0)) is brittle when the upstream file adds a column. Build an index from the header row once and use named lookups, or you will ship an off-by-one bug the first time a column is inserted.

Running It in a Container

A dedicated import worker keeps parsing off your API nodes. The important detail is the memory limit: it forces you to find out whether your parser streams or buffers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/Importer/Importer.csproj -c Release -o /app/publish

FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app/publish .
ENV DOTNET_GCHeapHardLimit=0x20000000
ENTRYPOINT ["dotnet", "Importer.dll"]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
services:
  importer:
    build: .
    mem_limit: 1g
    volumes:
      - ./incoming:/data/incoming:ro
    environment:
      ConnectionStrings__Warehouse: "Host=postgres;Database=warehouse;Username=etl;Password=etl"
      File__Path: /data/incoming/orders.csv
    depends_on:
      postgres:
        condition: service_healthy

The DOTNET_GCHeapHardLimit of 512 MB and the mem_limit: 1g are deliberate: with a streaming parser the job completes; with GetRecords<T>().ToList() it throws OutOfMemoryException on the first large file instead of getting OOM-killed by the kernel with no diagnostic.

Benchmark It Yourself

Published throughput numbers are unreliable across hardware, so measure on your data. A BenchmarkDotNet harness takes ten minutes to write and settles the question permanently:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using BenchmarkDotNet.Attributes;

[MemoryDiagnoser]
public class CsvBenchmarks
{
    private const string Path = "orders-1m.csv";

    [Benchmark(Baseline = true)]
    public long CsvHelper_ClassMap()
    {
        using var reader = new StreamReader(Path);
        using var csv = new CsvReader(reader, CultureInfo.InvariantCulture);
        csv.Context.RegisterClassMap<OrderMap>();
        long sum = 0;
        foreach (var order in csv.GetRecords<Order>()) sum += order.Id;
        return sum;
    }

    [Benchmark]
    public long Sep_RowSpan()
    {
        using var reader = Sep.New(',').Reader().FromFile(Path);
        long sum = 0;
        foreach (var row in reader) sum += row["order_id"].Parse<long>();
        return sum;
    }

    [Benchmark]
    public long Sylvan_DataReader()
    {
        using var tr = File.OpenText(Path);
        using var csv = CsvDataReader.Create(tr);
        long sum = 0;
        while (csv.Read()) sum += csv.GetInt64(0);
        return sum;
    }
}

Watch the Allocated column, not only Mean. In practice the ordering of means depends on your disk and CPU, while the allocation ratio between CsvHelper and the span-based parsers stays roughly constant — and allocation is what determines whether your container survives.

Pitfalls That Cause Real Incidents

The UTF-8 BOM breaks header matching. Excel writes a BOM; a byte-level parser sees it as part of the first header name, so row["order_id"] returns null while row["order_id"] works. Strip the BOM or configure encoding explicitly.

European CSVs use ; and , decimals. A file from a German ERP has semicolon delimiters and 1.234,56 amounts. Parsing that with InvariantCulture silently produces 1234.56 or throws. Detect the delimiter and culture from the file’s source metadata — never guess per row.

GetRecords<T>() is lazy. Returning it from a method that closes the reader throws later, far from the cause. Materialize inside the using block or yield return from within it.

Sep’s reader cannot cross await. A ref struct cannot live in an async state machine. Wrap the parse loop in a synchronous method and hand results to a channel — otherwise you will “fix” it by adding .ToList(), which defeats the entire point.

Quoted fields with embedded newlines are where naive splitting fails. All three handle RFC 4180 correctly; hand-rolled line.Split(',') does not. If a support ticket says “one record became three rows”, this is why.

Positional access is a time bomb. csv.GetInt32(0) silently reads a different column when the upstream schema changes. Resolve ordinals from the header at startup and fail loudly if a required column is missing.

Always cap the file size and stream. Even a fast parser will happily consume a 40 GB file. Enforce a maximum row count per job and process in chunks so a runaway import cannot take down the host.

FAQ

Which .NET CSV parser is the fastest?

Sep is designed for maximum throughput with zero allocation, and its published benchmarks claim the top spot among .NET CSV parsers. Sylvan.Data.Csv is typically very close for reader-style access. CsvHelper is slower and allocates more, because it builds typed objects and strings. Benchmark on your own files — the ratio depends heavily on field count, quoting density, and whether you need objects at all.

Can I parse CSV without loading the whole file into memory?

Yes, all three stream. CsvHelper streams lazily through GetRecords<T>() (as long as you do not call ToList()), Sep streams row by row, and Sylvan streams through Read(). The memory blow-up almost always comes from materializing results, not from the parser.

Does Sep work with .NET Framework?

The zero-allocation fast path depends on modern runtime features and C# 13 language support, so it targets current .NET versions. If you are stuck on .NET Framework 4.8, use CsvHelper or Sylvan.Data.Csv — both support older targets.

How do I load a CSV into SQL Server or PostgreSQL quickly?

Use Sylvan.Data.Csv. Because CsvDataReader is a DbDataReader, SqlBulkCopy.WriteToServer(csv) works directly, and PostgreSQL’s binary COPY accepts the same reader loop. This avoids building a List<T> and is usually several times faster than inserting row by row.

Why does my decimal parse fail only for some customers?

Because the file’s culture differs from the parser’s. 1,234.56 and 1.234,56 are the same number in different locales. Set CultureInfo from the file’s origin (or a per-tenant configuration), and validate a sample of parsed values before committing the import.

Can these libraries run in a trimmed or AOT-published container?

Yes. All three are reflection-free for their core read paths, and Sep in particular is span-based with no reflection. CsvHelper’s class maps use some reflection and configuration at startup, so verify after enabling trimming if you use PublishTrimmed.

For related reading, see our .NET Excel library comparison for spreadsheet-sized data, the Dapper vs EF Core vs NHibernate ORM guide for what to do with the rows once parsed, and the embedded database comparison if the destination is a local store rather than a server.


💰 想测试你的市场判断力?我用 Polymarket 做预测市场交易——这是全球最大的预测市场平台,从大选结果到技术监管时间线,什么都可以押注。和赌博不同,这是真正的信息市场:你懂的信息越多,胜率越高。我靠预测技术相关事件的走向已经赚了不少。用我的邀请链接注册:Polymarket.com