ExcelReader.NET 1.4.1

There is a newer version of this package available.
See the version list below for details.
dotnet add package ExcelReader.NET --version 1.4.1
                    
NuGet\Install-Package ExcelReader.NET -Version 1.4.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ExcelReader.NET" Version="1.4.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ExcelReader.NET" Version="1.4.1" />
                    
Directory.Packages.props
<PackageReference Include="ExcelReader.NET" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ExcelReader.NET --version 1.4.1
                    
#r "nuget: ExcelReader.NET, 1.4.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ExcelReader.NET@1.4.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ExcelReader.NET&version=1.4.1
                    
Install as a Cake Addin
#tool nuget:?package=ExcelReader.NET&version=1.4.1
                    
Install as a Cake Tool

ExcelReader

CI CodeQL Release NuGet Downloads License Benchmarks

High-performance Excel reading and writing for .NET 10. Reads .xlsx, .xlsb, .xls, and .csv; writes .xlsx, .xlsb, .xls, and .csv.

ExcelReader is built for streaming spreadsheet workloads where low allocations matter. It reads worksheet rows as lightweight ref struct values, resolves shared strings, recognizes date styles, handles sparse cells, and includes writers for producing .xlsx (Open XML), .xlsb (BIFF12), and .xls (BIFF8) workbooks.

Benchmarks

Benchmarks were run with BenchmarkDotNet v0.15.8 on Windows 10 (22H2), AMD Ryzen 7 5700X, .NET 10.0.9 (SDK 11.0.100-preview.4). Generated-data benchmarks use 50,000 rows. Raw results: tests/ExcelReader.Benchmarks/BenchmarkDotNet.Artifacts/results.

XLSX

Compares ExcelReader against established XLSX libraries on the same generated workbook shape.

Scenario ExcelReader MiniExcel Sylvan SpreadCheetah
Cell-by-cell read 11.650 ms, 16.80 KB 149.472 ms, 209.02 MB 35.843 ms, 1.89 MB -
Cell-by-cell read async 11.744 ms, 18.93 KB - - -
Typed row parsing 15.099 ms, 3.88 MB 154.309 ms, 197.79 MB 57.022 ms, 10.48 MB -
Typed row parsing async 14.620 ms, 3.88 MB - 58.538 ms, 10.51 MB -
Workbook writing 14.442 ms, 4.02 MB 280.914 ms, 84.90 MB - 15.912 ms, 15.84 MB
Workbook writing, shared strings 14.796 ms, 4.06 MB - - -

ExcelReader is ~12.8x faster than MiniExcel and ~3.1x faster than Sylvan for raw XLSX reads, allocating ~12,700x and ~115x less respectively. For typed parsing, it is ~10.2x faster than MiniExcel and ~3.8x faster than Sylvan. For XLSX writing, ExcelReader is ~1.1x faster than SpreadCheetah and allocates ~3.9x less memory; it is ~19.5x faster than MiniExcel and allocates ~21x less.

XLSB (BIFF12)

Scenario ExcelReader
Cell-by-cell read 4.418 ms, 16.43 KB
Cell-by-cell read async 4.781 ms, 18.98 KB
Typed row parsing 6.870 ms, 3.88 MB
Typed row parsing async 6.928 ms, 3.88 MB
Workbook writing 7.524 ms, 4.02 MB
Workbook writing, shared strings 7.382 ms, 4.06 MB

XLSB is the fastest generated Excel format in these results: raw reads are ~2.6x faster than XLSX reads, typed parsing is ~2.2x faster than XLSX parsing, and writing is ~1.9x faster than XLSX writing. The XLSB writer is also ~2.1x faster than SpreadCheetah on this benchmark while allocating ~75% less memory.

XLS (BIFF8)

Scenario ExcelReader Sylvan
Cell-by-cell read 4.267 ms, 3.69 KB 5.510 ms, 1,717.98 KB
Cell-by-cell read async 4.270 ms, 3.76 KB -
Workbook writing 5.422 ms, 16.03 MB -

