Skip to content

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.


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);
}
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);
}

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,Last
1,Ada,Lovelace
2,Grace,Hopper

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 sheet

For multi-sheet workbooks, CSV can only carry one sheet — write each sheet to its own file, or use XLSX instead.


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.


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.


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));
}
var options = new CsvOptions { Delimiter = ';', DecimalSeparator = ',' };
using var fs = File.OpenRead("it-export.csv");
var book = CsvReader.ReadAsModel(fs, options: options);
  • 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.Delimiter if it’s not ,
  • Infer column types — everything comes back as string in Cell.Value. If you need typed values, convert after reading.

Typed serialisation with the source generator

Section titled “Typed serialisation with the source generator”
Terminal window
dotnet add package Pragmatic.Documents.Csv.Generator
using 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 reflection
public 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.


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

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