Idiomatic C# facade over NPOI for creating and reading .xlsx spreadsheets.
Status: v1.0.0 released 2026-05-20. The public surface is exercised by 434 tests per TFM × 2 TFMs = 868 total runs per CI build across unit, golden-file, and public-API snapshot suites. The CHANGELOG has slice-level granularity all the way back to the initial scaffold. Future surface additions land via the standard PublicAPI.Unshipped.txt → PublicAPI.Shipped.txt flip at the next tagged release; the v1.1 roadmap lists the next batch of common asks (Tables, data validation, image embedding, fuzz harness, strict-concurrency opt-in).
Targets net8.0 and net10.0 (both LTS). MIT-licensed.
NPOI is the only complete OOXML implementation for .NET, but its API is a Java port — it shows. NetXlsx is a thin, opinionated layer on top that:
- Adds fluent ergonomics.
sheet.Range("A1:C1").Value("header").Apply(new CellStyle { Bold = true })instead of the multi-step NPOI dance. - Deduplicates styles automatically. A single internal pool keyed off
CellStylevalue equality. Avoids NPOI's 64K-style cap that bites every team writing many-colored reports (spike-measured at 60–64K — this is a correctness fix, not just polish). - Generates typed export/import at compile time.
[Worksheet]on a record gets yousheet.AddRows<T>(items)/sheet.ReadRows<T>()via source generator. No reflection at runtime, AOT-safe in principle. - Doesn't hide NPOI. Every public type exposes
.Underlyingreturning the rawXSSF*(orSXSSF*for streaming) handle. The facade is additive over NPOI, not a sandbox. - Splits streaming from random-access at the type level.
Workbook.CreateStreaming()returnsIStreamingWorkbook— not the same type as the random-access one. Random-access members are absent from the streaming surface because they'd lie. (Looking at you, EPPlus.)
| NetXlsx | ClosedXML | EPPlus | MiniExcel | |
|---|---|---|---|---|
| Engine | wraps NPOI | own OOXML impl | own OOXML impl | own OOXML impl |
| License | MIT | MIT | Commercial (since 5.0) | Apache-2.0 |
.xls (legacy) |
no (explicit Never) |
no | no | yes |
| Streaming write | yes (SXSSF) | partial | yes | yes |
| Typed export via source gen | yes | no (reflection) | no (reflection) | yes |
| Style auto-dedup | yes (required for correctness past 60K cells) | yes | yes | n/a |
| Formula evaluation | no (explicit Never) |
yes (limited) | yes | no |
| Escape hatch to raw OOXML | yes (.Underlying) |
partial | partial | no |
Pick NetXlsx if you're already using NPOI and want better ergonomics, you write large styled reports (the dedup pool is real), or you want compile-time-checked typed mapping without runtime reflection. Pick ClosedXML if you need formula evaluation or want a non-NPOI engine. Pick MiniExcel if you need .xls support.
⚠ Not compatible with
PublishAot=trueorPublishTrimmed=true. The engine (NPOI 2.7.x) usesSystem.Xml.SerializationandSystem.Reflection.Emitpaths that AOT and trim cannot satisfy — measured by spike 4. A build-time guard ships with the package: setting either property emits MSBuild errorNXLS0100/NXLS0101. The block will lift when NPOI removes those dependencies (track NPOI 3.x).
Not thread-safe. NPOI is not thread-safe; this facade does not lock. Concurrent mutation produces undefined behavior. A best-effort reentry-counter detection per design decision #43 is in place — concurrent
AddSheet/mutator calls may surface asInvalidOperationException. The detection is opportunistic, not a lock; do not rely on it to make concurrent use safe.
IColumn.AutoSize()requires font metrics. On headless Linux withoutlibgdiplus+ a fallback font (e.g. DejaVu),AutoSize()throwsMissingFontExceptionwith install commands (design decision I3). The deterministic alternative isIColumn.Width(double)with an explicit width.
using NetXlsx;
using var wb = Workbook.Create();
var sheet = wb.AddSheet("Sales");
sheet["A1"].SetString("Region");
sheet[1, 2].SetString("Revenue"); // (row, col), 1-based — A1 == [1,1]
sheet.Row(2).Set(1, "North").Set(2, 1234.56m);
await wb.SaveAsync("sales.xlsx");
using var read = await Workbook.OpenAsync("sales.xlsx");
var name = read["Sales"]["A2"].GetString(); // "North"
var rev = read["Sales"]["B2"].GetNumber(); // 1234.56// Row-level fluent setters (one Set overload per supported scalar type).
sheet.AppendRow().Set(1, "Total").Set(2, 9999.99m);
// Rectangular range — sparse default enumeration; dense via EnumerateAll().
sheet.Range("A1:C1").Value("header").Apply(new CellStyle { Bold = true });
sheet.Range(2, 1, 10, 3).Value(0); // fill 9x3 with zeros
// Column-level operations.
sheet.Column("B").Width(20);
sheet.Column("C").SetDefaultStyle(new CellStyle { NumberFormat = NumberFormats.Currency });
sheet.Column("D").Hidden = true;
sheet.Column("E").AutoSize(); // throws MissingFontException on headless-no-fontssheet["A1"].Style(new CellStyle
{
Bold = true,
FontColor = Color.White,
Background = Color.FromHex("#003366"),
HorizontalAlignment = HAlign.Center,
Borders = CellBorders.All(BorderStyle.Thin, Color.Black),
});
sheet["B2"].NumberFormat(NumberFormats.Currency);ICell.Style(...) is a merge — non-null axes of the overlay override the existing style; null axes are left untouched. Equal merged styles share one underlying NPOI style index via an internal pool (decision #4), keeping the file under Excel's 64K-style cap even when many cells differ only by background color.
sheet["A1"].SetDate(new DateOnly(2026, 5, 16));
sheet["B1"].SetDate(DateTime.Now);
sheet["C1"].SetTime(new TimeOnly(9, 30));
sheet["D1"].SetDuration(TimeSpan.FromMinutes(125)); // [h]:mm:ss format
if (sheet["E1"].Kind == CellKind.Error)
var err = sheet["E1"].GetError(); // CellError.DivByZero, .NA, etc.Freeze panes, merges, hidden sheets
sheet.FreezeRows(1);
sheet.MergeCells("A1:C1"); // anchor value preserved; overlaps throw
var hiddenSheet = wb.AddSheet("Hidden");
hiddenSheet.Hidden = true;
sheet.ShowGridlines = false;// Use streaming once you're past ~30k rows — spike-2-measured threshold
// where in-memory writes exceed the design's memory budget.
await using var wb = Workbook.CreateStreaming(new StreamingOptions { RowAccessWindowSize = 1_000 });
var sheet = wb.AddSheet("BigData");
for (int r = 1; r <= 1_000_000; r++)
sheet.AppendRow().Set(1, r).Set(2, $"row-{r}").Set(3, r * 1.5);
await wb.SaveAsync("big.xlsx");IStreamingWorkbook is a deliberately narrower contract — random-access members are absent because they'd lie once a row is flushed past the window.
sheet["A1"].SetNumber(10);
sheet["A2"].SetNumber(20);
sheet["A3"].SetFormula("=SUM(A1:A2)"); // leading '=' optional
wb.AddNamedRange("MonthlySales", "Data!$A$1:$A$12");
sheet["B1"].SetFormula("=SUM(MonthlySales)"); // readable formulas via named rangesFormula evaluation is intentionally out of scope — Excel and other competent consumers recalculate on open. NetXlsx never pre-computes cached values (design §7.8).
sheet["A1"].Comment("Reviewer flagged for follow-up"); // default author "NetXlsx"
sheet["B2"].Hyperlink("https://example.com", display: "Docs"); // sniffed scheme[Worksheet]
public partial record SalesRow(
[property: Column("Region")] string Region,
[property: Column("Revenue", Format = NumberFormats.Currency)] decimal Revenue);
using var wb = Workbook.Create();
var sheet = wb.AddSheet("Sales");
sheet.AddRows(records); // generator emits the body
foreach (var row in read["Sales"].ReadRows<SalesRow>())
Console.WriteLine($"{row.Region}: {row.Revenue:C}");The generator (NetXlsx.SourceGen) emits stable diagnostic IDs NXLS0001–NXLS0099 for invalid [Worksheet] / [Column] usage; build-time guards under NXLS0100–NXLS0199 cover AOT/trim incompatibility.
When a wrapped operation does not yet exist, every public type exposes its raw NPOI counterpart:
XSSFWorkbook raw = workbook.Underlying;
XSSFSheet rawSh = sheet.Underlying;
XSSFRow rawR = row.Underlying;
XSSFCell rawC = cell.Underlying;This is by design (#1, #32) — the facade is additive over NPOI, not a sandbox around it.
See samples/NetXlsx.Cookbook for 13 worked recipes covering every public-surface area; each recipe doubles as a golden-file test.
- Design — 52 foundational + 22 implementation decisions, full v1.0 interface sketch, performance targets, behavioral specifications, quality gates.
- Roadmap — binary feature matrix v1.0 / v1.1 / v2.0 / v3.0 / Never, per-release DoD, process rules.
- Implementation notes — patterns and lessons from the implementation phase (not yet a methodology — see file header).
- Scheduled spikes — quarterly re-checks (e.g. NPOI AOT/trim posture, next due 2026-08-16).
- NPOI workarounds — catalog of NPOI quirks the facade routes around (currently empty by design).
- Pre-impl spikes — measured outcomes for style-dedup feasibility, streaming back-pressure, async wrapping cost, and AOT/trim posture.
- Changelog — Keep-a-Changelog format, slice-level entries with public-API deltas.
src/ NetXlsx, NetXlsx.SourceGen
tests/ NetXlsx.Tests, NetXlsx.GoldenFiles, NetXlsx.PublicApi
benchmarks/ NetXlsx.Benchmarks (BenchmarkDotNet)
samples/ NetXlsx.Cookbook (13 worked recipes)
spikes/ NetXlsx.AotSpike + Spike{1,2,3} harnesses + results/
build/ build.sh / build.ps1 (local + CI entry points)
docs/ design, roadmap, implementation-notes, scheduled-spikes, npoi-workarounds
build/build.sh # restore + build + test + pack
build/build.sh test # tests only
build/build.sh bench # run benchmarks
build/build.sh -- spike-1 # run a specific pre-impl spikePowerShell equivalent: build/build.ps1. Both scripts auto-detect a user-level .NET install under ~/.dotnet and prefer it over a system install (useful if your system SDK is older than 10.x or if you maintain multiple SDK versions side-by-side).
SDK requirement: building from source needs the .NET 10 SDK (global.json pins it with rollForward: latestFeature). The .NET 8 + 9 runtimes are needed for the test matrix. See CONTRIBUTING.md for install pointers.
Issues and pull requests welcome. The project follows a deliberate design-then-implement loop: substantive new API surface should be discussed against docs/design.md before code lands. The public-API analyzer gates additions at compile time.
MIT — see LICENSE.