ExcelReader is ~1.3x faster than Sylvan for generated XLS reads while allocating ~466x less memory. The XLS writer is ~2.7x faster than the XLSX writer in this benchmark, but it allocates more because the BIFF8/OLE container is assembled in memory.

CSV

Scenario ExcelReader Sep Sylvan.Data.Csv CsvHelper
Cell-by-cell read 5.228 ms, 785 B 7.671 ms, 4.71 KB 4.820 ms, 1.61 MB 24.928 ms, 14.37 MB
Cell-by-cell read async 4.858 ms, 905 B - - -
Typed row parsing 6.113 ms, 3.86 MB 8.443 ms, 3.87 MB 12.736 ms, 10.95 MB 23.252 ms, 14.41 MB
Typed row parsing async 6.187 ms, 3.86 MB - - -
Row writing 6.625 ms, 4.00 MB 7.083 ms, 4.01 MB 6.928 ms, 4.04 MB 14.091 ms, 13.78 MB

For raw CSV reads, ExcelReader is ~1.5x faster than Sep while allocating ~6x less; Sylvan.Data.Csv is marginally faster here (~9%) but allocates ~2,150x more (1.61 MB vs 785 B) — worth it only if raw wall-clock time matters more than memory pressure; CsvHelper is ~4.8x slower and allocates ~19,200x more. For typed CSV parsing (the more common case — building actual records), ExcelReader is ~1.4x faster than Sep, ~2.1x faster than Sylvan.Data.Csv, and ~3.8x faster than CsvHelper, with the lowest allocation of the group. For CSV writing, ExcelReader, Sep, and Sylvan.Data.Csv are all within ~7% of each other, and ~2.1x faster than CsvHelper; the ~4 MB shown across the first three is primarily the benchmark's pre-sized destination MemoryStream, not per-row writer state.

Real data reads

This benchmark reads a real workbook exported in multiple formats.

Format ExcelReader Sylvan
XLSX 66.664 ms, 67.89 KB 201.999 ms, 648.21 KB
XLSM 66.822 ms, 67.93 KB 207.699 ms, 648.29 KB
XLSB 22.936 ms, 49.09 KB 30.097 ms, 338.91 KB
XLS 11.895 ms, 13.31 KB 18.355 ms, 189.91 KB
CSV 5.952 ms, 785 B 10.522 ms, 35.75 MB

On this real-data workload, ExcelReader is ~3.0x faster than Sylvan for XLSX, ~3.1x faster for XLSM, ~1.3x faster for XLSB, ~1.5x faster for XLS, and ~1.8x faster for CSV — allocating ~9.5x less for XLSX/XLSM, ~6.9x less for XLSB, ~14.3x less for XLS, and ~47,750x less for CSV (785 B vs 35.75 MB).

Typed record writing

WorkbookRecordWriter/RecordWriter (the header-plus-one-row-per-object API — see Write typed records) across all four formats, same 50,000-record source:

Format Mean Allocated
XLSX 15.410 ms 4.02 MB
XLSB 8.467 ms 4.02 MB
XLS 5.365 ms 4.03 MB
CSV 7.183 ms 4.00 MB

Relative ordering matches the lower-level writers above (XLS fastest, then XLSB, then CSV, then XLSX) — the record-mapping layer adds negligible overhead over hand-written cell-by-cell writes.

Ref struct typed parsing (zero-copy)

RefParser.ParseNamed<T> (see Parse into a ref struct) extends ExcelParser<T>'s reflection/attribute-driven column mapping to ref struct targets, binding a ReadOnlySpan<byte> property directly to the cell's raw bytes instead of allocating a string. Same generated XLSX workbook, same 50,000 rows, same four columns — only the target type and binding strategy change:

Target Mean Allocated
class (ExcelParser<T>) 15.19 ms 3.88 MB
struct (ExcelParser<T>) 15.10 ms 1.59 MB
ref struct + span binding (RefParser.ParseNamed<T>) 12.91 ms 17.17 KB

