Skip to content

0001 — Tabular files are read by our own cursor, not by a parsing library

Date: 2026-08-24 Status: Accepted

Context

This library reads an Excel or CSV file, profiles its columns, and extracts it through a mapping a user builds. Analysis reads the file completely — the questions it answers are falsified by a single row anywhere — so the reader underneath is not an implementation detail. It was chosen by measurement, behind three gates taken in order: licence, correctness, speed.

The measurements below come from tests/TriasDev.Tabular.Tests/Spike (since removed; the candidates remain in git history) and benchmarks/TriasDev.Tabular.Benchmarks, run on 2026-08-24 against sixteen golden fixtures and against four large real-world files of 17 columns each, from one hundred thousand to five million rows. Every timing ran in its own child process, because peak resident memory only ever rises within a process and would otherwise credit each candidate with the greediest earlier one's peak.

Gate one — licence

Verified from the published .nuspec of each package on the day of the spike rather than from memory, because these terms change retroactively: EPPlus moved to Polyform Noncommercial.

NPOI changed at 2.8.0. Versions 2.6.2 through 2.7.5 declare Apache-2.0 as a licence expression; 2.8.0 ships OSMFEULA.txt, an Open Source Maintenance Fee Agreement. The source remains Apache-2.0 and the fee is explicitly not a licence fee, but using the NuGet binary obliges revenue-generating users with at least US$10,000 annual gross revenue to pay a monthly maintenance fee. NPOI was therefore measured, at 2.7.5, as a baseline and was never a candidate: a dependency that can never be updated is not one to adopt.

ClosedXML (MIT) was excluded on technical grounds — it materialises the workbook — and SpreadCheetah (MIT) is a writer, kept only to generate fixtures. Sylvan.Data.Excel, Sylvan.Data.Csv, ExcelDataReader, Sep and DocumentFormat.OpenXml are MIT; MiniExcel is Apache-2.0; CsvHelper is MS-PL or Apache-2.0.

Gate two — correctness

Sixteen fixtures, each pinning one behaviour, assembled from raw OOXML and raw bytes rather than through a writer — a well-behaved writer cannot emit the shapes worth pinning.

Two candidates failed for a configuration reason rather than a defect, and were re-run. ExcelDataReader refused every workbook until a code-page provider was registered; Sep merged records and returned quoted text verbatim until Unescape and DisableColCountCheck were set. Both then passed the fixtures they had failed. Their requirements are recorded as findings — a reader that mutates process-wide encoding state to become usable, and a reader whose correctness is off by default, are both things a host has to know — but not as failures.

MiniExcel failed on merit: it ignores workbookPr/@date1904 and reads the serial 43465 as 2018-12-31 where the workbook means 2023-01-01. The serial was chosen so the error is four years, not one day, and so cannot be mistaken for rounding.

The decisive failures came from real data, not from the fixtures. On a 572 MB real-world export the candidates disagreed about how many records the file even holds — 2,845,485, 5,088,738, or an exception — and none of them raised a warning. The file mixes four shapes in one column:

"77 Main St, Suite 4"   a genuinely quoted field
"C" Road                       starts with a quote but is not a quoted field
";;12,34;...                     a lone quote, never closed
100 21"st AVE                  an inch mark inside an unquoted field

4,571 lines contain a quote and 302 of them carry an odd number. Two fixtures were added from this data, and they split the field:

quote inside an unquoted field text after a closing quote
Sylvan.Data.Csv passes throws
Sep merges records passes
CsvHelper passes passes
Our cursor, before the fix merges records passes

Our prototype had the same defect as Sep: it treated a quote as syntax anywhere in a field rather than only where a field begins, which is what RFC 4180 and Excel both mean. Fixed, it passes both. Among third-party readers only CsvHelper survives real data — and the fastest, leanest candidate on clean input, Sylvan.Data.Csv, cannot open the file at all.

Gate three — speed

These numbers are frozen at the decision, not maintained. They compare the alternatives against the prototype this decision was taken on, which is what an ADR is for. The reader has moved a long way since — see benchmarks and the performance page, which are kept current.

Only survivors were measured. ms is wall clock, allocated is total managed allocation, peak is peak resident set.

File Reader ms allocated peak rows
100k-row workbook (8.6 MB) Sylvan.Data.Excel 1,991 60 MB 98 MB 100,001
ExcelDataReader 2,362 456 MB 93 MB 100,001
MiniExcel 4,046 1,713 MB 78 MB 100,001
NPOI 2.7.5 (baseline) 6,045 2,852 MB 1,749 MB 100,001
Our cursor 2,383 1,321 MB 90 MB 100,001
1M-row workbook (101 MB) Sylvan.Data.Excel 5,672 324 MB 129 MB 1,000,001
ExcelDataReader 9,582 3,381 MB 120 MB 1,000,001
MiniExcel 19,788 15,308 MB 76 MB 1,000,001
NPOI 2.7.5 (baseline) 54,367 20,117 MB 11,298 MB 1,000,001
Our cursor 11,074 10,837 MB 123 MB 1,000,001
3M-row csv (364 MB) Sylvan.Data.Csv 1,825 1,566 MB 57 MB 3,000,001
Sep 1,276 1,429 MB 55 MB 3,000,001
CsvHelper 2,404 1,566 MB 55 MB 3,000,001
Our cursor 1,920 3,306 MB 54 MB 3,000,001
5M-row csv, malformed (572 MB) Sylvan.Data.Csv — — — failed
Sep 4,837 1,962 MB 201 MB 2,845,485 (wrong)
CsvHelper 3,753 2,751 MB 75 MB 5,088,738
Our cursor 3,151 5,685 MB 58 MB 5,088,738

