A reproducible comparison of SheetStream with the other .xlsx writers and readers .NET developers actually use. The suite measures speed, bytes allocated, peak memory and output size. Before anything is timed, it checks that every library really wrote the rows, and that every reader really read them.
| Library | Version | Licence | Why this version |
|---|---|---|---|
| SheetStream | this repo (ProjectReference) | MIT | |
| EPPlus | 4.5.3.3, pinned [4.5.3.3] |
LGPL | The last LGPL release. EPPlus 5 and later need a commercial licence for commercial use. |
| ClosedXML | 0.105.1 | MIT | Latest stable. |
| MiniExcel | 1.46.0 | Apache-2.0 | Latest stable. 2.0 is still in preview. |
| NPOI | 2.7.6, pinned [2.7.6] |
Apache-2.0 | The last release whose NuGet binaries are plain Apache-2.0. From 2.8.0 the package ships under an "OSMF EULA" with requireLicenseAcceptance: users above US$10,000 a year in revenue owe the project a maintenance fee for the binaries. The agreement says this fee is not a licence fee. 2.8.x still builds: it only prints a build warning until you set AcceptNPOIOSMFLicense. As with EPPlus 5+, this suite does not accept that agreement on anyone's behalf. |
| ExcelDataReader | 3.9.0 | MIT | Latest stable. Read benchmarks only; it does not write. |
| DocumentFormat.OpenXml | 3.5.1 | MIT | Used only by the correctness guard, to read every library's output back. |
| BenchmarkDotNet | 0.15.8 | MIT |
NPOI is measured twice on the write side: once with XSSF, the in-memory user model, and once with SXSSF, the streaming port of Apache POI's SXSSF. On the read side it is also measured twice: XSSF (user model) and SAX (the event model, XSSFReader + XSSFSheetXMLHandler).
All commands run from benchmarks/SheetStream.Benchmarks, and everything is Release.
# The two original write scenarios (about 10 minutes on the machine below)
dotnet run -c Release -- --filter '*Long*' '*.WideBenchmarks.*'
# One scenario at a time
dotnet run -c Release -- --filter '*Long*' # 100k rows x 10 columns
dotnet run -c Release -- --filter '*.WideBenchmarks.*' # 1,000 x 1,000 ('*Wide*' alone would also match VeryWide)
dotnet run -c Release -- --filter '*Million*' # 1,000,000 rows x 10 columns (needs 16 GB+ RAM, see below)
dotnet run -c Release -- --filter '*VeryWide*' # 1,000 rows x 10,000 columns
dotnet run -c Release -- --filter '*Read*' # read the 100k x 10 file with every reader (about 7 minutes)
# Everything, including the 10M-cell scenarios (well over an hour)
dotnet run -c Release -- --filter '*'
# Peak working set, output size and full-size verification, each library in a fresh process.
# With no scenario it runs all five (Long, Wide, Million, VeryWide, Read); name one or more to run just those.
dotnet run -c Release -- --peak # all scenarios
dotnet run -c Release -- --peak Long Wide # about 3 minutes
dotnet run -c Release -- --peak Read # about 3.5 minutes
dotnet run -c Release -- --peak Million VeryWide # tens of minutes; the in-memory libraries need 16 GB+ RAM
# Correctness guard only: shows which rows, cells and formats every writer actually wrote,
# and the checksum every reader arrived at
dotnet run -c Release -- --verifyIn Git Bash, quote the filter ('*'). Otherwise the shell expands * to file names.
BenchmarkDotNet's -j Dry switch adds a Dry job next to the one declared on each class rather than replacing it, so a "quick" --filter '*Read*' -j Dry still runs the full job as well.
The Million scenario holds a million LongRow objects (about 80 MB) plus each library's working set. SheetStream and MiniExcel stay close to the baseline, but the in-memory models (EPPlus, ClosedXML, NPOI XSSF) build the whole workbook first and allocate ten times what they do on Long, so run Million on a machine with at least 16 GB of RAM.
EPPlus 4.5.3.3 depends on System.Drawing.Common, which only works on Windows under .NET 6+. The EPPlus rows therefore only run on Windows.
Every library gets the same input. The input is built once, before timing, from deterministic code in Data.cs.
- Long: 100,000 rows x 10 columns plus a header row in bold. The columns are:
int- three strings, each from a small repeated set (8, 5 and 4 values), so shared-string de-duplication matters
- three doubles formatted
#,##0.00 - a
DateTimewith no time part, formattedyyyy-mm-dd - a second
int
- Wide: 1,000 rows x 1,000 columns of doubles, with no header and no formatting.
- Million: 1,000,000 rows x the same 10 columns as Long (10M cells). Same data generator, same
WriteLongcode; only the row count changes. - VeryWide: 1,000 rows x 10,000 columns of doubles (10M cells, the same count as Million). Same
WriteWidecode as Wide; only the column count changes. Million and VeryWide together show whether a library cares about the shape of a sheet or only about the number of cells. - Read: every reader reads the Long file (100,000 rows x 10 columns) that SheetStream wrote once, outside the timing, into a byte array. Each reader walks every data row and returns a checksum (the sum of the
Idcolumn plus the number of non-empty cells), so the work cannot be optimised away and the result can be checked against the generator.
-
One implementation per library (
Writers.csfor writing,Readers.csfor reading). The benchmark, the correctness guard and the peak mode all call the sameWriteLong/WriteWide/ReadLongmethod. What is verified is exactly what is timed. -
Output goes to a reused
MemoryStream(ReusableOutput) for every library. It is created once with enough capacity (64 MB for Long and Wide, 160 MB for Million and VeryWide, chosen from the largest output measured: MiniExcel's 76.6 MB on Million) and emptied before each write, so the buffer never grows while timed. As a result, "Allocated" counts only the library's own allocations. ItsDisposeis a no-op, so a library that closes its output stream does no harm. -
Each library is written the way its docs recommend:
- SheetStream:
new ExcelPackage(stream, leaveOpen: true),Row(n).WriteCell(value, style)with three styles created once, and default options (CompressionLevel.Optimal). A second row, SheetStream (Fastest zip), sets onlyPackageOptions.CompressionLevel = Fastest. It is labelled separately because every other library runs at its default compression. - EPPlus 4.5.3.3:
Worksheet.SetValue(row, col, value)in a loop. This avoids theExcelRangethatCells[r, c]allocates on every call. Number formats are then set once per column range, and the package is written withSaveAs(stream). - ClosedXML:
ws.Cell(r, c).Value = xin a loop.XLCellValueis a struct, so this does not box. Formats are set once per column range, thenSaveAs(stream). - MiniExcel:
stream.SaveAs(...).- Long passes the typed
LongRow[]with[ExcelColumn(Format = ...)]on the formatted columns. - Wide passes one
Dictionary<string, object>per row, MiniExcel's documented shape for dynamic columns. This boxes every double. - Everything else is left at MiniExcel's defaults.
- Long passes the typed
- NPOI:
CreateRow/CreateCell/SetCellValue, with the threeICellStyles created once andWrite(stream, leaveOpen: true). SXSSF uses its default 100-row window. It flushes older rows to a temporary file on disk, so its time includes that disk I/O.
- SheetStream:
-
Each reader is written the way its docs recommend. The input is the same byte array for all of them, handed over as a fresh read-only
MemoryStreamper invocation (created in[IterationSetup], outside the timing), so nobody pays for disk I/O and a reader that disposes its stream does no harm. Every reader skips the header row and counts data rows only.- SheetStream ReadRows:
ExcelReader.Open(stream, leaveOpen: true)andReadRows("Data"), the untyped streaming path; numbers arrive asdouble, shared strings resolved, the date column asDateTime. - SheetStream Read<T>:
Read<LongRow>("Data"), the typed path that maps the header row to properties by name. - MiniExcel Query<T>:
stream.Query<LongRow>(sheetName: "Data"), with the sameLongRowclass the MiniExcel writer uses. - MiniExcel Query (dynamic):
stream.Query(useHeaderRow: true, sheetName: "Data"), oneIDictionary<string, object>per row keyed by header caption. - ExcelDataReader:
ExcelReaderFactory.CreateReader(stream)and theRead()/GetValue(i)loop from its README. Its README also asks .NET Core applications to registerCodePagesEncodingProviderat start-up; that is done once in a static constructor. It is only needed for binary .xls files, which this suite never opens, but it is the documented set-up step. - ClosedXML:
new XLWorkbook(stream), thenRowsUsed()/CellsUsed()andCell(1).GetDouble(). - EPPlus 4.5.3.3:
new ExcelPackage(stream), then aCells[r, c].Valueloop overDimension. EachCells[r, c]allocates anExcelRange; there is no cheaper documented path on the read side. - NPOI XSSF:
new XSSFWorkbook(stream), thenGetRow/GetCelloverLastRowNum. - NPOI SAX:
OPCPackage.Open(stream),XSSFReader,ReadOnlySharedStringsTableand anISheetContentsHandler, driven throughXSSFEventBasedExcelExtractor.ProcessSheet, which is the only parser entry point NPOI exposes for its port of POI's event model. The handler delivers every cell as a formatted string, soIdis parsed back from text; that cost is inherent to the API. The package is closed withRevert()becauseOPCPackage.Open(Stream)opens read-write andClose()would try to save.
- SheetStream ReadRows:
-
BenchmarkDotNet job:
[MemoryDiagnoser]withSimpleJob(launchCount: 1, warmupCount: 3, iterationCount: 10)for Long, Wide and Read, and a lighterSimpleJob(launchCount: 1, warmupCount: 1, iterationCount: 3)for Million and VeryWide, where the in-memory libraries take 20 to 60 seconds per write. One invocation per iteration throughout.[IterationSetup]empties the buffer (or opens the input stream), outside the measurement. Each benchmark runs in its own process, as BenchmarkDotNet does by default, on the default runtime settings (workstation GC, tiered compilation). -
Correctness guard: every write benchmark's
GlobalSetupwrites a small case (50 rows for the Long-shaped scenarios, 40 x 40 for the Wide-shaped ones) with every library. It reads each output back with the Open XML SDK's SAX reader and throws if the row count or the number of cells in the last row is wrong. The read benchmark'sGlobalSetupwrites a 50-row file and throws if any reader's checksum differs from the generator's figure (Checksum.Expected, computed from the data definition, not from any reader). The--peakchildren repeat both checks on the full-size data after their measurement. -
Peak working set (
--peak): the executable starts itself again with--peak-child <library> <scenario>. Each child does the following:- Builds the same input (for Read: writes the 100k-row file with SheetStream into memory).
- Writes (or reads) once, cold, including JIT.
- Reads
Process.PeakWorkingSet64. - Only then verifies the full-size output (or compares the checksum).
Each case runs in 3 fresh processes and the median is reported. A Baseline child builds the input and calls no library. It shows the floor every library sits on: the .NET runtime, the benchmark executable and the input data (for Read, the input file as well). The Baseline child never calls a writer or reader, so the libraries' assemblies are not loaded or JIT-compiled in it. Each library's "above baseline" figure therefore includes loading and compiling its own code. The output buffer's pages count only once they are written, so each writer's figure includes roughly its output size.
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26340.9616)
Intel Core i7-8700 CPU 3.20GHz (Max: 3.19GHz) (Coffee Lake), 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.401
[Host] : .NET 8.0.31 (8.0.31, 8.0.3126.42015), X64 RyuJIT x86-64-v3
Job-IUYGTA : .NET 8.0.31 (8.0.31, 8.0.3126.42015), X64 RyuJIT x86-64-v3
This is a developer workstation, not an isolated benchmark box. The error columns show the noise this causes. SheetStream on Wide has the widest relative spread (±26% at 99.9%).
Measured on 2026-10-13. The tables are copied unchanged from BenchmarkDotNet.Artifacts/results/*-report-github.md and from the --peak output.
InvocationCount=1 IterationCount=10 LaunchCount=1
UnrollFactor=1 WarmupCount=3
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|---|
| SheetStream | 1,012.6 ms | 79.25 ms | 41.45 ms | 1.00 | 0.05 | 4000.0000 | - | - | 26.67 MB | 1.00 |
| 'SheetStream (Fastest zip)' | 534.1 ms | 71.14 ms | 47.05 ms | 0.53 | 0.05 | 4000.0000 | - | - | 26.67 MB | 1.00 |
| 'EPPlus 4.5.3.3' | 2,286.7 ms | 286.31 ms | 189.38 ms | 2.26 | 0.20 | 43000.0000 | 6000.0000 | 2000.0000 | 390.19 MB | 14.63 |
| ClosedXML | 3,103.2 ms | 74.72 ms | 44.46 ms | 3.07 | 0.13 | 161000.0000 | 20000.0000 | 2000.0000 | 1104.16 MB | 41.41 |
| MiniExcel | 596.4 ms | 66.25 ms | 43.82 ms | 0.59 | 0.05 | 54000.0000 | - | - | 343.36 MB | 12.88 |
| 'NPOI XSSF' | 4,658.4 ms | 478.16 ms | 316.27 ms | 4.61 | 0.35 | 164000.0000 | 53000.0000 | 3000.0000 | 1186.16 MB | 44.48 |
| 'NPOI SXSSF' | 2,039.7 ms | 370.02 ms | 220.19 ms | 2.02 | 0.22 | 117000.0000 | 39000.0000 | - | 704.22 MB | 26.41 |
InvocationCount=1 IterationCount=10 LaunchCount=1
UnrollFactor=1 WarmupCount=3
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|---|
| SheetStream | 984.2 ms | 255.99 ms | 169.32 ms | 1.02 | 0.23 | 6000.0000 | - | - | 38.81 MB | 1.00 |
| 'SheetStream (Fastest zip)' | 460.0 ms | 103.30 ms | 61.47 ms | 0.48 | 0.09 | 6000.0000 | - | - | 38.81 MB | 1.00 |
| 'EPPlus 4.5.3.3' | 1,863.9 ms | 69.74 ms | 41.50 ms | 1.94 | 0.29 | 59000.0000 | 7000.0000 | 2000.0000 | 470.67 MB | 12.13 |
| ClosedXML | 2,031.7 ms | 116.52 ms | 77.07 ms | 2.12 | 0.33 | 147000.0000 | 4000.0000 | - | 976.04 MB | 25.15 |
| MiniExcel | 579.0 ms | 45.67 ms | 30.21 ms | 0.60 | 0.10 | 64000.0000 | 1000.0000 | - | 400.56 MB | 10.32 |
| 'NPOI XSSF' | 11,106.6 ms | 1,168.99 ms | 773.21 ms | 11.56 | 1.90 | 141000.0000 | 51000.0000 | 4000.0000 | 1040.94 MB | 26.82 |
| 'NPOI SXSSF' | 2,145.3 ms | 282.06 ms | 186.57 ms | 2.23 | 0.38 | 121000.0000 | 24000.0000 | - | 704.68 MB | 18.16 |
Each figure is the median of 3 fresh processes. "Cold write time" is a single write that includes JIT, so it is higher than the BenchmarkDotNet means. "Above baseline" subtracts the Baseline child's peak.
Long
| Library | Peak working set (MB) | Above baseline (MB) | Cold write time (ms) | Output size (KB) | Full-size rows verified |
|---|---|---|---|---|---|
| Baseline | 36.0 | - | - | - | - |
| SheetStream | 65.6 | 29.6 | 1,203 | 5,798 | yes (100,001 rows x 10 cells) |
| SheetStream-Fastest | 67.0 | 31.0 | 737 | 7,006 | yes (100,001 rows x 10 cells) |
| EPPlus 4.5.3.3 | 219.9 | 183.8 | 3,060 | 5,601 | yes (100,001 rows x 10 cells) |
| ClosedXML | 267.2 | 231.1 | 4,416 | 6,030 | yes (100,001 rows x 10 cells) |
| MiniExcel | 73.3 | 37.3 | 1,016 | 7,421 | yes (100,001 rows x 10 cells) |
| NPOI XSSF | 581.2 | 545.1 | 5,351 | 5,304 | yes (100,001 rows x 10 cells) |
| NPOI SXSSF | 101.2 | 65.2 | 3,310 | 5,518 | yes (100,001 rows x 10 cells) |
Wide
| Library | Peak working set (MB) | Above baseline (MB) | Cold write time (ms) | Output size (KB) | Full-size rows verified |
|---|---|---|---|---|---|
| Baseline | 31.7 | - | - | - | - |
| SheetStream | 49.8 | 18.1 | 852 | 4,346 | yes (1,000 rows x 1,000 cells) |
| SheetStream-Fastest | 50.6 | 18.9 | 587 | 5,159 | yes (1,000 rows x 1,000 cells) |
| EPPlus 4.5.3.3 | 215.1 | 183.4 | 2,464 | 4,617 | yes (1,000 rows x 1,000 cells) |
| ClosedXML | 200.9 | 169.2 | 3,447 | 4,772 | yes (1,000 rows x 1,000 cells) |
| MiniExcel | 58.6 | 26.9 | 1,099 | 5,097 | yes (1,000 rows x 1,000 cells) |
| NPOI XSSF | 547.7 | 516.0 | 11,728 | 4,349 | yes (1,000 rows x 1,000 cells) |
| NPOI SXSSF | 136.4 | 104.8 | 3,669 | 4,353 | yes (1,000 rows x 1,000 cells) |
Measured on 2026-10-14. Job: InvocationCount=1 IterationCount=3 LaunchCount=1 UnrollFactor=1 WarmupCount=1 (3 measured writes per library; the error column is wide because of that).
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|---|
| SheetStream | 12.825 s | 14.5478 s | 0.7974 s | 1.00 | 0.08 | 45000.0000 | - | - | 274.7 MB | 1.00 |
| 'SheetStream (Fastest zip)' | 4.753 s | 1.0805 s | 0.0592 s | 0.37 | 0.02 | 45000.0000 | - | - | 274.7 MB | 1.00 |
| 'EPPlus 4.5.3.3' | 26.228 s | 103.0873 s | 5.6506 s | 2.05 | 0.40 | 409000.0000 | 30000.0000 | 4000.0000 | 3934.59 MB | 14.32 |
| ClosedXML | 36.506 s | 9.3365 s | 0.5118 s | 2.85 | 0.16 | 1599000.0000 | 142000.0000 | 5000.0000 | 11559.78 MB | 42.08 |
| MiniExcel | 5.325 s | 0.6575 s | 0.0360 s | 0.42 | 0.02 | 553000.0000 | - | - | 3325.16 MB | 12.10 |
| 'NPOI XSSF' | 47.264 s | 17.7229 s | 0.9715 s | 3.70 | 0.22 | 1616000.0000 | 488000.0000 | 6000.0000 | 11596.94 MB | 42.22 |
| 'NPOI SXSSF' | 20.738 s | 7.1444 s | 0.3916 s | 1.62 | 0.09 | 1178000.0000 | 393000.0000 | - | 7049.39 MB | 25.66 |
At a million rows the zip compression level decides the time: SheetStream with Fastest is the quickest writer (4.8 s), MiniExcel is close (5.3 s), and SheetStream's default Optimal level costs 12.8 s for a file 17% smaller. Allocation scales linearly from the 100,000-row figures: 275 MB for SheetStream against 3.3 GB (MiniExcel), 3.9 GB (EPPlus) and 11.6 GB (ClosedXML, NPOI XSSF).
Measured on 2026-10-14. Same job as Million (1 warm-up, 3 measured writes). The whole suite took 2 h 3 min, almost all of it NPOI XSSF.
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|---|
| SheetStream | 11.348 s | 11.8447 s | 0.6492 s | 1.00 | 0.07 | 74000.0000 | - | - | 443.17 MB | 1.00 |
| 'SheetStream (Fastest zip)' | 5.047 s | 3.6996 s | 0.2028 s | 0.45 | 0.03 | 74000.0000 | - | - | 443.17 MB | 1.00 |
| 'EPPlus 4.5.3.3' | 29.570 s | 1.6688 s | 0.0915 s | 2.61 | 0.13 | 642000.0000 | 34000.0000 | 5000.0000 | 5259.79 MB | 11.87 |
| ClosedXML | 19.268 s | 4.8145 s | 0.2639 s | 1.70 | 0.09 | 1485000.0000 | 32000.0000 | 4000.0000 | 10397.54 MB | 23.46 |
| MiniExcel | 6.766 s | 0.2649 s | 0.0145 s | 0.60 | 0.03 | 674000.0000 | 313000.0000 | 14000.0000 | 4197.88 MB | 9.47 |
| 'NPOI XSSF' | 1,274.623 s | 3,264.9381 s | 178.9623 s | 112.57 | 14.87 | 1587000.0000 | 585000.0000 | 14000.0000 | 11465.85 MB | 25.87 |
| 'NPOI SXSSF' | 20.747 s | 17.8856 s | 0.9804 s | 1.83 | 0.12 | 1176000.0000 | 392000.0000 | 12000.0000 | 7777.04 MB | 17.55 |
Ten million cells laid out wide cost SheetStream about the same as laid out long (5.0 s against 4.8 s with Fastest; 443 MB against 275 MB allocated, the difference being the 10,000 cached column references per row). NPOI XSSF needs 21 minutes per write for this shape: its in-memory column handling is quadratic in the column count.
Measured on 2026-10-14. Job: InvocationCount=1 IterationCount=10 LaunchCount=1 UnrollFactor=1 WarmupCount=3. Every reader reads the same 5.8 MB file (100,000 rows x 10 columns, written by SheetStream) from a fresh MemoryStream and returns the same checksum.
| Method | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Gen2 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|---|
| 'SheetStream ReadRows' | 532.7 ms | 80.35 ms | 42.02 ms | 1.01 | 0.10 | 19000.0000 | - | - | 118.62 MB | 1.00 |
| 'SheetStream Read' | 598.2 ms | 150.27 ms | 89.42 ms | 1.13 | 0.18 | 22000.0000 | - | - | 133.88 MB | 1.13 |
| 'MiniExcel Query' | 1,829.3 ms | 13.25 ms | 8.76 ms | 3.45 | 0.25 | 396000.0000 | 1000.0000 | - | 2369.07 MB | 19.97 |
| 'MiniExcel Query (dynamic)' | 1,859.1 ms | 55.72 ms | 36.85 ms | 3.51 | 0.26 | 400000.0000 | 1000.0000 | - | 2395.38 MB | 20.19 |
| ExcelDataReader | 915.9 ms | 5.29 ms | 3.50 ms | 1.73 | 0.13 | 35000.0000 | - | - | 211.03 MB | 1.78 |
| ClosedXML | 3,034.0 ms | 89.30 ms | 53.14 ms | 5.73 | 0.43 | 265000.0000 | 36000.0000 | 2000.0000 | 1598.17 MB | 13.47 |
| 'EPPlus 4.5.3.3' | 1,205.8 ms | 17.18 ms | 11.36 ms | 2.28 | 0.17 | 115000.0000 | 17000.0000 | 3000.0000 | 787.26 MB | 6.64 |
| 'NPOI XSSF' | 3,622.8 ms | 207.26 ms | 123.34 ms | 6.84 | 0.55 | 192000.0000 | 85000.0000 | 5000.0000 | 1191.55 MB | 10.05 |
| 'NPOI SAX' | 2,664.7 ms | 331.53 ms | 197.29 ms | 5.03 | 0.51 | 304000.0000 | 1000.0000 | 1000.0000 | 1883.08 MB | 15.88 |
SheetStream reads the file fastest (0.53 s untyped, 0.60 s mapped to objects) and allocates the least; ExcelDataReader is the closest at 0.92 s and 211 MB, EPPlus 4 takes 1.2 s and 787 MB, and MiniExcel, which is quick at writing, takes 1.8 s and 2.4 GB to read. The SheetStream allocation (1.2 KB per row) is the boxed object?[] per row plus the strings; a typed Read<T> with a generated mapper avoids the reflection but still boxes cell values today.
Measured on 2026-10-14 with dotnet run -c Release -- --peak Million VeryWide Read (the whole run took about two hours; NPOI XSSF alone needs 32 minutes per VeryWide write). Median of 3 fresh processes.
Million: 1,000,000 rows x 10 columns
| Library | Peak working set (MB) | Above baseline (MB) | Cold write time (ms) | Output size (KB) | Full-size rows verified |
|---|---|---|---|---|---|
| Baseline | 121.1 | - | - | - | - |
| SheetStream | 200.4 | 79.3 | 13,303 | 58,079 | yes (1,000,001 rows x 10 cells) |
| SheetStream-Fastest | 212.3 | 91.1 | 13,628 | 70,121 | yes (1,000,001 rows x 10 cells) |
| EPPlus 4.5.3.3 | 1548.1 | 1427.0 | 24,529 | 56,226 | yes (1,000,001 rows x 10 cells) |
| ClosedXML | 2378.4 | 2257.3 | 55,943 | 60,542 | yes (1,000,001 rows x 10 cells) |
| MiniExcel | 220.7 | 99.6 | 7,327 | 74,788 | yes (1,000,001 rows x 10 cells) |
| NPOI XSSF | 5025.6 | 4904.5 | 64,764 | 53,480 | yes (1,000,001 rows x 10 cells) |
| NPOI SXSSF | 225.2 | 104.1 | 29,035 | 55,180 | yes (1,000,001 rows x 10 cells) |
SheetStream's 79 MB above baseline is mostly the 58 MB output buffer: the writer itself holds a few MB. The in-memory libraries need 1.4 GB (EPPlus), 2.3 GB (ClosedXML) and 4.9 GB (NPOI XSSF) for the same file.
VeryWide: 1,000 rows x 10,000 columns
| Library | Peak working set (MB) | Above baseline (MB) | Cold write time (ms) | Output size (KB) | Full-size rows verified |
|---|---|---|---|---|---|
| Baseline | 100.3 | - | - | - | - |
| SheetStream | 160.1 | 59.8 | 16,680 | 45,908 | yes (1,000 rows x 10,000 cells) |
| SheetStream-Fastest | 166.0 | 65.7 | 9,942 | 52,144 | yes (1,000 rows x 10,000 cells) |
| EPPlus 4.5.3.3 | 1489.5 | 1389.2 | 72,930 | 49,690 | yes (1,000 rows x 10,000 cells) |
| ClosedXML | 1555.6 | 1455.3 | 48,753 | 48,254 | yes (1,000 rows x 10,000 cells) |
| MiniExcel | 180.4 | 80.1 | 14,691 | 49,460 | yes (1,000 rows x 10,000 cells) |
| NPOI XSSF | 4842.2 | 4741.9 | 1,918,212 | 47,748 | yes (1,000 rows x 10,000 cells) |
Read: 100,000 rows x 10 columns, the same 5,798 KB file for every reader (--peak Read, 2026-10-14)
| Library | Peak working set (MB) | Above baseline (MB) | Cold read time (ms) | Input size (KB) | Full-size checksum verified |
|---|---|---|---|---|---|
| Baseline | 74.1 | - | - | 5,798 | - |
| SheetStream-Rows | 76.7 | 2.6 | 823 | 5,798 | yes (checksum 5,001,050,000) |
| SheetStream-Typed | 77.4 | 3.3 | 890 | 5,798 | yes (checksum 5,001,050,000) |
| MiniExcel-Typed | 83.7 | 9.6 | 2,896 | 5,798 | yes (checksum 5,001,050,000) |
| MiniExcel-Dynamic | 84.9 | 10.8 | 2,878 | 5,798 | yes (checksum 5,001,050,000) |
| ExcelDataReader | 80.4 | 6.3 | 1,742 | 5,798 | yes (checksum 5,001,050,000) |
| ClosedXML | 187.3 | 113.2 | 4,876 | 5,798 | yes (checksum 5,001,050,000) |
| EPPlus 4.5.3.3 | 179.5 | 105.5 | 1,841 | 5,798 | yes (checksum 5,001,050,000) |
| NPOI XSSF | 853.7 | 779.7 | 4,926 | 5,798 | yes (checksum 5,001,050,000) |
| NPOI SAX | 161.2 | 87.2 | 4,038 | 5,798 | yes (checksum 5,001,050,000) |
Streaming readers (SheetStream, MiniExcel, ExcelDataReader) stay within a few MB of the baseline; the in-memory loaders need 100 MB to 780 MB for a 5.8 MB file. The NPOI SXSSF row for VeryWide is missing from the first --peak run and will be added with the next full run.
| Library | Header bold | Price cell type / format | Date cell type / format |
|---|---|---|---|
| SheetStream (both) | yes | number / #,##0.00 (built-in 4) |
number / yyyy-mm-dd |
| EPPlus 4.5.3.3 | yes | number / #,##0.00 (built-in 4) |
number / yyyy-mm-dd |
| ClosedXML | yes | number / #,##0.00 |
number / yyyy-mm-dd |
| MiniExcel | no | number / #,##0.00 |
number / yyyy-mm-dd |
| NPOI XSSF / SXSSF | yes | number / #,##0.00 (built-in 4) |
number / yyyy-mm-dd |
Built-in number format 4 is #,##0.00, so every library stores the same values with the same number formats. The exceptions are in the header and sheet extras:
- MiniExcel cannot make the header bold.
OpenXmlHeaderStyleoffers only a background colour, wrap and alignment. Its default header style is a grey fill with borders. - By default, MiniExcel also adds an auto-filter and freezes the first row on the Long sheet.
Both are small amounts of extra XML and were left at MiniExcel's defaults.
Every reader returns the same checksum (1,775 for 50 rows: the Ids 1..50 sum to 1,275, plus 500 non-empty cells), which is also what the generator predicts. The full 100,000-row file gives 5,001,050,000 for every reader in the --peak Read children.
- Speed at default settings: MiniExcel beats SheetStream in both scenarios.
- Long: 596 ms vs 1,013 ms. Wide: 579 ms vs 984 ms.
- Most of the gap is zip compression. SheetStream defaults to
CompressionLevel.Optimaland writes the smallest or near-smallest files. MiniExcel's files are 28% larger on Long (7,421 KB vs 5,798 KB) and 17% larger on Wide (5,097 KB vs 4,346 KB). - MiniExcel's file sizes are close to what SheetStream writes at
Fastest, which suggests MiniExcel compresses at a faster level. That is an inference from the output sizes; we did not inspect MiniExcel's source. - With the same trade-off (
Fastest), SheetStream has the lowest mean time in both scenarios: 534 ms on Long and 460 ms on Wide. - On Long, the gap between SheetStream (Fastest zip) and MiniExcel (534 vs 596 ms) is inside the error bars. Call it a tie. On Wide, SheetStream (Fastest zip) is ahead (460 vs 579 ms), but SheetStream's Wide runs are the noisiest in the suite.
- Smallest file: not SheetStream on Long.
- NPOI XSSF writes the smallest Long file (5,304 KB), then NPOI SXSSF (5,518 KB) and EPPlus (5,601 KB). SheetStream comes fourth at 5,798 KB.
- On Wide, SheetStream is the smallest (4,346 KB), with both NPOI variants within 0.2%.
- For context, NPOI XSSF takes 4.6x (Long) and 11.6x (Wide) as long as SheetStream to write those files.
- Allocations: SheetStream allocates the least by a wide margin.
- Long: 26.7 MB, against 343 MB for MiniExcel (next lowest) and 1.1 to 1.2 GB for ClosedXML and NPOI XSSF.
- Wide: 38.8 MB, against 401 MB for MiniExcel.
- SheetStream's allocation is identical at both compression levels. Compression changes time, not allocation.
- Peak memory: SheetStream is lowest in both scenarios. MiniExcel is close; the in-memory models are not.
- Above the baseline, SheetStream holds about 30 MB on Long and 18 MB on Wide, and roughly the output size (4 to 6 MB) of that is the output buffer itself.
- MiniExcel holds 37 and 27 MB. Both libraries stream.
- EPPlus, ClosedXML and NPOI XSSF build the whole workbook in memory first: 170 to 545 MB above baseline.
- NPOI SXSSF streams rows to a temp file but still reaches 65 to 105 MB, and it pays in disk I/O.
- EPPlus 4.5.3.3 is the fastest of the in-memory models on both scenarios: 2.3x and 1.9x SheetStream's time.
- NPOI XSSF on Wide (11.1 s) is the one pathological case in the suite. On this shape, use SXSSF (2.1 s) instead.
- The read benchmarks read a file that SheetStream wrote. Every reader gets the same bytes, but a file from Excel or another writer could lay out shared strings, styles or cell references differently, and a reader tuned for those files might rank differently. The 10-column file is also small enough to fit in memory many times over; a reader's advantage on files that do not is not measured here.
- The read checksum needs the
Idvalue and a null check per cell. For the typed readers (Read<T>,Query<T>) that means the mapping cost of all 10 properties, which is the price of using the typed API; the untyped readers only touch the values they are handed. - The output stays in memory. Writing to a
FileStreamadds the same disk cost to every library, except NPOI SXSSF, which already does disk I/O for its temp file. - Million and VeryWide use a lighter job (1 warm-up, 3 measured iterations) than Long and Wide, so their error bars are wider; treat differences of a few percent as noise.
- MiniExcel's Wide path boxes every double into a per-row dictionary. That is MiniExcel's documented shape for dynamic columns, but it explains much of its Wide allocation. An
IDataReadersource might allocate less; it was not measured. - Every
GlobalSetupruns the small correctness check for all libraries, so each benchmark process has every library's assemblies loaded and JIT-warmed. This affects neither the timed region nor the allocation figures.