Parsing into a ref struct with a ReadOnlySpan<byte> text column removes essentially all per-row allocation — ~99.6% less than the class baseline — and is ~15% faster, since there's no per-row model allocation and no per-row string allocation for the text column. It is not AOT/trim-safe (reflection-based, same tradeoff as ExcelParser<T>). It can be consumed with foreach or await foreach but not through IEnumerable<T>/IAsyncEnumerable<T>/LINQ — a ref struct element can't be boxed through those interfaces.

Cold start

First use of ExcelParser<T>/RecordWriter in a process pays a one-time reflection + Expression.Compile cost (16 launches, cold JIT, 200 rows):

Scenario Mean Allocated
First typed parse 39.46 ms 75.17 KB
First typed record write 21.11 ms 89.5 KB

This cost is paid once per type per process and cached thereafter — irrelevant for long-running services, worth knowing for CLI tools or serverless cold starts.

Run the benchmarks locally:

dotnet run --project tests/ExcelReader.Benchmarks/ExcelReader.Benchmarks.csproj --configuration Release -- --filter *

Install

dotnet add package ExcelReader.NET

Read rows

using ExcelReader.Core.Enums;
using ExcelReader.Core.Reader;

using var reader = Excel.FromFile("report.xlsx");

foreach (var row in reader)
{
    string name = row[0].GetString();

    if (row[1].TryParse(null, out int quantity))
    {
        Console.WriteLine($"{name}: {quantity}");
    }

    if (row[2].Type == CellType.Date && row[2].TryGetDateTime(reader.IsDate1904, out var date))
    {
        Console.WriteLine(date);
    }
}

Open by auto-detecting the format

Excel.Open picks the reader from the file signature (XLSX/XLSB are ZIP packages, XLS is an OLE2 document) and returns an IExcelRowReader. The interface exposes GetEnumerator() directly, so no pattern-match is needed for basic row iteration.

using ExcelReader.Core.Reader;

using IExcelRowReader reader = Excel.Open("report.xlsx"); // or report.xlsb / report.xls

foreach (var row in reader)
{
    Console.WriteLine(row[0].GetString());
}

Sheet navigation (SheetCount, SheetName, MoveToSheet(index), TryMoveToSheet(name)) is available on IExcelRowReader itself, so you can walk every sheet without knowing the format:

using IExcelRowReader reader = Excel.Open("report.xlsx");

for (int i = 0; i < reader.SheetCount; i++)
{
    reader.MoveToSheet(i);
    Console.WriteLine(reader.SheetName);
    foreach (var row in reader)
    {
        Console.WriteLine(row[0].GetString());
    }
}

CSV is exposed as a single, unnamed sheet (SheetCount == 1, SheetName == ""). Pattern-match to the concrete type only for reader-specific internals beyond this surface.

OpenAsync is the async counterpart. Both require a seekable stream (or a file path) so the signature can be read without consuming the input.

Read asynchronously

Every reader supports await foreach. For XLSX files, the async reader buffers one row at a time and uses the same row parser as the sync reader, so sync and async reads stay behaviorally aligned while awaits happen only when more bytes are needed.

using ExcelReader.Core.Reader;

await using var reader = await Excel.FromFileAsync("report.xlsx", cancellationToken);

await foreach (var row in reader)
{
    Console.WriteLine(row[0].GetString());
}

await foreach binds to the reader's GetAsyncEnumerator() by pattern — the sheet is opened synchronously and only each row advance is awaited. Because Row and Cell are ref struct types, the current row cannot be held across an await inside the loop body: read its cells (or copy the values out) before awaiting anything else.

When you need the sheet opened asynchronously too (e.g. the first read touches the network), or you need to await while a row is in scope, drive the enumerator manually via GetAsyncEnumeratorAsync, which awaits the open and threads the cancellation token:

await using var reader = await Excel.FromFileAsync("report.xlsx", cancellationToken);
await using var rows = await reader.GetAsyncEnumeratorAsync(cancellationToken);

while (await rows.MoveNextAsync())
{
    var row = rows.Current;
    Console.WriteLine(row[0].GetString());
}

