CSV
Reading, dialect sniffing and writing delimited text. Parallel CSV parsing is covered in reading.md.
Read CSV
CsvReader is a workbook with exactly one, unnamed sheet (csv.FirstSheet, the same sheet as csv.Sheets[0]). It streams RFC 4180 CSV (quoted fields, embedded delimiters/newlines, ""-escaped quotes) through the same Row/Cell model as the Excel workbooks, so ExcelParser<T> works on it unchanged.
using ExcelReader.Core.Parser;
using ExcelReader.Core.Reader;
using var csv = Excel.FromCsvFile("report.csv");
foreach (var row in csv.FirstSheet)
{
Console.WriteLine(row[0].GetString());
}
// Typed parsing works exactly like the Excel workbooks:
foreach (var item in ExcelParser.FromAttributes<ChangeRow>().Parse(csv.FirstSheet))
{
Console.WriteLine($"{item.File}: +{item.LinesAdded}");
}
Excel.FromCsv/FromCsvFile/FromCsvAsync/FromCsvFileAsync mirror the other formats' factory shape and take the same ExcelReaderOptions. Its Csv property, a CsvReaderOptions, changes the delimiter/quote character, supplies a non-UTF-8 Encoding (transcoded to UTF-8 internally), or turns off BOM detection; MaxCellBytes and InternStrings stay on ExcelReaderOptions, as for every format:
var options = new ExcelReaderOptions { Csv = new CsvReaderOptions { Delimiter = (byte)';' } };
using var csv = Excel.FromCsvFile("relatorio.csv", options);
Every CSV cell is text (CellType.ExcelString, or CellType.Empty for a blank field); at the reader level there is no binary numeric or date representation, so Cell.TryGetDateTime/IsDate1904 (always false for CSV) do not apply. The typed parser, however, is CSV-specialized: ExcelParser<T>.Parse(CsvSheet) parses DateTime/DateOnly columns directly from the cell text (ISO or culture format, honoring Culture — e.g. pt-BR 02/07/2026), so no [ExcelConverter] is needed for dates. All the usual attributes work unchanged ([ExcelColumn] aliases, [ExcelRequired], [ExcelConverter]), and a converter still takes precedence over the built-in date parsing. (Holding the sheet as an IExcelSheet instead routes through the generic Excel pipeline, where dates use serial-number semantics — prefer the concrete Parse(CsvSheet) overload for CSV.)
A CsvReader over memory, a file path or a seekable stream allows several enumerators at once, each reading the sheet from the start. Over a seekable stream, every enumerator starts at the position the stream had when the CsvReader was created; where the stream's position ends up afterwards is unspecified, so do not rely on it (a file stream, and a memory stream with an exposed buffer, are read without touching it). A non-seekable stream can be read once: a second enumeration throws InvalidOperationException.
Excel.Open/OpenAsync do not auto-detect CSV — plain text has no magic-byte signature to sniff. Name the format instead: every Open/OpenAsync overload takes an optional ExcelFileFormat, and ExcelReaderOptions.Csv carries the dialect, so a caller that decides the format from an extension or a content-type header configures both families through one options object:
ExcelFileFormat format = Path.GetExtension(path) is ".csv" ? ExcelFileFormat.Csv : ExcelFileFormat.Unknown;
using IExcelWorkbook workbook = Excel.Open(path, format, new ExcelReaderOptions
{
Password = secret, // used by the Excel formats
Csv = CsvReaderOptions.Default with { SniffDialect = true }, // used by the CSV format
});
ExcelFileFormat.Unknown keeps the signature-based detection the no-format overloads do; EncryptedOoxml is a detection result rather than something to open, so passing it throws ArgumentOutOfRangeException.
Sniff a CSV dialect
CsvSniffer.Detect infers the delimiter, quote character, and encoding (from a leading byte-order mark) from a sample of bytes, so a ;-separated pt-BR export or a TSV can be read without the caller knowing the dialect up front. It is deterministic (ties break by candidate order) and never throws on arbitrary input — an indecisive sample returns CsvDialect.Default (comma, ", UTF-8).
using ExcelReader.Core.Reader;
using ExcelReader.Core.Reader.Csv;
CsvDialect dialect = CsvSniffer.DetectFile("export.csv");
using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default.WithDialect(dialect) });
CsvSniffer.Detect takes a span of bytes or a seekable Stream, DetectFile takes a path, and DetectAsync/DetectFileAsync are the async siblings; only the first 64 KiB are examined. The Stream overloads require a seekable source — they read a bounded sample and restore the stream's position — so a non-seekable stream throws ArgumentException; buffer it first, or pass the bytes as a span instead. Pass CsvSnifferOptions to change the candidate delimiters/quotes (and their priority order) or the number of sample lines considered.
CsvReaderOptions.SniffDialect does the sample-then-apply above for you, at the entry point that owns the source — the Excel.FromCsv* factories, and Excel.Open with ExcelFileFormat.Csv. The dialect of a parallel read is the one the CSV workbook was opened with:
using var csv = Excel.FromCsvFile("export.csv", new ExcelReaderOptions { Csv = CsvReaderOptions.Default with { SniffDialect = true } });
An explicitly set Encoding survives sniffing unless the source carries a byte-order mark naming a different one — a sniffed encoding only ever comes from a BOM, so it never silently overrides a caller who knows their file is Windows-1252.
Write CSV
CsvWorkbookWriter emits RFC 4180 CSV: no styles or shared strings, so rows stream straight to the output. A CSV file is a single sheet, so AddSheet is called once (the name is ignored).
using ExcelReader.Core.Writer;
using ExcelReader.Core.Writer.Csv;
using var stream = File.Create("out.csv");
using var workbook = CsvWorkbookWriter.Create(stream);
using var writer = workbook.AddSheet("Sheet1");
using (CsvRowWriter row = writer.StartRow())
{
row.Write("Name");
row.Write("Total");
row.Write("Created");
}
using (CsvRowWriter row = writer.StartRow())
{
row.Write("Q1");
row.Write(42);
row.Write(DateTime.UtcNow);
}
Fields are quoted only when they contain the delimiter, quote character, \r, or \n; embedded quotes are doubled. bool writes as lowercase true/false and DateTime/DateOnly as round-trip ISO 8601 ("O"); TimeOnly as a time-of-day fraction — all matching what ExcelParser<T>.Parse(CsvSheet) expects, so a file written by CsvWorkbookWriter parses back without configuration. Skip(count) writes empty fields to keep column positions aligned (CSV has no sparse-cell concept).
CsvWriterOptions mirrors CsvReaderOptions's delimiter, quote and encoding, so a file written with one set of settings reads back with the matching set:
using var workbook = CsvWorkbookWriter.Create(stream, leaveOpen: false, new CsvWriterOptions
{
Delimiter = (byte)';',
Encoding = Encoding.Latin1, // fields are formatted as UTF-8, then transcoded on the way out
WriteByteOrderMark = true, // what makes Excel open a UTF-8 file as UTF-8
NewLine = CsvNewLine.LineFeed, // default is CRLF, as RFC 4180 specifies
});
Fields containing \r or \n are quoted whichever NewLine is chosen, so the terminator never changes how the file reads back.
To dump a collection of typed records instead of writing cells by hand, call WriteRecordsAsync on the sheet from CsvWorkbookWriter.Create(stream) — the same record-writing API as the Excel formats, restricted to a single sheet.