CsvToolkit.Core is a high-performance CSV library targeting netstandard2.1, focused on streaming and low allocations with Span<T>, Memory<T>, and ArrayPool<T>.
In the latest focused manual-read benchmark, CsvToolkit.Core now beats Sep in 6/6 read scenarios. At 100k rows, manual reads are 14.190 ms vs 21.938 ms for default CSV, 15.873 ms vs 25.877 ms for semicolon/high-quote CSV, and 7.179 ms vs 11.648 ms for converter-options data. The older full-suite typed POCO table is retained below separately for broader context.
Package name on NuGet.org: CsvToolkit.Core
dotnet add package CsvToolkit.CoreThis project is licensed under the MIT License. See LICENSE.
var options = new CsvOptions
{
DelimiterString = ",", // supports multi-character delimiters too
DetectDelimiter = false,
DelimiterCandidates = new[] { ",", ";", "\t", "|" },
HasHeader = true,
Quote = '"',
Escape = '"',
TrimOptions = CsvTrimOptions.Trim,
DetectColumnCount = true,
ReadMode = CsvReadMode.Strict,
CultureInfo = CultureInfo.InvariantCulture,
PrepareHeaderForMatch = static (header, _) => header.Trim().ToLowerInvariant(),
SanitizeForInjection = true
};
using var reader = new CsvReader(streamOrTextReader, options);
using var writer = new CsvWriter(streamOrTextWriter, options);
// Row/field iteration
while (reader.TryReadRow(out _))
{
int id = reader.GetInt32("id");
ReadOnlySpan<char> name = reader.GetFieldSpan(1);
ReadOnlySpan<char> email = reader.GetFieldSpan("email");
string materialized = reader.GetField("email");
}
// Manual mapping for throughput-sensitive paths
var idIndex = reader.GetFieldIndex("id");
var nameIndex = reader.GetFieldIndex("name");
var state = (IdIndex: idIndex, NameIndex: nameIndex, Items: new List<MyRow>());
reader.ReadRows(state, static (csv, s) =>
{
s.Items.Add(new MyRow
{
Id = csv.GetInt32(s.IdIndex),
Name = csv.GetField(s.NameIndex)
});
});
// Dictionary / dynamic
if (reader.TryReadDictionary(out var dict)) { /* header -> value */ }
if (reader.TryReadDynamic(out dynamic dyn)) { /* ExpandoObject */ }
// Strongly typed POCO
while (reader.TryReadRecord<MyRow>(out var record)) { }
writer.WriteHeader<MyRow>();
writer.WriteRecord(new MyRow());
var records = new List<MyRow> { new MyRow() };
writer.WriteRecords(records, writeHeader: false);
writer.WriteField("manual".AsSpan());
writer.WriteField("row".AsSpan());
writer.NextRecord();
// Async
while (await reader.ReadAsync()) { var current = reader.GetRecord<MyRow>(); }
await reader.ReadRecordAsync<MyRow>();
await foreach (var row in reader.GetRecordsAsync<MyRow>()) { }
await writer.WriteRecordAsync(new MyRow());
await writer.WriteRecordsAsync(records, writeHeader: true);
// ADO.NET adapter
using var dataReader = reader.AsDataReader();- Read from
TextReaderand UTF-8Stream - Write to
TextWriterand UTF-8Stream - Streaming row-by-row parser (no full-file load)
- Quoted fields, escaped quotes, delimiters inside quotes, CRLF/LF handling
- Header support, delimiter/quote/escape/newline configuration
- Multi-character delimiters via
DelimiterString - Optional delimiter auto-detection via
DetectDelimiter+DelimiterCandidates - Trim options and strict/lenient error handling
- Error and validation callbacks:
BadDataFound,MissingFieldFound,HeaderValidated,ReadingExceptionOccurred - Header normalization callback:
PrepareHeaderForMatch - Field access as
ReadOnlySpan<char>/ReadOnlyMemory<char> - Name-based low-level field access:
GetFieldSpan(name),GetFieldMemory(name),GetFieldIndex(name, nameIndex) - Fast typed manual access:
GetInt32(...),GetDecimal(...),GetDateTime(...),GetBoolean(...), and nullable variants - Projection-style manual reads via
ReadRows(state, static (reader, state) => ...) - Typed field access helpers:
GetField<T>(index)/GetField<T>(name, nameIndex) - Reader APIs:
TryReadRow,ReadAsync,TryReadDictionary,ReadDictionaryAsync,TryReadDynamic - Record APIs:
TryReadRecord<T>,ReadRecordAsync<T>,GetRecords<T>,GetRecordsAsync<T> CsvDataReaderadapter viaAsDataReader()- POCO mapping with:
- Attributes:
[CsvColumn],[CsvIndex],[CsvIgnore],[CsvNameIndex],[CsvOptional],[CsvDefault],[CsvConstant],[CsvValidate] - Converter option attributes:
[CsvNullValues],[CsvTrueValues],[CsvFalseValues],[CsvFormats],[CsvNumberStyles],[CsvDateTimeStyles],[CsvCulture] - Fluent mapping:
CsvMapRegistry.Register<T>(...)withOptional,Default,Constant,Validate,NameIndex, member converter options - Constructor-based record materialization (immutable / constructor-only models)
- Attributes:
- Type conversion:
- primitives, enums, nullable,
DateTime,DateOnly,TimeOnly,Guid - culture-aware parsing/formatting
- custom converters (
ICsvTypeConverter<T>) - global converter options per type via
CsvOptions.ConverterOptions
- primitives, enums, nullable,
- Writer bulk APIs:
WriteRecords(...),WriteRecordsAsync(...) - Manual writer APIs:
WriteField(ReadOnlySpan<char>),WriteField<T>(...),NextRecord() - CSV injection sanitization for spreadsheet-safe output (
SanitizeForInjection)
using var reader = new CsvReader(new StreamReader("people.csv"));
while (reader.TryReadRecord<Person>(out var person))
{
Console.WriteLine(person.Name);
}using var writer = new CsvWriter(File.Create("people.csv"), new CsvOptions { NewLine = "\n" });
writer.WriteHeader<Person>();
foreach (var person in people)
{
writer.WriteRecord(person);
}var idIndex = reader.GetFieldIndex("id");
var nameIndex = reader.GetFieldIndex("name");
reader.ReadRows((IdIndex: idIndex, NameIndex: nameIndex), static (csv, state) =>
{
int id = csv.GetInt32(state.IdIndex);
ReadOnlySpan<char> name = csv.GetFieldSpan(state.NameIndex);
});using var writer = new CsvWriter(File.Create("people.csv"), new CsvOptions { NewLine = "\n" });
writer.WriteField("id".AsSpan());
writer.WriteField("name".AsSpan());
writer.NextRecord();
foreach (var person in people)
{
Span<char> idBuffer = stackalloc char[16];
person.Id.TryFormat(idBuffer, out var written, default, CultureInfo.InvariantCulture);
writer.WriteField(idBuffer[..written]);
writer.WriteField(person.Name.AsSpan());
writer.NextRecord();
}var maps = new CsvMapRegistry();
maps.Register<Person>(map =>
{
map.Map(x => x.Id).Name("person_id");
map.Map(x => x.Name).Name("full_name");
});public sealed class PersonRow
{
[CsvColumn("name"), CsvNameIndex(0)]
public string FirstName { get; set; } = string.Empty;
[CsvColumn("name"), CsvNameIndex(1)]
public string LastName { get; set; } = string.Empty;
[CsvOptional, CsvDefault(18)]
public int Age { get; set; }
[CsvConstant("US")]
public string Country { get; set; } = string.Empty;
[CsvValidate(nameof(IsValidAge), Message = "Age must be >= 0.")]
public int CheckedAge { get; set; }
[CsvTrueValues("Y"), CsvFalseValues("N")]
public bool Active { get; set; }
[CsvFormats("dd-MM-yyyy"), CsvCulture("en-GB")]
public DateTime CreatedAt { get; set; }
[CsvNullValues("NULL")]
public decimal? Score { get; set; }
private static bool IsValidAge(int value) => value >= 0;
}For read/write conversion options, resolution order is:
- Member-level fluent options (
map.Map(...).TrueValues(...).Formats(...)) - Member-level attribute options (
[CsvTrueValues],[CsvFormats], etc.) - Global type options (
options.ConverterOptions.Configure<T>(...)) - Built-in defaults
Notes:
- Fluent member options override attribute options for the same member.
- Attribute/member options are isolated to that mapped member and do not affect other properties of the same type.
ConverterOptionsprecedence applies to bothCsvReaderandCsvWriter.
Benchmarks compare CsvToolkit.Core with CsvHelper and Sep for:
- Typed read/write (default mapping)
- Manual mapped read/write using
ReadRows,TryReadRow,GetFieldIndex,GetFieldSpan, andWriteField(ReadOnlySpan<char>) - Typed read/write with converter options
- Async typed read/write for stream-backed APIs
- Typed read with duplicate headers (
NameIndex) - Dictionary/dynamic read
- Semicolon + high quoting parse
- Dataset sizes:
10kand100krows
If you want deeper implementation details and rationale, start here:
- Technical Architecture: components, data flow, and technologies used.
- Performance Design Decisions: why hot paths are fast and the tradeoffs behind each optimization.
Run all benchmarks non-interactively:
dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks -- --filter "*CsvReadWriteBenchmarks*"Run one benchmark (faster while iterating):
dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks -- --filter "*CsvReadWriteBenchmarks.CsvToolkitCore_ReadTyped_Stream*"Run manual mapping vs Sep-focused benchmarks:
dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks -- --anyCategories SepCompareRun only manual read benchmarks in that comparison set:
dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks -- --allCategories ManualRead SepCompareRun the async stream-focused benchmarks:
dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks -- --filter "*CsvReadWriteBenchmarks.CsvToolkitCore_*Async*"Run from IDE:
- Project:
benchmarks/CsvToolkit.Benchmarks - Configuration:
Release - Program arguments:
--filter "*CsvReadWriteBenchmarks*"
If you run without --filter, BenchmarkDotNet enters interactive selection mode and waits for input.
Benchmark dataset generation is deterministic (Random seed-based) inside benchmark setup.
Run date: 2026-03-07
Machine: Apple M3 Pro (11 logical / 11 physical cores)
OS: macOS Tahoe 26.3 (25D125) [Darwin 25.3.0]
Runtime: .NET 10.0.0
Command: dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks -- --filter "*CsvReadWriteBenchmarks*"
Note: the table below is the saved full-suite typed POCO run. A newer focused manual mapping vs Sep
run is included after the common scenario table.
Common scenarios benchmarked across all three libraries:
| Scenario | RowCount | CsvToolkit.Core (Mean / Alloc) | CsvHelper (Mean / Alloc) | Sep (Mean / Alloc) | Time Winner | Allocation Winner |
|---|---|---|---|---|---|---|
| ReadTyped | 10,000 | 3.907 ms / 0.99 MB | 4.879 ms / 3.76 MB | 2.097 ms / 0.92 MB | Sep (1.86x vs CsvToolkit) |
Sep (1.08x lower vs CsvToolkit) |
| WriteTyped | 10,000 | 2.038 ms / 1.05 MB | 2.936 ms / 3.45 MB | 1.145 ms / 2.01 MB | Sep (1.78x) |
CsvToolkit (1.92x lower vs Sep) |
| ReadTyped_SemicolonHighQuote | 10,000 | 4.661 ms / 0.99 MB | 5.292 ms / 3.76 MB | 2.310 ms / 0.92 MB | Sep (2.02x) |
Sep (1.08x lower) |
| ReadTyped_WithConverterOptions | 10,000 | 2.606 ms / 1.33 MB | 3.193 ms / 2.67 MB | 1.088 ms / 0.39 MB | Sep (2.40x) |
Sep (3.43x lower) |
| WriteTyped_WithConverterOptions | 10,000 | 2.197 ms / 0.54 MB | 2.434 ms / 2.46 MB | 0.746 ms / 0.70 MB | Sep (2.94x) |
CsvToolkit (1.29x lower vs Sep) |
| ReadTyped | 100,000 | 30.120 ms / 9.30 MB | 46.449 ms / 36.72 MB | 21.639 ms / 9.23 MB | Sep (1.39x) |
Sep (1.01x lower) |
| WriteTyped | 100,000 | 17.117 ms / 16.05 MB | 22.847 ms / 39.40 MB | 12.028 ms / 16.01 MB | Sep (1.42x) |
Sep (1.00x lower) |
| ReadTyped_SemicolonHighQuote | 100,000 | 32.651 ms / 9.30 MB | 49.259 ms / 36.72 MB | 23.793 ms / 9.23 MB | Sep (1.37x) |
Sep (1.01x lower) |
| ReadTyped_WithConverterOptions | 100,000 | 23.674 ms / 12.80 MB | 27.904 ms / 25.81 MB | 10.971 ms / 3.82 MB | Sep (2.16x) |
Sep (3.35x lower) |
| WriteTyped_WithConverterOptions | 100,000 | 14.878 ms / 8.04 MB | 19.270 ms / 26.60 MB | 7.409 ms / 9.93 MB | Sep (2.01x) |
CsvToolkit (1.24x lower vs Sep) |
Focused manual mapping vs Sep run:
Run date: 2026-05-15
Runtime: .NET 10.0.5
Command: dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks/CsvToolkit.Benchmarks.csproj -- --anyCategories SepCompare
Read rows were refreshed after adding the no-copy buffered row fast path, ReadRows, typed manual getters, and span-based custom parsers with:
dotnet run -c Release --project benchmarks/CsvToolkit.Benchmarks/CsvToolkit.Benchmarks.csproj -- --allCategories ManualRead SepCompare.
| Scenario | RowCount | CsvToolkit Manual (Mean / Alloc) | Sep (Mean / Alloc) | Time Winner | Allocation Winner |
|---|---|---|---|---|---|
| ReadTyped_ManualMapping | 10,000 | 1.402 ms / 398.01 KB | 2.213 ms / 941.98 KB | CsvToolkit (1.58x) |
CsvToolkit (2.37x lower) |
| ReadTyped_ManualMapping_SemicolonHighQuote | 10,000 | 1.616 ms / 398.03 KB | 2.403 ms / 941.98 KB | CsvToolkit (1.49x) |
CsvToolkit (2.37x lower) |
| ReadTyped_ManualMapping_WithConverterOptions | 10,000 | 0.677 ms / 7.30 KB | 1.142 ms / 395.09 KB | CsvToolkit (1.69x) |
CsvToolkit (54.12x lower) |
| WriteTyped_ManualMapping | 10,000 | 1.700 ms / 2,038.09 KB | 1.312 ms / 2,054.48 KB | Sep (1.30x) |
CsvToolkit (1.01x lower) |
| WriteTyped_ManualMapping_WithConverterOptions | 10,000 | 1.142 ms / 501.75 KB | 0.780 ms / 715.11 KB | Sep (1.46x) |
CsvToolkit (1.43x lower) |
| ReadTyped_ManualMapping | 100,000 | 14.190 ms / 3,984.52 KB | 21.938 ms / 9,450.38 KB | CsvToolkit (1.55x) |
CsvToolkit (2.37x lower) |
| ReadTyped_ManualMapping_SemicolonHighQuote | 100,000 | 15.873 ms / 3,984.55 KB | 25.877 ms / 9,450.38 KB | CsvToolkit (1.63x) |
CsvToolkit (2.37x lower) |
| ReadTyped_ManualMapping_WithConverterOptions | 100,000 | 7.179 ms / 7.30 KB | 11.648 ms / 3,910.72 KB | CsvToolkit (1.62x) |
CsvToolkit (535.71x lower) |
| WriteTyped_ManualMapping | 100,000 | 17.272 ms / 16,373.89 KB | 13.091 ms / 16,390.40 KB | Sep (1.32x) |
CsvToolkit (1.00x lower) |
| WriteTyped_ManualMapping_WithConverterOptions | 100,000 | 11.677 ms / 8,181.85 KB | 8.335 ms / 10,167.00 KB | Sep (1.40x) |
CsvToolkit (1.24x lower) |
Focused manual mapping takeaway:
- CsvToolkit now wins all
6/6focused manual read scenarios againstSep. - CsvToolkit read allocations are lower in all focused manual read scenarios because simple unquoted rows can reuse input-buffer slices.
Sepremains faster in the focused manual write rows, while CsvToolkit has lower write allocations.
Additional scenarios:
| Scenario | RowCount | CsvToolkit.Core (Mean / Alloc) | CsvHelper (Mean / Alloc) | Time Winner | Allocation Winner |
|---|---|---|---|---|---|
| ReadTyped_DuplicateHeader_NameIndex | 10,000 | 0.927 ms / 1.17 MB | 1.605 ms / 1.78 MB | CsvToolkit (1.73x) |
CsvToolkit (1.51x lower) |
| ReadTyped_DuplicateHeader_NameIndex | 100,000 | 8.585 ms / 12.23 MB | 14.277 ms / 17.64 MB | CsvToolkit (1.66x) |
CsvToolkit (1.44x lower) |
| ReadDictionary vs ReadDynamic | 10,000 | 1.762 ms / 5.26 MB | 4.312 ms / 8.00 MB | CsvToolkit (2.45x) |
CsvToolkit (1.52x lower) |
| ReadDictionary vs ReadDynamic | 100,000 | 18.355 ms / 52.64 MB | 43.219 ms / 79.41 MB | CsvToolkit (2.35x) |
CsvToolkit (1.51x lower) |
Observed trend:
- In the older full common-scenario run shown above,
Sepis the fastest option in10/10. - In that full run,
Sepwins allocations in7/10common scenarios andCsvToolkit.Corewins3/10, all on typed write scenarios. - In that full run,
CsvToolkit.CorebeatsCsvHelperin10/10common scenarios. - In the refreshed focused manual read comparison,
CsvToolkit.CorebeatsSepin6/6read scenarios. - In the extra
DuplicateHeader_NameIndexscenario,CsvToolkit.CorebeatsCsvHelperat both sizes. - In the extra
ReadDictionary vs ReadDynamicscenario,CsvToolkit.CorebeatsCsvHelperat both sizes. - Important caveat for the full-suite table:
Sepis benchmarked there through explicit/manual column mapping and writing, while the listedCsvToolkit.Corerows use higher-level typed POCO APIs. Use the focused manual mapping table for the direct low-level comparison.
Benchmark run time:
- Benchmark execution:
00:14:31(871.21 sec) - Global total:
00:14:37(877.11 sec)
Benchmark artifacts:
- Generated local output:
BenchmarkDotNet.Artifacts/results/