await foreach does not accept .WithCancellation(ct): Row being a ref struct rules out IAsyncEnumerable<Row>, so the loop binds to the pattern rather than the interface. Pass the token at open time (as above), or use the manual GetAsyncEnumeratorAsync(ct) loop.

Parse typed rows

ExcelParser<T> maps worksheet columns to the public settable properties of T. Columns match on the property name, or on [ExcelColumn("header")] aliases — repeat the attribute to accept several headers. The first row is the header by default.

using ExcelReader.Core.Parser;
using ExcelReader.Core.Reader;

public sealed class ChangeRow
{
    [ExcelColumn("file")]
    public string File { get; set; } = "";

    [ExcelColumn("lines_added")]
    public int LinesAdded { get; set; }
}

using var reader = Excel.FromFile("changes.xlsx");
var parser = new ExcelParser<ChangeRow>();

foreach (var item in parser.Parse(reader))
{
    Console.WriteLine($"{item.File}: +{item.LinesAdded}");
}

Built-in property types: string, bool, DateTime, DateOnly, Guid, every integral and floating type plus decimal, and enums (matched by member name or numeric value). Each also works as a Nullable<T>. Empty cells leave the property at its default; an unparseable cell is skipped (keeps the default) unless the column is required. T needs no parameterless-constructor constraint, so models with required members are supported.

Parse and ParseAsync also accept the IExcelRowReader from Excel.Open, so you can parse without knowing the concrete format:

using IExcelRowReader reader = Excel.Open("changes.xlsx"); // or .xlsb / .xls
foreach (var item in new ExcelParser<ChangeRow>().Parse(reader)) { /* ... */ }

Parser configuration

Pass an ExcelParserConfig to control header handling and culture:

using System.Globalization;
using ExcelReader.Core.Parser;

var config = new ExcelParserConfig
{
    HeaderRow = 1,                                   // 1-based row holding the headers
    ColumnNameComparer = StringComparer.OrdinalIgnoreCase,
    HeaderNormalization = HeaderNormalization.Trim | HeaderNormalization.CollapseSpaces,
    Culture = CultureInfo.GetCultureInfo("pt-BR"),   // parse "1.234,56" as 1234.56m
};

var parser = new ExcelParser<ChangeRow>(config);

Culture applies when parsing text-backed numeric/Guid cells (XLSX inline and shared strings); binary numeric cells (XLS/XLSB) carry a raw value and ignore it. HeaderNormalization flags (Trim, CollapseSpaces, RemoveDiacritics) are applied to both the sheet headers and the property names before matching.

Required columns

Mark a property [ExcelRequired] to assert its column exists and carries a value:

public sealed class Order
{
    [ExcelRequired]
    public int Id { get; set; }

    [ExcelRequired(AllowEmpty = true)]   // column must exist; blank cells allowed
    public string? Note { get; set; }
}
  • A missing required header throws when the header row is read, listing every missing column.
  • By default each data row must have a non-empty cell; the first blank throws, naming the column and row number. AllowEmpty = true relaxes this to column presence only.
  • The check covers presence, not parseability — a present-but-malformed value does not throw here.

Custom converters

For types the built-in parsers do not handle — money strings, custom formats, domain value objects — implement IExcelCellConverter<T> and attach it with [ExcelConverter]. T must be the property's exact type. One instance is created and reused across all rows, so converters must be stateless.

using System.Globalization;
using ExcelReader.Core.Parser;
using ExcelReader.Core.ValueObjects;

public sealed class BrlMoneyConverter : IExcelCellConverter<decimal>
{
    public bool TryConvert(in Cell cell, bool isDate1904, IFormatProvider provider, out decimal value)
    {
        string text = cell.GetString().Replace("R$", "", StringComparison.Ordinal).Trim();
        return decimal.TryParse(text, NumberStyles.Currency, CultureInfo.GetCultureInfo("pt-BR"), out value);
    }
}

public sealed class Invoice
{
    [ExcelConverter(typeof(BrlMoneyConverter))]
    public decimal Total { get; set; }
}

