CSV Read and Write
The Pragmatic.Documents.Csv package reads and writes CSV files with RFC 4180 semantics plus locale variants. It shares the SpreadsheetModel with XLSX, so roundtrips are idempotent.
An optional source generator (Pragmatic.Documents.Csv.Generator) produces AOT-safe typed serialisers for your record types.
API surface
Section titled “API surface”Writer
Section titled “Writer”public static class CsvWriter{ public static void Write(Stream stream, IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string?>> rows, CsvOptions? options = null);
public static void Write(Stream stream, SpreadsheetModel model, CsvOptions? options = null); public static void Write(Stream stream, Sheet sheet, CsvOptions? options = null);
public static byte[] WriteToArray(IReadOnlyList<string> headers, IReadOnlyList<IReadOnlyList<string?>> rows, CsvOptions? options = null);}Reader
Section titled “Reader”public static class CsvReader{ public static SpreadsheetModel ReadAsModel(Stream stream, string sheetName = "Sheet1", CsvOptions? options = null); public static SpreadsheetModel ReadAsModel(byte[] data, string sheetName = "Sheet1", CsvOptions? options = null);}Simple write
Section titled “Simple write”using Pragmatic.Documents.Csv;
using var fs = File.Create("guests.csv");CsvWriter.Write(fs, headers: ["Id", "First", "Last"], rows: new[] { new[] { "1", "Ada", "Lovelace" }, new[] { "2", "Grace", "Hopper" }, });Output (RFC 4180, CRLF line endings, UTF-8 no BOM by default):
Id,First,Last1,Ada,Lovelace2,Grace,HopperWrite from a SpreadsheetModel
Section titled “Write from a SpreadsheetModel”using Pragmatic.Documents.Spreadsheet;using Pragmatic.Documents.Csv;
var book = new SpreadsheetBuilder() .Sheet("Guests", s => s .HeaderRow("Id", "First", "Last") .Row(1, "Ada", "Lovelace") .Row(2, "Grace", "Hopper")) .Build();
using var fs = File.Create("guests.csv");CsvWriter.Write(fs, book); // exports the first sheetFor multi-sheet workbooks, CSV can only carry one sheet — write each sheet to its own file, or use XLSX instead.
Options
Section titled “Options”var italian = new CsvOptions{ Delimiter = ';', // Italian Excel default DecimalSeparator = ',', // 1,5 instead of 1.5 QuoteChar = '"', LineEnding = "\r\n", // CRLF (default); "\n" for Unix Encoding = Encoding.UTF8, // default AlwaysQuote = false, // quote only when needed WriteByteOrderMark = false, // UTF-8 no BOM (default)};
CsvWriter.Write(fs, book, italian);The reader accepts the same CsvOptions so you can round-trip locale-specific CSVs without surprises.
Formula-injection hardening
Section titled “Formula-injection hardening”CSV opened in Excel can execute formulas if a cell starts with =, +, -, @, or tab. This is a known attack vector.
Enable hardening to prefix unsafe leading characters with an apostrophe:
var safe = new CsvOptions { HardenAgainstFormulaInjection = true };CsvWriter.Write(fs, book, safe);When enabled:
=SUM(A1)is written as'=SUM(A1)(leading apostrophe forces text interpretation in Excel)- Plain data and legitimate negative numbers are unaffected
Use this for any CSV that goes to an untrusted user.
Reading CSV
Section titled “Reading CSV”using var fs = File.OpenRead("guests.csv");var book = CsvReader.ReadAsModel(fs, sheetName: "Guests");
var sheet = book.Sheets[0];foreach (var row in sheet.Rows){ var cells = row.Cells.Select(c => c.Value?.ToString()); Console.WriteLine(string.Join(" | ", cells));}Reading with a locale
Section titled “Reading with a locale”var options = new CsvOptions { Delimiter = ';', DecimalSeparator = ',' };using var fs = File.OpenRead("it-export.csv");var book = CsvReader.ReadAsModel(fs, options: options);What the reader handles
Section titled “What the reader handles”- RFC 4180 quoting (
"embedded ""quote""", embedded commas, embedded newlines) - CRLF, LF, CR line endings
- UTF-8 with or without BOM
- Empty rows (skipped)
- Leading/trailing whitespace preserved inside quoted fields, stripped in unquoted fields (RFC behaviour)
What it doesn’t do:
- Auto-detect delimiter — specify
CsvOptions.Delimiterif it’s not, - Infer column types — everything comes back as
stringinCell.Value. If you need typed values, convert after reading.
Typed serialisation with the source generator
Section titled “Typed serialisation with the source generator”dotnet add package Pragmatic.Documents.Csv.Generatorusing Pragmatic.Documents.Csv;
[CsvSerializable<Guest>]public partial class GuestCsv { }
public record Guest(int Id, string First, string Last, DateTime CheckIn);The generator emits partial methods:
// Generated at compile time — zero reflectionpublic partial class GuestCsv{ public static void Write(Stream stream, IEnumerable<Guest> rows, CsvOptions? options = null); public static IEnumerable<Guest> Read(Stream stream, CsvOptions? options = null);}Use it:
var guests = new[]{ new Guest(1, "Ada", "Lovelace", new DateTime(2026, 4, 1)), new Guest(2, "Grace", "Hopper", new DateTime(2026, 4, 2)),};
using (var fs = File.Create("guests.csv")) GuestCsv.Write(fs, guests);
using (var fs = File.OpenRead("guests.csv")) foreach (var g in GuestCsv.Read(fs)) Console.WriteLine($"{g.Id}: {g.First} {g.Last}");Types supported out of the box: int, long, double, decimal, string, bool, DateTime, DateOnly, TimeOnly, Guid, and their nullable variants. Enums are written as their display string.
For custom types, implement ICsvField<T> and wire it with a [CsvField<T, TConverter>] attribute on the property.
Performance
Section titled “Performance”- Writing is single-pass, constant-memory (reads headers + rows, writes as it goes).
- Reading materialises the full model in memory by default. For very large files, use the source-generator path which reads row-by-row.
- Typical throughput: 500k rows/s write, 300k rows/s read on modern hardware for 8-column records.
Limitations
Section titled “Limitations”- No multi-sheet support (CSV is inherently single-sheet).
- No cell styling, formulas, or column widths — use XLSX if those matter.
- No delimiter auto-detection.
Related
Section titled “Related”- xlsx-rendering.md — same model, full-featured output
Pragmatic.Documents.Csv.Samples— basic roundtrip, locale, formula injection, edge cases