Writing workbooks
Writers for XLSX (Open XML), XLSB (BIFF12) and XLS (BIFF8), cell styles, typed records, and prefetch compression. See csv.md for CSV output.
Write XLSX workbooks
using ExcelReader.Core.Writer;
using ExcelReader.Core.Writer.Xlsx;
await using var stream = File.Create("out.xlsx");
await using var workbook = XlsxWorkbookWriter.Create(stream);
await using (var sheet = workbook.AddSheet("Summary"))
{
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 = XlsxWorkbookWriter.Create(stream, options: new XlsxWriterOptions { UseSharedStrings = true });
Hidden sheets
Every IWorkbookWriter<TSheet> takes an optional ExcelSheetVisibility alongside the sheet name, written to whatever the format uses for it — XLSX's state attribute, XLSB's BrtBundleSh.hsState, XLS's BoundSheet8.hsState — so it reads back through IExcelSheet.Visibility:
using ExcelReader.Core; // ExcelSheetVisibility, shared by the readers and writers
await using (var data = workbook.AddSheet("Data")) { /* rows */ } // visible
await using (var lookups = workbook.AddSheet("Lookups", ExcelSheetVisibility.Hidden)) { /* rows */ }
await using (var audit = workbook.AddSheet("Audit", ExcelSheetVisibility.VeryHidden)) { /* rows */ } // not in Excel's unhide dialog
A workbook whose sheets are all hidden is rejected when it is ended (InvalidOperationException): the formats allow it, but Excel reports such a file as damaged, so it is caught at write time rather than shipped to a user. CSV accepts the argument and ignores it — delimited text has no tab bar to hide from.
Cell styles on write
Every IWorkbookWriter<TSheet> supports column- and row-level styling: a number format (currency, date, percentage), bold, and italic. Register a CellStyle once with AddStyle and apply its returned index to a column (before the first row) or to a whole row (when starting it):
using ExcelReader.Core.Writer;
using ExcelReader.Core.Writer.Xlsx;
await using var workbook = XlsxWorkbookWriter.Create(stream);
int currency = workbook.AddStyle(new CellStyle { NumberFormat = "R$ #,##0.00" });
int header = workbook.AddStyle(new CellStyle { Bold = true });
await using var sheet = workbook.AddSheet("Summary");
sheet.SetColumnStyle(columnIndex: 1, currency); // before the first row
sheet.SetColumnWidth(columnIndex: 0, width: 20);
await using (var row = await sheet.StartRowAsync(header))
{
row.Write("Product");
row.Write("Total");
}
await using (var row = await sheet.StartRowAsync())
{
row.Write("Widget");
row.Write(1234.5);
}
await workbook.EndAsync();
AddStyle deduplicates by value: registering the same CellStyle twice returns the same index, and index 0 is always the general/default style. SetColumnStyle/SetColumnWidth must be called before the sheet's first StartRow/StartRowAsync (calling them later throws InvalidOperationException) — the column layout (XLSX <cols>, XLSB BrtColInfo, XLS COLINFO) has to be written ahead of the row data. A row's style (from StartRowAsync(int, CancellationToken)) takes precedence over its column's style for any cell in that row. CSV has no cell concept of style: every style member is a documented no-op there.
Cell-level styling (one specific cell rather than a whole column or row) is out of scope. Bold/italic render only in XLSX today; XLSB and XLS apply the number format but keep the default font, since their font records are opaque binary blobs this library isn't confident hand-editing without a verified field map.
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;
using ExcelReader.Core.Writer.Xlsb;
await using var stream = File.Create("out.xlsb");
await using var workbook = XlsbWorkbookWriter.Create(stream);
await using (XlsbSheetWriter sheet = workbook.AddSheet("Summary"))
{
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. Set XlsbWriterOptions.UseSharedStrings 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;
using ExcelReader.Core.Writer.Xls;
await using var stream = File.Create("out.xls");
await using var workbook = XlsWorkbookWriter.Create(stream);
using (var sheet = workbook.AddSheet("Summary"))
{
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, sheet.WriteRecordsAsync(records, layout) writes a header row followed by one row per record, with the columns coming from an ExcelRecordLayout<T>. ExcelRecordLayout.FromAttributes<T>() maps each public readable property to a column by reflection. It is an extension on ISheetWriter<TRow>, so the same call works on a sheet from any of the writers above — XLSX, XLSB, XLS, and CSV.
using ExcelReader.Core.Writer;
using ExcelReader.Core.Writer.Xlsx;
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 workbook = XlsxWorkbookWriter.Create(stream); // or XlsbWorkbookWriter / XlsWorkbookWriter / CsvWorkbookWriter
await using (var sheet = workbook.AddSheet("Sales"))
{
await sheet.WriteRecordsAsync(sales, ExcelRecordLayout.FromAttributes<Sale>());
}
Disposing the sheet ends it; add the next sheet after that (a duplicate name throws, ignoring case), so one workbook can hold sheets of different record types. The sheet stays open after WriteRecordsAsync, so you can append more rows before disposing it. CsvWorkbookWriter is the exception: a CSV file is a single sheet, so a second AddSheet 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 implementsIExcelCellWriter<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 (including Half) become number cells; any other type is written as text formatted with the invariant culture — DateTimeOffset as ISO 8601 ("O"), enums by name — so the file parses back the same whatever the writing machine's culture. Int128/UInt128 stay text because a number cell is a double and would drop their low digits. (CsvWorkbookWriter 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>.)
For a model marked [ExcelSerializable], pass ExcelRecordLayout.Generated<T>() instead — same behavior, but driven by the source-generated map instead of reflection, so it stays Native AOT/trim-safe. See Generate typed maps at compile time.
Write an Excel table
The XLSX and XLSB sheet writers implement ITableSheetWriter<TRow>. BeginTable writes the header row
and opens a table there; every row written until EndTable (or the end of the sheet) belongs to it. The
file opens in Excel already formatted as a table, with filter buttons and the chosen built-in style, and
reads back through IExcelWorkbook.Tables.
using XlsxWorkbookWriter workbook = XlsxWorkbookWriter.Create(File.Create("report.xlsx"));
XlsxSheetWriter sheet = workbook.AddSheet("Report");
sheet.BeginTable("Sales", ["Region", "Units", "Revenue"]);
foreach (Sale s in sales)
{
using XlsxRowWriter row = sheet.StartRow();
row.Write(s.Region);
row.Write(s.Units);
row.Write(s.Revenue);
}
sheet.EndTable();
For records, WriteTableAsync does all three steps with the same layouts as WriteRecordsAsync:
await sheet.WriteTableAsync(sales, "Sales", ExcelRecordLayout.FromAttributes<Sale>());
ExcelTableOptionssetsFirstColumn,StyleName(a built-inTableStyleLight1–21,TableStyleMedium1–28orTableStyleDark1–11, ornullfor none; defaultTableStyleMedium2) and the banding flagsShowRowStripes,ShowColumnStripes,ShowFirstColumn,ShowLastColumn.- With
FirstColumn > 0, rows you write yourself mustSkip(FirstColumn)to reach the table;WriteTableAsyncdoes it for you. - One table can be open per sheet at a time; write as many as you like one after another. Table names
must be valid Excel names, must not read as cell references (
A1,R1C1), and must be unique in the workbook, ignoring case. A table with no data rows gets one empty row, as Excel requires. - Totals rows, custom table styles, and tables in XLS or CSV are not supported.
Prefetch compression (XLSX/XLSB writing)
The write-side mirror of Prefetch decompression. XLSX and XLSB
are ZIP-backed, so every row a writer serializes has to be deflated before it reaches the stream,
and by default that happens on the calling thread. Set PrefetchWrite on the writer options to move the deflate
onto a background thread, so the caller keeps building the next batch of rows while the previous one
compresses:
await using var wb = XlsxWorkbookWriter.Create(stream, leaveOpen: true, options: new XlsxWriterOptions { PrefetchWrite = true });
XlsxSheetWriter sheet = wb.AddSheet("S1");
foreach (var record in records)
{
using XlsxRowWriter row = sheet.StartRow();
row.Write(record.Name);
row.Write(record.Value);
}
await sheet.EndAsync();
XlsbWriterOptions has the same property. XLS and CSV are uncompressed, so
they have nothing to overlap and do not offer it.
Writing a 50,000-row workbook (WriteBenchmark, both figures from one run):
| Workload | Default | PrefetchWrite = true |
Gain |
|---|---|---|---|
| XLSX | 8.527 ms | 5.245 ms | 38% |
| XLSB | 5.786 ms | 3.234 ms | 44% |
Allocations are unchanged (4.02 MB either way) — the background writer hands over buffers the row writer already owns rather than copying them.
The same caveat as the read side applies: do not enable it for concurrent server workloads. A caller already writing many files in parallel is CPU-saturated, and an extra background thread per writer only doubles thread demand for no gain. It's meant for single-file batch work.
Write records in parallel (XLSX/XLSB)
PrefetchWrite moves compression off the calling thread, but the rows are still built on one thread,
and building them is most of a write. WriteRecordsParallelAsync and WriteTableParallelAsync use
several cores for both: the records are cut into batches, each batch is formatted and compressed on
a pool thread, and the batches reach the file in order. They are extensions on
XlsxSheetWriter and
XlsbSheetWriter, and take an IEnumerable<T>
or an IAsyncEnumerable<T>. The cell-by-cell API stays single-threaded, and XLS and CSV have no
parallel form.
await using var wb = XlsxWorkbookWriter.Create(stream, leaveOpen: true);
XlsxSheetWriter sheet = wb.AddSheet("S1");
// Header row from the layout, then one row per record.
await sheet.WriteRecordsParallelAsync(records, ExcelRecordLayout.FromAttributes<Metric>());
// Or format each row yourself; this delegate runs on several threads at once.
await sheet.WriteRecordsParallelAsync(records, static (XlsxRowWriter row, Metric record) =>
{
row.Write(record.Name);
row.Write(record.Value);
}, new ParallelWriteOptions { DegreeOfParallelism = 4 });
await sheet.EndAsync();
The rules:
- Your delegate runs on several threads at once, though never twice at once on the same row writer. Whatever it captures must be thread-safe. The library's own layouts are.
- Order is kept. Rows come out in the order the source yields them, and the source itself is enumerated by one flow at a time, never concurrently.
- Rows may be written before and after the call, not during it. Row numbering carries on, and the call can be made more than once on a sheet.
- A failure or a cancellation ends the sheet and the workbook. A serial
WriteRecordsAsynccancelled between rows leaves the sheet usable; a parallel call does not. The original exception is rethrown once every batch in flight has stopped. - With
UseSharedStrings, string indexes depend on thread timing, so two runs give files that differ byte for byte. Their content is the same. - More than 1,048,576 rows throws
ExcelLimitExceededException.
ParallelWriteOptions has two knobs.
DegreeOfParallelism is the most batches in flight at once (0, the default, means one per core).
BatchRows is the rows per batch, 8,192 by default. Memory grows with their product: at the
defaults each batch in flight holds about 3 MB (its formatted rows, the compressor's window and the
compressed result), whatever the size of the sheet.
Writing 1,000,000 rows of four columns (text, integer, date, number) to a MemoryStream at
Fastest, on a Ryzen 7 5700X (ParallelRecordWriteBenchmark):
| Write | XLSX | XLSB |
|---|---|---|
| Serial | 171.57 ms | 115.39 ms |
Serial, PrefetchWrite |
104.92 ms | 60.02 ms |
| Parallel, 1 batch in flight | 165.22 ms | 107.81 ms |
| Parallel, 2 | 89.69 ms | 57.94 ms |
| Parallel, 4 | 46.29 ms | 30.62 ms |
| Parallel, 8 | 30.56 ms | 21.04 ms |
| Parallel, 16 | 27.93 ms | 18.25 ms |
The file comes out 0.18% (XLSX) or 0.04% (XLSB) larger, because each batch is compressed on its own.
As with PrefetchWrite, this is for single-file batch work: a server already writing many files at
once has no idle cores to give it.