Return false to signal a parse failure (the property keeps its default). Empty cells are skipped before the converter runs.

Parse into a ref struct (zero-copy)

RefParser.ParseNamed<T> (.NET 9+) targets a ref struct model instead of a class/struct — same attribute-driven column matching as ExcelParser<T> ([ExcelColumn], [ExcelRequired], [ExcelConverter]), but a ReadOnlySpan<byte> property binds directly to the cell's raw bytes instead of allocating a string:

using System.Text;
using ExcelReader.Core.Parser;
using ExcelReader.Core.Reader;

public readonly ref struct ChangeRowRef
{
    public ReadOnlySpan<byte> File { get; init; }   // zero-copy — aliases the reader's row buffer
    public int LinesAdded { get; init; }
}

using var reader = Excel.FromFile("changes.xlsx");

foreach (ChangeRowRef item in RefParser.ParseNamed<ChangeRowRef>(reader))
{
    Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}");
}

The sequence also supports await foreach, so a ref struct model can be parsed asynchronously — the rows are streamed via MoveNextAsync while the model stays a zero-copy ref struct:

await using var reader = await Excel.FromFileAsync("changes.xlsx");

await foreach (ChangeRowRef item in RefParser.ParseNamed<ChangeRowRef>(reader))
{
    Console.WriteLine($"{Encoding.UTF8.GetString(item.File)}: +{item.LinesAdded}");
}