NPOI's numbers are the quantitative form of what reading its prior use showed: 11.3 GB held for a 101 MB file, and the slowest run by a factor of five.

Decision

Both formats are read by a cursor we own, built on the base class library alone — ZipArchive and XmlReader for xlsx, a state machine for csv. The library takes no third-party parsing dependency.

An unterminated quoted field is bounded and reported. A quoted field may span line endings, which is legal and common, but not beyond a configured number of lines. Past that bound the opening quote is treated as literal and the record is re-parsed, and the occurrence is counted in the run's summary as a recovered anomaly.

Consequences

The library depends on the base class library alone, which was one of its goals. It also becomes the only reader measured here that passes all sixteen fixtures across both formats.

The standard we held the candidates to applies to us. Gate two recorded, against ExcelDataReader, that a reader which mutates process-wide encoding state to become usable is something a host has to know about. Our own dialect detector then did exactly that, from a static constructor, to obtain Windows-1252 — invisibly, on the first csv anyone read. It no longer does: the encoding is a table of twenty-seven characters and is decoded here. A criticism worth making of a competitor is worth applying to oneself.

We now own every edge case. The date epoch, custom number formats, inline strings, rich-text runs, sparse rows, encoding detection, delimiter detection and malformed quoting are ours to get right, and the fixtures are what say whether we did. That is the real cost of this decision, and it is paid in tests rather than in dependencies.

On csv the choice costs nothing and gains resilience. Our cursor is the fastest survivor on the malformed 572 MB file and holds the smallest peak of any candidate on both csv fixtures. Encoding detection with a Windows-1252 fallback would have been ours in any case. (Corrected after the decision: this sentence first said no csv library detects the delimiter either, which is wrong — Sep detects the separator by default and CsvHelper offers DetectDelimiter. The decision does not rest on it.)

On xlsx the choice costs speed, and the figure is honest. Sylvan.Data.Excel is roughly twice as fast and allocates a twentieth of what our prototype does. Two causes are known and already planned against — the shared string table is loaded eagerly, and a string is allocated per cell — but the gap after that work is not yet measured, and this decision does not assume it closes. Peak memory, which is the number that governs whether a reader is usable at all, is already comparable: 123 MB against 129 MB on the million-row workbook.

The condition was met — measured 2026-08-24

The bar below was cleared, and by more than it asked for. Replacing XmlReader with a scanner over the character buffer for the worksheet part alone took the million-row workbook from 668 bytes of allocation per cell to 29, against a budget of 60, and from 11.2 seconds to 4.4 — which is faster than the runner-up's 5.7. Consuming cells without turning any of them into text costs 13.

XmlReader was the whole of the gap: it has no span-returning attribute API, so every cell paid for a string per attribute — r, t, s — whether or not anything read them. Nothing was copied from the runner-up to achieve this; the technique follows from the measurement.

Two notes for whoever revisits these numbers. Bytes per cell rises on smaller files — 69 on a 100,000-row workbook, 66 on an 8,500-row one — because the shared string table is read once and amortised over fewer cells; the budget is stated against the large fixture for that reason. And the scanner is a hand-written reader of a narrow dialect: the golden fixtures could not have caught its first real defect, because a small fixture never fills a buffer, so tests that force a compaction and a growth were added alongside it.

The decision carries a condition, and the condition has a number. Expressed per cell, so it can be compared across fixtures, the million-row workbook costs our prototype about 670 bytes of allocation per cell against Sylvan's roughly 20. The optimisation work — a lazily indexed shared string table, a reused row buffer, and no string materialised for a cell nobody asked for — must bring that within three times the runner-up, so at most 60 bytes per cell, measured on the same fixture and recorded here. If it does not, this decision is revisited rather than defended: the cursor's shape exists precisely so that swapping in a Sylvan-backed implementation is one file, and the sixteen fixtures already stand ready to prove the replacement identical.

Sylvan.Data.Excel is the runner-up, and the cursor's shape is what keeps that cheap. Everything that depends on how xlsx is parsed lives behind ITabularCursor; replacing our implementation with a Sylvan-backed one is one file, and the sixteen fixtures already exist to prove the replacement behaves identically.

The comparison is deliberately about parsing, not about typing. Candidates were asked for cells normalised to text, which makes readers that return typed values pay for formatting and readers that return strings pay nothing. The bias favours the string-returning readers, our cursor included, and it is stated here rather than hidden.