XLBench β .NET Excel Library Benchmarks
Independent read/write performance and memory benchmarks comparing popular .NET Excel libraries, all consumed via NuGet and run on .NET 10 with BenchmarkDotNet.
π Latest results (tables + static charts) β
π Interactive charts β
π€ CI results (indicative) β
Libraries under test
| Library | Package | Version |
|---|---|---|
| ClosedXML | ClosedXML |
0.105.1 |
| EPPlus | EPPlus |
8.6.3 |
| OpenXML SDK | DocumentFormat.OpenXml |
3.5.1 |
| NPOI | NPOI |
2.8.0 |
| MiniExcel | MiniExcel |
1.45.0 |
| XLibur | XLibur.Bundle |
0.300.0 |
| IronXL | IronXL.Excel |
2026.8.1 |
Library links point at each projectβs source repository, except IronXL, which is closed source β that one goes to the product page.
Scenarios
- Read β
OpenAndReadAll(open + read every cell) over a 50,000 Γ 15 sheet; every library reads the exact same.xlsxbytes. Alongside it,OpenAmendPropertiesAndSaveis a metadata round trip on a smaller purpose-built 1,000 Γ 8 numeric sheet: open, set the documentTitleandCategory, save back out. It uses the smaller workbook on purpose β at 750,000 cells the serialization would bury the part being measured. - Write β
CreateAndSavebuilds a 50,000-row sheet (string / number / date columns plus aSUMtotal) and serializes it to a stream. - Report β
CreateStockReportimports 20 tickers Γ 260 weekly closing prices from JSON (five years, 5,200 records), lays them out as a 261 Γ 22 sheet, conditionally formats every price green or red against the prior weekβs close, auto-fits the week-ending column, and plots all 20 symbols as a line chart. Feature-bound, not volume-bound. - Edit β
EditAndRecalculateopens a prepared 500 Γ 20 workbook (each row totalled by aSUM(A:T)in column U, every second row bold), deletes every third data row, sets column A to20on every survivor, and recalculates the row totals. Deleting a row means shifting everything below it and rewriting theirSUMranges, so this is where the cost of maintaining a cell model shows up. Every library produces the same 335-row result. - Insert β
InsertColumnsAndRecalculateopens that same prepared workbook, deletes nothing, inserts 2 columns before column B, writes10into both on all 500 data rows, and recalculates. The new columns land inside the totalled range, so everySUM(A:T)has to come back asSUM(A:V)and take them in. Where the edit scenario re-points every formula 166 times, this is one structural edit with a single workbook-wide reference fixup β read the two together.
Report scenario β capability matrix
Not every library can express this scenario; a library that skips a feature is doing strictly less work, so read its timing against this table.
| Library | Import + grid | Conditional formatting | Auto-fit column | Chart |
|---|---|---|---|---|
| ClosedXML | β | β | β | β no public API |
| EPPlus | β | β | β | β |
| OpenXML SDK | β | β οΈ hand-authored | β οΈ estimated width | β οΈ hand-authored |
| NPOI | β | β | β | β οΈ titled, invalid XML |
| MiniExcel | β | β | β | β (not benchmarked) |
| XLibur | β | β | β | β |
| IronXL | β | β οΈ font colour only | β | β |
Edit scenario β capability matrix
| Library | Open + edit in place | Delete row (shift + reference fixup) | Calculation engine |
|---|---|---|---|
| ClosedXML | β | β
IXLRow.Delete() |
β |
| EPPlus | β | β
DeleteRow() |
β |
| OpenXML SDK | β οΈ hand-authored | β οΈ hand-authored | β sums computed by the benchmark |
| NPOI | β | β οΈ RemoveRow + ShiftRows |
β |
| MiniExcel | β | β | β (not benchmarked) |
| XLibur | β | β
IXLRow.Delete() |
β |
| IronXL | β | β
RemoveRow() |
β |
Insert scenario β capability matrix
| Library | Open + edit in place | Insert columns (shift + reference fixup) | Calculation engine |
|---|---|---|---|
| ClosedXML | β | β
IXLColumn.InsertColumnsBefore() |
β |
| EPPlus | β | β
InsertColumn(from, count) |
β |
| OpenXML SDK | β οΈ hand-authored | β οΈ hand-authored | β sums computed by the benchmark |
| NPOI | β | β
XSSFSheet.ShiftColumns() |
β |
| MiniExcel | β | β | β (not benchmarked) |
| XLibur | β | β
IXLColumn.InsertColumnsBefore() |
β |
| IronXL | β | β
InsertColumns(index, count) |
β |
All 500 row totals in every saved artifact are checked against the source CSV on each
dotnet run -- insert, read out of the package XML rather than through a library.
Caveats
- Timings are hardware-specific. The allocation and GC columns are the most portable signal when comparing across machines.
- OpenXML SDK and MiniExcel are streaming APIs with no eager βload workbookβ step, so they only appear in the read-all scenario.
- All five libraries in the properties round trip persist both properties, but not to the same
place. Four write the OPC core-properties part the package already pointed at (
.psmdcp); EPPlus writes the conventionaldocProps/core.xmland leaves the inherited relationship behind, so its output declares two core-properties relationships where OPC allows one. Excel reads it; a strict relationship-following reader gets the part without the title. See the README. - MiniExcel has no formula engine; its write total is a pre-computed value rather than a
SUM()formula. It supports neither conditional formatting nor charts, so it sits out the report scenario entirely, and it has no cell model to open and mutate, so it sits out the edit and insert scenarios too. All other differences are noted inline in the source. - The OpenXML SDK has no calculation engine, so in the edit and insert scenarios its βrecalculationβ is a sum the benchmark computes and writes into the cached value itself. It is also the only library there doing a single ordered pass with no model to maintain β read its timing with both facts in mind.
- ClosedXML is the only library that does not persist what it recalculated: a plain
SaveAswrites each formula cell with no cached value, leaving the totals to Excel on open. That affects the saved artifacts, not the timings β the insert scenarioβs artifact save opts intoevaluateFormulae: trueso its file carries the numbers like the others do. - IronXL is licence-gated. It is commercial and cannot run without a key, so a run that lacks one replays its rows and chart points from a previously captured run instead β marked β§, and named with the version and capture date it came from. The current results carry no such marks: every number on the page, IronXL included, was measured in one run on one machine. See the README for detail.
Results are produced by running the suite locally and committing the generated markdown β see the repository README for how to reproduce.