A few differences from ExcelParser<T>:

  • Span fields alias the reader's row buffer — valid only until the next row. Copy them out (e.g. Encoding.UTF8.GetString(span)) if you need to keep the value past the loop body. Under await foreach, the same rule means the model can't be held across an await in the loop body.
  • foreach / await foreach only. Consumption is pattern-based — the sequence cannot be surfaced through IEnumerable<T>, IAsyncEnumerable<T>, or LINQ, because a ref struct element can't be boxed through those interfaces (IAsyncEnumerable<T> in particular forbids a ref struct element type — CS9267). Iterate it directly.
  • Not AOT/trim-safe, same tradeoff as ExcelParser<T> (both reflect over T's properties and compile setters at runtime).
  • A regular struct/class model works with ParseNamed too — only a genuine ref struct model gets the extra zero-copy span-property binding.

Write XLSX workbooks

using ExcelReader.Core.Writer;

await using var stream = File.Create("out.xlsx");
await using var workbook = await XlsxWorkbookWriter.CreateAsync(stream);

await workbook.StartAsync();
await using (var sheet = workbook.AddSheet("Summary"))
{
    await sheet.StartAsync();

    await using (var row = await sheet.StartRowAsync())
    {
        row.Write("Name");
        row.Write("Total");
        row.Write("Created");
    }

    await using (var row = await sheet.StartRowAsync())
    {
        row.Write("Q1");
        row.Write(42);
        row.Write(DateTime.UtcNow);
    }
}

await workbook.EndAsync();

By default, the XLSX writer emits inline strings to keep memory usage flat while rows stream out. If your workbook repeats many strings and smaller files matter more than the extra lookup table, opt in to shared strings:

await using var workbook = await XlsxWorkbookWriter.CreateAsync(stream, useSharedStrings: true);

Read and write XLSB workbooks (BIFF12)

Use Excel.FromXlsbFile, Excel.FromXlsb, Excel.FromXlsbFileAsync, or Excel.FromXlsbAsync to open XLSB directly. For writing, use XlsbWorkbookWriter, XlsbSheetWriter, and XlsbRowWriter.

using ExcelReader.Core.Writer;

await using var stream = File.Create("out.xlsb");
await using var workbook = await XlsbWorkbookWriter.CreateAsync(stream);

await workbook.StartAsync();
await using (XlsbSheetWriter sheet = workbook.AddSheet("Summary"))
{
    await sheet.StartAsync();

    await using (XlsbRowWriter row = await sheet.StartRowAsync())
    {
        row.Write("Name");
        row.Write("Total");
        row.Write("Created");
    }
}

await workbook.EndAsync();

The XLSB writer also defaults to inline string cells. Pass useSharedStrings: true to deduplicate repeated text into sharedStrings.bin.

Write XLS workbooks (BIFF8)

XlsWorkbookWriter emits a binary BIFF8 .xls file. The sheet and row APIs are synchronous; only the final EndAsync (which assembles and flushes the OLE container) is async. BIFF8 is capped at 65,536 rows × 256 columns per sheet.

using ExcelReader.Core.Writer;

await using var stream = File.Create("out.xls");
await using var workbook = XlsWorkbookWriter.Create(stream);

workbook.Start();
using (var sheet = workbook.AddSheet("Summary"))
{
    sheet.Start();

    using (var row = sheet.StartRow())
    {
        row.Write("Name");
        row.Write("Total");
        row.Write("Created");
    }

    using (var row = sheet.StartRow())
    {
        row.Write("Q1");
        row.Write(42);
        row.Write(DateTime.UtcNow);
    }
}

await workbook.EndAsync();

Write typed records

The low-level writers above give you cell-by-cell control. When you just want to dump a collection of objects to a sheet, WorkbookRecordWriter writes a header row followed by one row per record, mapping each public readable property to a column. It is generic over the low-level interfaces, so the same API targets XLSX, XLSB, XLS, and CSV — pick the format with a RecordWriter.Create* factory.

using ExcelReader.Core.Writer;

public sealed class Sale
{
    public string? Region { get; set; }
    public int Units { get; set; }
    public decimal Revenue { get; set; }
    public DateOnly Date { get; set; }
}

var sales = new[]
{
    new Sale { Region = "North", Units = 42, Revenue = 1234.50m, Date = new DateOnly(2026, 1, 2) },
    new Sale { Region = "South", Units = 17, Revenue = 512.00m,  Date = new DateOnly(2026, 1, 3) },
};

await using var stream = File.Create("sales.xlsx");
await using var writer = await RecordWriter.CreateXlsxAsync(stream);   // or CreateXlsbAsync / CreateXlsAsync / CreateCsv
await writer.WriteSheetAsync("Sales", sales);

Each WriteSheetAsync call targets a new sheet (a duplicate name throws), so one workbook can hold sheets of different record types. RecordWriter.CreateCsv is the exception: a CSV file is a single sheet, so a second WriteSheetAsync throws (the sheet name is ignored). An IAsyncEnumerable<T> overload streams records that are produced asynchronously. The written file round-trips straight back through ExcelParser<T> because the headers are the property names.

Column behavior mirrors the parser attributes:

  • [ExcelColumn("Header")] — use a custom header instead of the property name (the first alias wins).
  • [ExcelIgnore] — exclude a property from both writing and parsing (for computed/transient members).
  • [ExcelConverter(typeof(MyConverter))] — if the converter also implements IExcelCellWriter<T>, it controls how the value is written, so a custom type round-trips through the same converter it reads with.

DateTime and DateOnly are written as Excel date serials; TimeOnly as a time-of-day fraction. Numeric properties become number cells; any other type is written as its ToString() text. (CreateCsv follows the CSV rules instead — see Write CSV — writing DateTime/DateOnly as ISO text and TimeOnly as a time-of-day fraction, all still round-tripping through ExcelParser<T>.)

Read CSV

CsvReader streams RFC 4180 CSV (quoted fields, embedded delimiters/newlines, ""-escaped quotes) through the same Row/Cell model as the Excel readers, so ExcelParser<T> works on it unchanged.

using ExcelReader.Core.Parser;
using ExcelReader.Core.Reader;

using var reader = Excel.FromCsvFile("report.csv");

foreach (var row in reader)
{
    Console.WriteLine(row[0].GetString());
}

// Typed parsing works exactly like the Excel readers:
foreach (var item in new ExcelParser<ChangeRow>().Parse(reader))
{
    Console.WriteLine($"{item.File}: +{item.LinesAdded}");
}

Excel.FromCsv/FromCsvFile/FromCsvAsync/FromCsvFileAsync mirror the other formats' factory shape. Pass CsvReaderOptions to change the delimiter/quote character, supply a non-UTF-8 Encoding (transcoded to UTF-8 internally), or turn off BOM detection:

var options = new CsvReaderOptions { Delimiter = (byte)';' };
using var reader = 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(CsvReader) 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 reader as IExcelRowReader instead routes through the generic Excel pipeline, where dates use serial-number semantics — prefer the concrete Parse(CsvReader) overload for CSV.)

