Independent performance and memory benchmarks for .NET Excel libraries, consumed via NuGet and run on .NET 10 with BenchmarkDotNet. Results are published as GitHub-flavored markdown to GitHub Pages.
📊 Results: https://jafin.github.io/XLBench/
| Library | NuGet package | Version | Notes | License |
|---|---|---|---|---|
| ClosedXML | ClosedXML |
0.105.1 | High-level cell model | MIT |
| EPPlus | EPPlus |
8.6.3 | Requires a license declaration (non-commercial, set in code) | Polyform Noncommercial 1.0.0 ᴬ |
| OpenXML SDK | DocumentFormat.OpenXml |
3.5.1 | Low-level SAX streaming | MIT |
| NPOI | NPOI |
2.8.0 | Java POI port | Apache-2.0 ᴮ |
| MiniExcel | MiniExcel |
1.45.0 | Streaming, POCO/dynamic oriented | Apache-2.0 |
| XLibur | XLibur.Bundle |
0.310.0 | Bundles the SkiaSharp font engine (auto-registers) | MIT |
| IronXL | IronXL.Excel |
2026.8.1 | Commercial. Runs only with a licence key; otherwise its results are replayed from snapshots/ — see IronXL |
Proprietary EULA |
Library links point at each project's source repository, except IronXL, which is closed source —
that one goes to the product page. Versions are the pinned NuGet package versions (see
src/XLBench/XLBench.csproj); the generated docs/results.md reports the exact resolved
versions from each run via reflection.
Licenses are as declared by each pinned package (<license> in its .nuspec, or the license
file it ships), not as reported by any third-party index. Four are plain permissive terms; the
other three are not, and the distinction matters more than the benchmark numbers if you are
choosing a library for commercial work:
- ᴬ EPPlus — noncommercial by default. From 5.0 the free terms are Polyform Noncommercial,
which permits use only for noncommercial purposes; commercial use needs a paid licence from
EPPlus Software. That is what
Libraries/EpPlusLicense.csdeclares, and it is a licence choice this benchmark repository is entitled to make — it is not a template for your product. - ᴮ NPOI — Apache-2.0 code, fee-bearing binaries. The source is Apache-2.0, but the NuGet
binary release ships an Open Source Maintenance Fee EULA on top: the fee applies to users who
use it in revenue-generating activity with annual gross revenue at or above US$10,000, and
those below are exempt. Accepting it is what
<AcceptNPOIOSMFLicense>in the project file does. Building from source under Apache-2.0 alone avoids the agreement entirely. - IronXL is proprietary throughout — there is no free tier, only a trial. See IronXL.
This is a summary for orientation, read off the packages this repository pins. It is not legal advice, and it can go stale the moment a library relicenses — check the terms yourself before depending on any of them.
Read (50,000 × 15 sheet — every library reads the same .xlsx bytes):
OpenAmendPropertiesAndSave— a metadata round trip on its own, much smaller workbook: open, set the documentTitleandCategory, save back to a stream. Eager-model libraries only. It runs over a purpose-built 1,000 × 8 numeric sheet (PropertiesData) rather than the 50,000 × 15 read file, because the point is the round trip and the metadata write, not throughput — at 750,000 cells serialization would cost hundreds of milliseconds and bury the part being measured. The result read back is the serialized length, so the save cannot be optimized away. (This replaces the formerOpenWorkbook, which measured the load in isolation.)OpenAndReadAll— open, then read every populated cell as a string using each library's idiomatic iteration (e.g. ClosedXML/XLiburCellsUsed(), EPPlusCells, NPOI row enumeration, OpenXML/MiniExcel streaming). RandomCell(row,col)indexer access is deliberately avoided — it is pathologically slow in some libraries and unrepresentative of real usage.
Write (CreateAndSave — 50,000 rows of string/number/date + a SUM total):
- Each library builds and serializes the sheet to a
MemoryStream.
Report (CreateStockReport — a small sheet exercising features rather than volume):
- Imports
src/XLBench/Data/stock_data.json(20 tickers × 260 weekly closes — five years, 5,200 records) and pivots it into a 261-row × 22-column sheet: a header row plus 260 week rows, and two label columns (Week,Week Ending) followed by one price column per symbol — a 260 × 20 price block inC2:V261. - Adds conditional formatting over that price block as one pair of relative-reference
expression rules — green when a week closes above the prior week, red when below. The
applied range is
C3:V261: week 1 is excluded, having no prior week to compare against. - Auto-fits the week-ending column to its contents. This is the one step whose cost is text measurement rather than XML assembly, so it separates libraries that ship a font engine from the raw SDK, which has none.
- Adds a line chart plotting all 20 symbols against the week-ending dates.
Unlike Read/Write, this scenario is deliberately not volume-bound — at 5,742 cells the timings are dominated by how each library models styles, rules and DrawingML, not by throughput. Its real output is the capability matrix below.
Edit (EditAndRecalculate — mutate an existing workbook, then get the formulas right):
- Init, not measured. A canonical
.xlsxis built once fromsrc/XLBench/Data/numbers.csv(the first 500 of its 4,000 rows × 20 numeric columns — see below for why): a header row, then each data row carrying aSUM(A:T)row total in column U, with every second sheet row bold. Every library opens these exact bytes. - Measured. Open the workbook, delete every third data row (sheet rows 4, 7, 10 … — 166
of the 500, leaving 334), set column A to
20on every surviving data row, then recalculate so each row'sSUMreflects both edits. The result read back is the last surviving row's total, whichdotnet run -- editchecks against the value computed straight from the CSV — every library must agree to the last ulp. - Saving the edited workbook is not measured: serialization is what
CreateAndSavealready covers, and folding it in here would bury the difference this scenario exists to show.
The interesting cost is the delete. Removing a row means shifting every row beneath it up and
rewriting their SUM ranges, so a run of non-contiguous deletes is quadratic in every library
that maintains a cell model — and the constant factor varies by more than an order of magnitude
between them. It also separates the libraries that own a calculation engine from the one that
does not. Each library is handed the deletion set through the widest API it offers: a whole-set
call where there is one, a bottom-up loop where there is not.
Insert (InsertColumnsAndRecalculate — the same workbook, widened instead of shortened):
- Init, not measured. The same source workbook as the edit scenario, byte for byte, so the two are directly comparable and only the operation differs.
- Measured. Open the workbook, insert 2 columns before column B, write
10into both of them on all 500 data rows, then recalculate. Nothing is deleted and no existing value is overwritten, so every row total must come back as its original total plus20. - The columns go inside the totalled range, not at its edge, and that is the whole point:
SUM(A2:T2)has to come back asSUM(A2:V2)and pick the new columns up. Inserting at column A would shift the range rather than widen it, which tests nothing. - Saving is not measured, for the same reason as the edit scenario.
Where the delete makes each library re-point every formula 166 times, this is one structural edit
whose reference fixup runs across the workbook exactly once — so read the two together: the edit
scenario is dominated by per-formula cost repeated, this one by the cost of a single pass plus the
1,000 cells written into it. dotnet run -- insert checks it harder than the edit scenario checks
itself: not just the one cell the benchmark reads back, but all 500 row totals in the saved
file, read straight out of the package XML with no library in the way (see
Reviewable output).
Every benchmark uses [MemoryDiagnoser], so allocations and Gen0/1/2 collections are
reported alongside timings. A joined summary adds a Library column so libraries line up
per scenario.
Not every library can express this scenario. Read the timings alongside this table: a library that skips a feature is doing strictly less work.
| Library | Import + grid | Conditional formatting | Auto-fit column | Chart | Artifact valid? |
|---|---|---|---|---|---|
| ClosedXML | ✅ | ✅ | ✅ | ❌ | ✅ schema-clean |
| EPPlus | ✅ | ✅ | ✅ | ✅ | ✅ schema-clean |
| OpenXML SDK | ✅ | ✅ schema-clean | |||
| NPOI | ✅ | ✅ | ✅ | ✅ | |
| MiniExcel | ✅ | ❌ | ❌ | ❌ | — not benchmarked |
| XLibur | ✅ | ✅ | ✅ | ✅ | ✅ schema-clean |
| IronXL | ✅ | ✅ | ✅ |
All five charts carry a legend on the right. EPPlus adds one by default; NPOI, the OpenXML SDK, XLibur and IronXL are each asked for one explicitly.
- ClosedXML — no charts. 0.105.0 ships internal
XLChart/XLChartstypes, but nothing exposes them:IXLWorksheethas noChartsmember, so there is no public API to add one. ItsCreateStockReportnumber therefore covers data + conditional formatting only. - OpenXML SDK — everything by hand. It can do the whole scenario, but the stylesheet,
the differential formats, the
conditionalFormattingelement, the drawing anchor and the entire chart part are authored element by element. That authoring cost — not the runtime — is the finding. - OpenXML SDK — no auto-fit. The raw SDK ships no font or text-measurement engine, so there
is nothing to ask for a fitted width. The benchmark computes one from the longest string the
column holds and writes an explicit
<col width=…>. That is an estimate, not auto-fit, and it is also why itsCreateStockReportnumber skips the text-shaping work the other four do. - NPOI — titled chart, at the cost of a schema-invalid artifact.
XDDFChart.SetTitleTextserializes the title body as<a:rich>where the chart schema requires<c:rich>, andCT_Tx.Writepasses theaprefix unconditionally, so no public API avoids it. The title is set anyway — so NPOI does the same work as the other libraries and its timing stays comparable — which leavesoutput/stock-report-npoi.xlsxwith one validation error, and Excel may offer to repair it. DroppingSetTitleText(or re-addingSetAutoTitleDeleted(true), which discards the title NPOI just built) restores a clean file and an untitled chart. Separately,XDDFLineChartData.SetGroupingthrowsNullReferenceExceptionon a chart NPOI itself created, so<c:grouping>is set through the underlying CT model. - MiniExcel — excluded. It has neither conditional formatting nor charts, so it could only run the data-import third of the scenario. Timing that against libraries doing all three would be actively misleading, so it is left out rather than listed as a fast result.
- XLibur — legends are opt-in. A chart XLibur creates has none until
Legend.Visibleis set, so the report benchmark sets it explicitly. (This scenario used to force a prerelease pin: stable0.106.0wrote every series name as a<c:strRef>with no required<c:f>— 20 schema errors, one per series — and had no legend API at all. Both fixes have since shipped in stable, so the repo tracks the stable channel again from0.200.0.)
| Library | Open + edit in place | Delete row (shift + reference fixup) | Calculation engine | Artifact valid? |
|---|---|---|---|---|
| ClosedXML | ✅ | ✅ IXLRows.Delete() |
✅ | ✅ |
| EPPlus | ✅ | ✅ DeleteRow() |
✅ | ✅ |
| OpenXML SDK | ❌ computed by the benchmark | ✅ | ||
| NPOI | ✅ | RemoveRow + ShiftRows |
✅ | ✅ |
| MiniExcel | ❌ | ❌ | ❌ | — not benchmarked |
| XLibur | ✅ | ✅ IXLRows.Delete() (batched) |
✅ | ✅ |
| IronXL | ✅ | ✅ RemoveRow() |
✅ | ✅ |
Unlike the report scenario, every benchmarked library here does the same work and produces the
same 335-row sheet: A = 20 on every data row, every SUM(A{r}:T{r}) renumbered to its new row,
the bold-row pattern carried up with the shift, and totals that agree to the last ulp. The
timings are therefore directly comparable — with the OpenXML SDK the one caveat noted below.
dotnet run -- edit re-checks all of that against the CSV on every pass.
- ClosedXML and XLibur — same call, different engine. Both take the whole deletion set as
ws.Rows("4,7,10,…").Delete(), so the two benchmarks are the same line of code. ClosedXML groups the set by sheet and then deletes bottom-up one row at a time, which is what the benchmark used to spell out by hand; XLibur (since0.300.0) collapses the set into a single row-deletion map, re-points every formula against it in one pass, and only then removes the rows run by run with the per-run formula pass switched off. Since the formula pass visits every formula in the workbook, doing it once instead of 166 times is most of the delete cost. It falls back to the per-run path for workbooks holding array, data-table or dynamic-array formulas, whose stored ranges the composite pass does not relocate — this one holds none. - NPOI — no delete-and-close-gap call.
ISheet.RemoveRowonly empties the slot; closing it takes a separateShiftRowsover everything below, which is also what rewrites the shifted rows'SUMranges. Two calls per deleted row, each touching every row beneath it. That is the finding rather than an artifact of how the benchmark is written — no bulk API exists to use instead. - OpenXML SDK — everything by hand, and no recalculation at all. There is no
DeleteRow: rows areRemove()d fromSheetDataand every surviving row below then has itsrattribute, each of its cells'CellReference, and itsSUMrange renumbered explicitly. There is also no calculation engine, so the benchmark adds each row up itself and writes the result into the formula cell's cached<v>, drops the now-stalecalcChainpart, and setsfullCalcOnLoadso Excel rebuilds both on open. Read its timing against that: it is the only library here doing a single ordered pass with no model to maintain, and the only one whose "recalculation" is arithmetic the benchmark wrote. - MiniExcel — excluded. It is a streaming reader/writer with no cell model, so there is nothing to open and mutate in place, and no formula support to recalculate. It could not run any part of this scenario.
- Why 500 rows and not the full 4,000 in
numbers.csv. The quadratic delete cost is real: at the full file NPOI takes ~178 s per operation, which is roughly 80 minutes of measured run for this scenario alone. 500 rows keeps the quadratic shape and the ranking between libraries while bringing the whole scenario down to a couple of minutes. The row count is a single constant —EditData.MaxRows— so a deeper run is a one-line change.
| Library | Open + edit in place | Insert columns (shift + reference fixup) | Calculation engine | Totals persisted on save |
|---|---|---|---|---|
| ClosedXML | ✅ | ✅ IXLColumn.InsertColumnsBefore() |
✅ | |
| EPPlus | ✅ | ✅ InsertColumn(from, count) |
✅ | ✅ |
| OpenXML SDK | ❌ computed by the benchmark | ✅ | ||
| NPOI | ✅ | ✅ XSSFSheet.ShiftColumns() |
✅ | ✅ |
| MiniExcel | ❌ | ❌ | ❌ | — not benchmarked |
| XLibur | ✅ | ✅ IXLColumn.InsertColumnsBefore() |
✅ | ✅ |
| IronXL | ✅ | ✅ InsertColumns(index, count) |
✅ | ✅ |
All six libraries do the same work here and produce the same 501-row × 23-column sheet: B and
C = 10 on every data row, every SUM(A{r}:T{r}) widened to SUM(A{r}:V{r}), and totals that
agree to the last ulp. dotnet run -- insert re-checks all 500 of them against the CSV on every
pass, reading the saved file rather than asking the library that wrote it.
- The last column is the point of the fourth column above. A benchmark that recalculates in
memory and saves a file with no cached values has done the arithmetic but not shipped it: the
numbers only exist once Excel opens the workbook and computes them again. Four libraries write
their totals into the file as a matter of course. ClosedXML does not — a plain
SaveAsemits each formula cell as<f>with no<v>— so the artifact save opts in withevaluateFormulae: true. That is outside the measured region and changes no timing; without it the check reports 500 cells with no usable cached value, which is how the difference was found. The same is true of ClosedXML's edit artifact, which this repo has never checked. - NPOI — an insert spelled as a shift. There is no "insert n columns here"; the equivalent is
XSSFSheet.ShiftColumns, moving the block that has to make way. It is a real structural call and not a workaround — internally it builds aFormulaShifterfor the column move and re-points formulas, named ranges, conditional formatting and hyperlinks against it — but it lives on the concreteXSSFSheetrather than onISheet, where its row counterpartShiftRowssits. Note the contrast with the same library's row delete, which has no bulk call at all: here one call covers the whole edit. - OpenXML SDK — everything by hand, and no recalculation at all. There is no
InsertColumn. Every cell from column B rightwards has itsCellReferencerewritten two columns along, the two new cells are spliced into the gap in column order —sheetDatais an ordered element list, so appending them would produce a file Excel rejects — and each row'sSUMrange is widened explicitly. As in the edit scenario there is no calculation engine, so the benchmark adds each row up itself, writes the result into the cached<v>, drops the now-stalecalcChain(whose entries still point at the old column) and setsfullCalcOnLoad. - Styling of the inserted columns is library-defined and deliberately not equalized. Some libraries carry a neighbouring column's style onto the new ones, the hand-written OpenXML path leaves them unstyled, so the bold-row pattern may or may not extend across B and C. Forcing agreement would mean adding per-library styling code to a measured region to paper over a real difference in what each library's insert does.
- MiniExcel — excluded, for the same reason as the edit scenario.
IronXL is implemented for all three scenarios, but only measures when XLBENCH_IRONXL_KEY holds
a valid licence key. Runs without one replay the committed snapshots/ironxl.json instead, so
IronXL stays in the comparison without every contributor needing a key. The findings below were
produced under a time-limited trial key and are reproducible with any valid key.
Requiring a key for every run would drop IronXL out of the comparison entirely, so its results are carried between runs:
- A run with
XLBENCH_IRONXL_KEYmeasures IronXL normally and writes what it measured tosnapshots/ironxl.json— timings, allocations, and the markdown row BenchmarkDotNet rendered, each stamped with the IronXL version, host, job and capture time. - A run without the key skips IronXL and replays that snapshot into the results tables, the static charts, the interactive page and the versions table, every occurrence marked ⧗ with a banner naming the version and date the numbers came from.
Entries are merged per method, so --filter '*Read*' with a key refreshes only the read methods
and leaves the rest of the snapshot intact. Two guards stop a stale snapshot producing a wrong
table row: the column set must still match, and the Mean column must be in the same unit
(BenchmarkDotNet picks units per table). If either differs the row is dropped with a warning,
though the chart data — which is stored numerically in ms — is still used.
# Refresh the snapshot after an IronXL version bump.
$env:XLBENCH_IRONXL_KEY = 'IRONXL.YOURCOMPANY.IRO######.####'
./scripts/run-benchmarks.ps1
# then commit both docs/ and snapshots/ironxl.jsonsnapshots/ironxl.json is committed, so a clone with no key still publishes a complete
comparison. The trade-off is explicit rather than hidden: those numbers came from different
hardware at a different time, the page says so, and the snapshot goes stale until someone with
a key re-runs it. Dependabot bumping IronXL.Excel will not refresh it — that needs a keyed run.
With a key, IronXL runs the whole scenario end to end and produces a complete workbook. Three defects came out of validating the result and diffing its XML against the other five (the artifact was not opened in Excel, so whether Excel offers to repair it is untested):
- Conditional-format fills are silently discarded.
PatternFormatting.BackgroundColoris a hex string that IronXL converts to ashortlegacy-palette index before handing it to the NPOI model underneath. Every colour tried — pastel or primary,#RRGGBBor#AARRGGBB— emits<bgColor indexed="0"/>, i.e. black. The property getter reads the string back out of IronXL's own cache rather than the model, so it round-trips convincingly while the workbook receives none of it. The rule's font colour does apply, so the artifact ends up with green/red text on a black fill. There is no other public API for a conditional-format fill. - The two colour properties disagree on format.
FontFormatting.FontColorwrites the digits through verbatim, so a 6-digit value lands asrgb="006100"and fails schema validation (OOXML requires 4-byte ARGB) — the alpha byte is mandatory.PatternFormatting.BackgroundColordoes the opposite and truncates to 6 digits, reading#FFFF0000(opaque red) back as#ffff00(yellow). Font.Bold = truealone emits invalid XML. Setting nothing but bold on the header row produces<color indexed="8" rgb="000000"/>— again a 6-digitrgbwhere the schema wants 8. An unstyled workbook is clean, so this is IronXL's own doing and no public API avoids it. It is the single validation error inoutput/stock-report-ironxl.xlsx, and the same class of problem as the NPOI chart-title issue above.
Everything else in the scenario works: 20 chart series with a title and a real legend
(SetLegendPosition is honoured — Right emits an empty <c:legendPos/> only because "r" is
the schema default), AutoSizeColumn fits the week-ending column to 12.6 with bestFit="1",
and both conditionalFormatting blocks carry the correct sqref and relative formulas.
- It is NPOI underneath.
IronXL.dllcontains ILMergedNPOI.XSSF.UserModeltypes and its own XML docs cross-referenceNPOI.SS.Formula.Formula. The artifact is 79 KB against NPOI's 79 KB and ClosedXML's 45 KB. Expect NPOI-shaped behaviour and NPOI-shaped output quirks. WorkBookis notIDisposable. The only eager-model library here with no deterministic release of a loaded workbook — relevant at the 50,000 × 15 read size.- No
SaveAs(Stream).ToStream()allocates and returns its ownMemoryStream, so the whole workbook is buffered in memory whatever the real destination is. The write benchmark measures that buffer because there is no way not to. AddSeries(values)throws for line charts —"You must choose categories range for Line chart type together with values". The two-argument overload is mandatory.- Heavy transitive footprint. It pulls in
Grpc.Net.Client,Google.Protobuf,PollyandIronSoftware.System.Drawingfor licensing and telemetry.IronXlLicense.Ensure()callsLicense.DisableAppAnalytics()so the phone-home cannot fold network latency into timings.
The report, edit and insert benchmarks keep their workbooks. Each run writes
output/stock-report-<library>.xlsx, output/numbers-edited-<library>.xlsx and
output/numbers-inserted-<library>.xlsx (git-ignored, overwritten on the next run) so the results
can be opened and eyeballed:
# Just write the artifacts — same code path, no measurement (seconds, not minutes).
dotnet run -c Release --project src/XLBench -- report
dotnet run -c Release --project src/XLBench -- edit
dotnet run -c Release --project src/XLBench -- insert-- edit additionally verifies each library's recalculated row total against the value computed
straight from numbers.csv and prints OK or MISMATCH per library, so the scenario's
correctness can be checked without a measured run. The elapsed time it prints is one cold pass —
indicative only, never a benchmark result.
-- insert goes further, and deliberately: the benchmark reads one cell back, which is enough to
stop the work being optimized away but not enough to call the result correct. So it re-opens each
saved artifact and checks every row — all 500 totals against the CSV, and every SUM range
against the widened form it should have taken. It reads them straight out of the package XML
(Data/SavedSheet.cs) rather than through a library, because checking a library's output by
re-opening it with a library risks the reader evaluating the formula and reporting the answer the
check wanted rather than the one stored in the file. What it prints per library is
500/500 saved row totals verified, or the first few cells that disagree with the reason.
Saving happens in [GlobalCleanup], once per library and outside every measured iteration, by
re-running the build against a FileStream — so file I/O never lands in the timings. If a
workbook is still open in Excel the save is skipped with a warning rather than failing the run.
- OpenXML SDK and MiniExcel are streaming APIs with no eager "load workbook" step, so they
only appear in
OpenAndReadAll. - MiniExcel has no formula engine; its write total is a pre-computed value, not a
SUM(). - MiniExcel cannot open and mutate a workbook in place, so it does not appear in
EditAndRecalculateorInsertColumnsAndRecalculate. - The shared read file, the source workbook the edit and insert scenarios share, and the properties round trip's 1,000 × 8 sheet are generated once with ClosedXML purely as a neutral OOXML producer, outside any measured region.
- All five libraries in
OpenAmendPropertiesAndSavedo the same work and all five persist both properties, but they do not agree on where. ClosedXML, XLibur, NPOI and IronXL write the OPC core-properties part the package relationship already pointed at —package/services/metadata/core-properties/{guid}.psmdcp, which is what anySystem.IO.Packaging-based writer produces. EPPlus instead writes the conventionaldocProps/core.xmland leaves the inherited relationship in place, so its output carries twometadata/core-propertiesrelationships where OPC permits at most one. Both parts exist, only one holds the title and category, and which a consumer honours is undefined — Excel reads it fine, a strict relationship-following reader gets the stale part. That is a property of EPPlus round-tripping a package it did not create, not of the benchmark: a workbook Excel authored has no.psmdcpfor EPPlus to leave behind. - Read timings and allocations both scale linearly with the sheet, which is 50,000 × 15
(750,000 cells). That size is deliberate: it is well past the point where the fixed cost of
opening the package matters, and still heavy enough to push the eager-model libraries into
multi-gigabyte allocation — which is the finding the allocation column is there to carry. A
smaller sheet keeps the ranking but flattens that into per-cell overhead.
TestData.ReadRowCountis one constant if you want a deeper run. - Edit timings are comparable across all six libraries — they produce identical sheets — with the OpenXML SDK the one caveat: it has no calculation engine, so its "recalculation" is a sum the benchmark computes and writes into the cached value itself. The same caveat, and the same comparability, applies to the insert scenario.
- The insert scenario's artifact save is the one place a library gets a flag the others do not:
ClosedXML saves with
evaluateFormulae: trueso the file carries its totals, which the other libraries do without being asked. It is outside the measured region and affects no timing. - The report scenario's JSON is parsed once into a shared week × symbol matrix, also outside any measured region — the deserialization is identical for every library and would only add the same constant to each result.
- Report timings are only comparable within the capability matrix above. ClosedXML emits no chart, and the OpenXML SDK estimates its column width instead of measuring text, so each does less work than a full run of the scenario.
- Auto-fit widths legitimately differ between libraries, because each measures text with its own font engine and padding rule: ClosedXML 12.71, XLibur 13.00, EPPlus 14.53, NPOI 11.16, IronXL 12.6, and the OpenXML SDK's character-count estimate 11.71. A like-for-like width was not forced — the point is what each library does when simply asked to fit the column.
- IronXL's numbers are snapshots, not fresh measurements, unless the run that produced the page had a licence key. They are marked ⧗ wherever they appear and carry the version and date they were captured; treat them as indicative against the rest of the table.
- IronXL's report timing covers the full scenario, but its conditional-format fill never reaches the file (see above). It still pays for building the rule, so the timing stays comparable — the artifact is what differs.
- Every library now runs on its latest stable release. XLibur was previously pinned to a
prerelease because stable
0.106.0could not produce a valid chart for the report scenario;0.200.0can, so that pin is gone and its read and write numbers come from stable too.
Each library is driven the way its own documentation and idiomatic samples suggest, and every benchmark was written to give that library its best shot at the scenario — streaming where a streaming API exists, bulk or idiomatic iteration over per-cell indexer access, the cheapest correct way to express each feature. Where a library forced a workaround, it is documented above rather than quietly folded into the timing.
None of that makes these implementations authoritative. Knowing one of these libraries well is not the same as knowing all seven well, the fastest path through an API is not always the documented one, and a maintainer or heavy user will often spot in seconds something that took a wrong turn here. If a benchmark is doing something clumsy, or missing an API that would produce the same result with less time or fewer allocations, please open a PR.
A change is easiest to accept when it:
- Produces the same result. Same cells, same styles, same chart, same validity — a faster number that quietly drops a feature moves the library in the capability matrix, not the timings.
- Uses public API only. No reflection, no internals, no vendored source. The benchmark should reflect what a consumer of the NuGet package can actually write.
- Says which version it needs. If the better API only exists in a newer release, note it — the pinned versions are deliberate (see XLibur above).
- Comes with a re-run. Regenerate
docs/locally (./scripts/run-benchmarks.ps1) and commit it, since CI numbers are not authoritative. IronXL will replay fromsnapshots/unless you hold a key, which is fine.
Corrections to the prose count too - if a capability-matrix cell or one of the API notes is wrong or out of date, an issue is enough; you don't have to write the code.
- .NET 10 SDK (pinned via
global.json). - Linux only:
libfontconfig1(needed by XLibur's SkiaSharp font engine).
# Full suite (writes docs/results.md), with the tuned warmup/iteration counts below.
./scripts/run-benchmarks.ps1
# Subset
./scripts/run-benchmarks.ps1 -Filter '*Write*'
./scripts/run-benchmarks.ps1 -Filter '*Read*' -Job short
# BenchmarkDotNet's stock defaults — slowest, most trustworthy. Use for published numbers.
./scripts/run-benchmarks.ps1 -FullFidelityOr directly:
dotnet run -c Release --project src/XLBench -- --filter '*'
dotnet run -c Release --project src/XLBench -- --filter '*ClosedXml*' --job shortEvery scenario here runs between 25 ms and 20 s per operation, so BenchmarkDotNet's stock
floors — a 6-iteration minimum warmup and a 15-iteration minimum workload — cost minutes per
benchmark buying accuracy this comparison does not need. The libraries differ by 2–10×, far
outside any plausible confidence interval. run-benchmarks.ps1 therefore lowers the bounds on
both adaptive stages by default:
| Stage | BenchmarkDotNet default | XLBench default |
|---|---|---|
| Warmup | 6 – 50 | 1 – 3 |
| Workload | 15 – 100 | 5 – 10 |
Both stages still stop early once measurements settle, so these move the floor and ceiling rather than pinning a count: a stable benchmark finishes at the minimum, and only a noisy one runs to the maximum. Measured on the edit scenario, this cut the wall clock from 2m34s to 1m05s with means within ~5% of a full-fidelity run, the ranking unchanged, and Error columns 2–3× wider.
That trade is right for comparing libraries and wrong for detecting a few-percent regression
between versions of one library — use -FullFidelity for the numbers you publish after a
dependency bump. Individual bounds are overridable (-MinIterationCount, -MaxWarmupCount, …),
and a -Job preset pins the counts outright and so takes precedence over all of them.
BenchmarkDotNet validates that each maximum exceeds its minimum and fails the run otherwise, so lowering a maximum past the stock minimum means lowering the minimum in the same call.
--maxIterationCount 10on its own is rejected, because the minimum is still 15.
The run publishes a curated docs/results.md (raw BenchmarkDotNet output under
BenchmarkDotNet.Artifacts/ is git-ignored). Commit and push docs/ to update Pages.
Pages is served from the main branch /docs folder
(Settings → Pages → Source: Deploy from a branch → main / /docs). Because CI runners
are noisy, authoritative numbers come from local runs you commit — CI only validates
that the suite builds and executes (see .github/workflows/build.yml). An optional on-demand
full run lives in .github/workflows/benchmark.yml.
- Add the NuGet
PackageReferencetosrc/XLBench/XLBench.csproj. - Add
Read/<Name>ReadBenchmarks.cs,Write/<Name>WriteBenchmarks.csand (where the library can express them)Report/<Name>ReportBenchmarks.cs,Edit/<Name>EditBenchmarks.csandInsert/<Name>InsertBenchmarks.cs, mirroring an existing set. Method names must match —OpenAmendPropertiesAndSave/OpenAndReadAll/CreateAndSave/CreateStockReport/EditAndRecalculate/InsertColumnsAndRecalculate— so the joined summary aligns. - Add a case to
LibraryNameColumninsrc/XLBench/Config/LibraryComparisonConfig.cs. - For a report, edit or insert benchmark, register it in
Data/ReportArtifacts.cs,Data/EditArtifacts.csorData/InsertArtifacts.cssodotnet run -- report/-- edit/-- insertwrites its workbook, and add a row to the matching capability matrix above. - If the library needs credentials to run at all, add it to
SnapshotLibrariesinProgram.csand gate it inBenchmarkConfigthe way IronXL is, so runs without those credentials replaysnapshots/<library>.jsoninstead of failing.
To check every referenced package against nuget.org and bump the outdated ones in one pass:
# Report only — what could move, and to which version.
./scripts/update-libraries.ps1
# Fetch them (via `dotnet add package`, never by hand-editing the csproj).
./scripts/update-libraries.ps1 -Apply
# Narrow the scope.
./scripts/update-libraries.ps1 -Apply -LibrariesOnly
./scripts/update-libraries.ps1 -Package XLibur.Bundle -ApplyThe report separates the Excel libraries under test from tooling/pins, so it is obvious
whether a bump changes the published numbers. Stable releases only, except for a package
already pinned to a prerelease (where the prerelease channel stays in scope) or when
-IncludePrerelease is passed.
Dependabot covers the same ground automatically, opening weekly
PRs for outdated NuGet packages and GitHub Actions. Minor/patch bumps are grouped into one
PR; majors get their own. build.yml validates each PR (build + a Dry smoke run).
Note: merging a Dependabot PR updates the package version and confirms it builds — it does not refresh the published benchmark numbers. After merging a library bump, re-run
scripts/run-benchmarks.ps1(or dispatchbenchmark.yml) and commit the regenerateddocs/so the results reflect the new version.
src/XLBench/
Program.cs # switcher + results publisher (-> docs/)
Config/LibraryComparisonConfig # joined summary, Library column, memory diagnoser, GH export
Data/TestData.cs # shared deterministic dataset (seed 42)
Data/StockData.cs # report-scenario dataset (embedded stock_data.json)
Data/EditData.cs # edit-scenario dataset + source workbook (embedded numbers.csv)
Data/InsertData.cs # insert-scenario layout over the same source workbook
Data/SavedSheet.cs # reads a saved .xlsx's cells from the package XML, no library
Data/LibrarySnapshot.cs # persisted results for licence-gated libraries
Libraries/EpPlusLicense.cs # EPPlus non-commercial license declaration
Libraries/IronXlLicense.cs # IronXL commercial key (opt-in via XLBENCH_IRONXL_KEY)
Benchmarks/Read/* # one class per library
Benchmarks/Write/* # one class per library
Benchmarks/Report/* # one class per library that can express the scenario
Benchmarks/Edit/* # one class per library that can express the scenario
Benchmarks/Insert/* # one class per library that can express the scenario
docs/ # GitHub Pages content (index.md + generated results.md)
snapshots/ # committed results for libraries that need a licence key
scripts/run-benchmarks.ps1 # run the suite and publish docs/
scripts/update-libraries.ps1 # check nuget.org for newer library versions and fetch them