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
| Dimension | CsvHelper | Sep | Sylvan.Data.Csv |
|---|---|---|---|
| GitHub stars | 5,214 | 1,481 | 456 (Sylvan monorepo) |
| License | Apache 2.0 / MS-PL | MIT | MIT |
| Last push | 2026-10-28 | 2026-10-19 | 2026-04-07 |
| API model | CsvReader + class maps | ref struct row/column spans | DbDataReader |
| Allocation profile | Per-field strings | Near-zero (spans) | Low (buffer reuse) |
| Column access | By name, typed | By name, Parse<T>() | By ordinal, typed getters |
| Header/name mapping | Full (maps, converters) | Header row aware | Optional schema |
| Quoted newlines | Yes | Yes (configurable) | Yes |
| Multiline + encoding edge cases | Most battle-tested | Good, opinionated | Good |
| Async-friendly | Yes | No (ref struct) | Yes (ADO.NET surface) |
| Best at | Validated imports | Maximum throughput | Bulk loading into a DB |
Decision Matrix
| Your situation | Pick | Why |
|---|---|---|
| Import a customer file with 40 columns and validation rules | CsvHelper | Class maps + converters give per-column errors |
| Parse 10 GB of telemetry CSVs nightly | Sep | Allocation-free; GC pressure is the real bottleneck |
| Load a CSV into PostgreSQL/SQL Server | Sylvan.Data.Csv | DbDataReader → native bulk copy |
| Mixed delimiters and European decimals | CsvHelper | Culture and delimiter config are explicit |
| Library must work on .NET Framework 4.8 | CsvHelper or Sylvan | Sep’s fast path targets modern .NET |
| You need to report the exact bad row and column | CsvHelper | BadDataFound 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.
| |
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.
| |
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.
| |
You can also supply a schema when the file has no header, or when types need to be coerced:
| |
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.
| |
| |
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:
| |
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