Excel.Open/OpenAsync do not auto-detect CSV — plain text has no magic-byte signature to sniff, so open CSV explicitly via Excel.FromCsv*.

Write CSV

CsvWriter emits RFC 4180 CSV: no sheets, styles, or shared strings, so rows stream straight to the output.

using ExcelReader.Core.Writer;

using var stream = File.Create("out.csv");
using var writer = CsvWriter.Create(stream);

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(CsvReader) expects, so a file written by CsvWriter parses back without configuration. Skip(count) writes empty fields to keep column positions aligned (CSV has no sparse-cell concept). Pass CsvWriterOptions to change the delimiter/quote byte, mirroring CsvReaderOptions.

To dump a collection of typed records instead of writing cells by hand, use RecordWriter.CreateCsv(stream) — the same record-writing API as the Excel formats, restricted to a single sheet.

Notes

  • Reads .xlsx, .xlsb (BIFF12), .xls (BIFF8), and .csv; writes .xlsx, .xlsb, .xls, and .csv.
  • Reads one sheet at a time (XLSX/XLSB/XLS); use MoveToSheet(index) or TryMoveToSheet(name) to switch sheets. CSV has no sheets.
  • Missing cells in sparse rows are exposed as empty cells.
  • String conversion allocates only when you call GetString().
  • The XLSX scanner accepts the SpreadsheetML shapes commonly emitted by non-Excel producers, including single-quoted attributes, comments in sheetData, and CDATA text runs.
  • Readers bound untrusted input by default: 512 MB total decompressed ZIP data, 32 MB per cell/row value buffer, and 128 MB for shared strings. Pass ExcelReaderOptions to the Excel.From*/Excel.Open* factories to tune these limits; set a limit to 0 to opt out and restore unlimited behavior for that limit. CsvReader has its own CsvReaderOptions.MaxCellBytes (default 32 MB) for the same purpose.
  • The XLSX writer emits a compact workbook with strings, numbers, booleans, dates, and blank cells; shared strings are opt-in.
  • The XLSB writer emits BIFF12 workbook parts inside the standard XLSB ZIP package; shared strings are opt-in.
  • The XLS writer buffers records in memory and assembles the OLE container at EndAsync; choose it when write throughput matters more than peak allocation.

Build

dotnet restore ExcelReader.slnx
dotnet build ExcelReader.slnx --configuration Release
dotnet test --project tests/ExcelReader.Tests/ExcelReader.Tests.csproj --configuration Release

License

ExcelReader is licensed under the MIT License. See LICENSE.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on ExcelReader.NET:

Package Downloads
ExcelReader.Arrow

Apache Arrow RecordBatch conversion for ExcelReader.NET.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
4.1.0 0 9/21/2026
4.0.0 46 9/20/2026
3.1.0 115 9/14/2026
3.0.2 117 9/9/2026
3.0.1 3,184 9/4/2026
3.0.0 116 9/4/2026
2.3.0 1,802 8/30/2026
2.2.0 109 8/26/2026
2.1.3 141 8/20/2026
2.1.2 2,904 8/16/2026
2.1.1 100 8/14/2026
2.1.0 104 8/14/2026
2.0.1 108 8/5/2026
2.0.0 172 8/5/2026
1.4.5 118 8/4/2026
1.4.4 121 7/31/2026
1.4.2 146 7/28/2026
1.4.1 134 7/23/2026
1.4.0 106 7/22/2026
1.3.1 115 7/21/2026
Loading failed