XLSight is a fast, dependency-free Excel reader and analyzer for .NET 10. It supports .xlsx, .xlsm, and .xlsb files.
XLSight reads worksheet XML as UTF-8 bytes. It bypasses XmlReader on hot paths and creates managed strings only when required.
- Reads the NYC 311 workbook with one million rows in 3.38 s with 161 MiB peak RSS: 2.3× faster than
calamine, 5.2× faster thanExcelDataReader, and 6.5× faster thanMiniExcel. - Stops as soon as the caller has read the required N rows. This allows fast sample reads with minimal memory allocation.
Scope: XLSight reads Open XML and binary workbooks. It can inspect VBA metadata without running macros. It does not support legacy
.xlsor.csvfiles. Benchmark tables compare equivalent.xlsxreads unless stated otherwise.
dotnet add package XLSightusing XLSight;
// Open from file path
using var workbookFromFile = ExcelWorkbook.Open("report.xlsx");
// Open from a stream
using var workbookFromStream = ExcelWorkbook.Open(stream);
// Async variants
await using var workbookFromFileAsync = await ExcelWorkbook.OpenAsync("report.xlsx");
await using var workbookFromStreamAsync = await ExcelWorkbook.OpenAsync(stream);
// Read workbook metadata
Console.WriteLine(string.Join(", ", workbookFromFile.SheetNames)); // "Sheet1, Sheet2"
Console.WriteLine(workbookFromFile.IsDate1904);
Console.WriteLine(workbookFromFile.HasMacros);using XLSight;
using var workbook = ExcelWorkbook.Open("report.xlsx");
// Single cell — returns ExcelCellValue directly
ExcelCellValue cell = workbook.ReadCell("Sheet1", "B2");
Console.WriteLine(cell);
// Typed address overload — no string parsing at call site
ExcelCellValue cell2 = workbook.ReadCell("Sheet1", new ExcelAddress(2, 2));
// Addresses are case-insensitive
ExcelCellValue cell3 = workbook.ReadCell("Sheet1", "b2");
// Range — result.Rows gives one ExcelRow per row, consistent with streaming
RangeResult result = workbook.ReadRange("Sheet1", "A1:D10");
foreach (var row in result.Rows)
{
foreach (var c in row.Cells)
{
Console.Write($"{c}\t");
}
Console.WriteLine();
}
// Typed range overload
var range = ExcelRange.Parse("A1:D10");
RangeResult result2 = workbook.ReadRange("Sheet1", range);
// Async equivalents
ExcelCellValue cellAsync = await workbook.ReadCellAsync("Sheet1", "B2");
RangeResult rangeAsync = await workbook.ReadRangeAsync("Sheet1", "A1:D10");Read one row at a time without loading the full sheet. StreamSheet* and StreamRange* return independent row snapshots.
You can retain these rows or use them with LINQ. This is the best default for most consumers:
using XLSight;
await using var workbook = await ExcelWorkbook.OpenAsync("large.xlsx");
await foreach (var row in workbook.StreamSheetAsync("Sheet1"))
{
Console.WriteLine($"Row {row.RowIndex}");
foreach (var cell in row) // ExcelRow is IEnumerable<ExcelCellValue>
Console.Write($"{cell}\t");
Console.WriteLine();
}
// Stream a typed range — no string parsing
var range = ExcelRange.Parse("A1:C1000");
await foreach (var row in workbook.StreamRangeAsync("Sheet1", range))
{
var name = row.GetCell(1); // 1-based column index
var value = row.GetCell(3);
}
// Synchronous streaming — rows are independent; safe to buffer or pass to LINQ
foreach (var row in workbook.StreamSheet("Sheet1"))
{
ReadOnlySpan<ExcelCellValue> cells = row.Cells; // zero-copy span access
}Use GetSheetReader* or GetRangeReader* for the lowest allocation. ExcelSheetReader.Current borrows a reused internal buffer.
The current row stays valid until the next successful read. Process each row before you read the next row.
await using var reader = await workbook.GetSheetReaderAsync("Sheet1");
while (await reader.ReadAsync())
{
ExcelRow current = reader.Current;
ReadOnlySpan<ExcelCellValue> cells = current.Cells;
runningTotal += Sum(cells); // process the row before the next ReadAsync()
}If you ever need to keep a borrowed row past the next read, call current.ToSnapshot().
In most application code, using StreamSheet* is simpler.
ExcelAddress and ExcelRange are value types you can construct once and reuse across calls:
// Parse from string (case-insensitive)
ExcelAddress addr = ExcelAddress.Parse("B2");
ExcelRange rng = ExcelRange.Parse("A1:D10");
// Try-pattern — returns false on invalid input, never throws
bool okAddress = ExcelAddress.TryParse("b2", out ExcelAddress addr2);
bool okRange = ExcelRange.TryParse("A1:D10", out ExcelRange rng2);
// Construct directly
var addr3 = new ExcelAddress(column: 2, row: 2); // B2
var rng3 = new ExcelRange(new ExcelAddress(1, 1), new ExcelAddress(4, 10)); // A1:D10Pass ReadMode to control what data is returned:
// Values (default) — decoded cached values: dates, numbers, text, booleans, errors
RangeResult valuesRange = workbook.ReadRange("Sheet1", "A1:D10", ReadMode.Values);
// Formulas — return formula text for formula cells; fall back to decoded value otherwise
RangeResult formulasRange = workbook.ReadRange("Sheet1", "A1:D10", ReadMode.Formulas);ReadMode applies to ReadCell, ReadRange, StreamSheet, and StreamRange.
Analyze and AnalyzeSheet return workbook structure. Use AnalysisLevel to select the required work.
| Level | What is included |
|---|---|
Exact |
Package metadata, including names, tables, charts, merged cells, validation rules, links, and macros |
Observed |
Exact data plus used ranges, counts, column profiles, and formula dependencies |
Full (default) |
Observed data plus inferred regions and the inferred header row |
using XLSight;
using XLSight.Analysis;
using var workbook = ExcelWorkbook.Open("report.xlsx");
// Analyze all sheets. Full analysis is the default.
WorkbookInfo info = workbook.Analyze();
Console.WriteLine($"Tables: {info.Tables.Count}");
Console.WriteLine($"Has macros: {info.HasMacros}");
Console.WriteLine($"VBA modules: {info.VbaProject?.Modules.Count ?? 0}");
foreach (SheetInfo sheet in info.Sheets)
{
Console.WriteLine($"{sheet.SheetName}: {sheet.Tables.Count} tables, {sheet.MergedRegions.Count} merged regions");
if (sheet.RowCount is { } rowCount)
Console.WriteLine($" Used range: {sheet.UsedRange}, {rowCount} rows");
if (sheet.InferredHeaderRowIndex is { } headerRow)
Console.WriteLine($" Inferred header row: {headerRow}");
}
// Analyze one sheet at the selected level.
SheetInfo s = workbook.AnalyzeSheet("Sheet1", AnalysisLevel.Observed);
Console.WriteLine($"Used range: {s.UsedRange}");
Console.WriteLine($"Columns with formulas: {string.Join(", ", s.FormulaColumns)}");
// Use the asynchronous APIs.
WorkbookInfo infoAsync = await workbook.AnalyzeAsync();
SheetInfo sheetAsync = await workbook.AnalyzeSheetAsync("Sheet1");Exact is always available. Observed and Inferred are null when the selected level does not create them.
Related convenience properties also return null. Use TryGetObserved or TryGetInferred to access the complete objects.
For macro-enabled .xlsm and .xlsb workbooks, XLSight can inspect the embedded VBA project
without executing any macros:
using XLSight;
using XLSight.Analysis;
using var workbook = ExcelWorkbook.Open("report.xlsm");
VbaProjectInfo? project = workbook.GetVbaProject();
if (project is not null)
{
foreach (VbaModuleInfo module in project.Modules)
{
Console.WriteLine($"{module.Name}: {module.Kind}");
string source = workbook.GetVbaModuleSource(module.Name);
}
}GetVbaProject returns source-free project metadata. GetVbaModuleSource and
GetVbaModuleSourceBytes decode an individual module on demand.
SheetInfo.Columns gives a per-column profile available at AnalysisLevel.Observed and above.
Each ColumnProfile captures the dominant cell type, inferred header, non-empty count,
an estimated distinct-value count, the exact distinct values for low-cardinality columns,
and the numeric min/max — everything an agent or pipeline needs to understand a sheet's
schema without reading the data itself.
Low-cardinality columns additionally surface their exact distinct values (capped by
AnalysisOptions.DistinctValuesCap, default 32), so a consumer can pick filter values without
an exploratory scan. High-cardinality columns report DistinctValues == null — itself a signal
that the column is an ID or free-text column not worth enumerating.
SheetInfo sheet = workbook.AnalyzeSheet("Data");
if (sheet.Columns is { } columns)
{
foreach (ColumnProfile col in columns)
{
string header = col.InferredHeader ?? $"Col {col.ColumnIndex}";
Console.WriteLine($"{header}: {col.DominantType}, {col.NonEmptyCount} rows, ~{col.DistinctValueEstimate} distinct");
if (col.DistinctValues is { } values)
Console.WriteLine($" values: {string.Join(", ", values)}");
if (col.MinNumericValue.HasValue)
Console.WriteLine($" range [{col.MinNumericValue} – {col.MaxNumericValue}]");
}
}ColumnProfile.DistinctValues is populated when a column's distinct count falls within
AnalysisOptions.DistinctValuesCap (default 32). High-cardinality columns leave it null
— use DistinctValueEstimate instead. Set DistinctValuesCap = 0 to disable the feature entirely.
var options = new AnalysisOptions { DistinctValuesCap = 50 };
SheetInfo sheet = workbook.AnalyzeSheet("Data", options);The optional XLSight.Layout package finds structure in unknown worksheets.
It identifies labels, data blocks, value profiles, and logical tables. Use the result to select ranges and headers for XLSight.Query.
dotnet add package XLSight.Layoutusing XLSight.Layout;
SheetLayoutInfo layout = workbook.AnalyzeLayout("Financials");Layout analysis scans the selected worksheet. Core Analyze and AnalyzeSheet do not run these heuristics.
The optional XLSight.Query package answers
"sum of X by Y where Z" in one streaming pass — no sheet materialization, no database.
Filters, a single-column group-by, and Sum/Count/Min/Max/Average aggregates are fused over
borrowed rows, so memory scales with group cardinality rather than row count. Dirty cells
never throw; they are skipped and reported per column with sample row indices.
dotnet add package XLSight.Queryusing XLSight.Query;
using static XLSight.Query.QueryAggregates;
QueryResult result = workbook
.QueryRange("Sheet1", "A6:F2410", headerRow: 6)
.Where("Region", QueryOperator.Equals, "EMEA")
.GroupBy("Month")
.Select(Sum("NetSales"), Count())
.Execute();
// Filter discovery beyond the analysis cap: value → count, frequency-ordered.
var months = workbook.QueryRange("Sheet1", "A6:F2410").DistinctValues("Month");Data validation rules attached to cells are available at AnalysisLevel.Exact and above.
Each DataValidationInfo carries the validation type, operator, formula constraints, allowed
ranges, and the UI text shown to users:
SheetInfo sheet = workbook.AnalyzeSheet("Input");
foreach (DataValidationInfo dv in sheet.DataValidations)
{
Console.WriteLine($"Type: {dv.Type}, Ranges: {string.Join(" ", dv.Ranges)}");
if (dv.Formula1 is { } f1) Console.WriteLine($" Formula1: {f1}");
if (dv.Formula2 is { } f2) Console.WriteLine($" Formula2: {f2}");
if (dv.Operator is { } op) Console.WriteLine($" Operator: {op}");
}WorkbookInfo.ExternalLinks lists external workbook references. Each item can include cached sheet names and defined names.
WorkbookInfo info = workbook.Analyze();
foreach (ExternalWorkbookLinkInfo link in info.ExternalLinks)
{
Console.WriteLine($"Target: {link.Target}");
Console.WriteLine($" Sheets: {string.Join(", ", link.SheetNames)}");
Console.WriteLine($" Defined names: {string.Join(", ", link.DefinedNames)}");
}At AnalysisLevel.Observed and above, XLSight tracks which sheets and workbooks each formula
cell references. SheetInfo.FormulaDependencies aggregates these into a per-target count,
giving a quick picture of how sheets are connected:
WorkbookInfo info = workbook.Analyze();
foreach (SheetInfo sheet in info.Sheets)
{
foreach (FormulaDependencyInfo dep in sheet.FormulaDependencies)
{
string target = dep.TargetWorkbook is { } wb
? $"[{wb}]{dep.TargetSheet}"
: dep.TargetSheet;
Console.WriteLine($"{sheet.SheetName} → {target}: {dep.FormulaCount} formula(s)");
}
}ExcelCellValue is a 24-byte readonly struct. Use CellType to discriminate and typed accessors to read:
ExcelCellValue v = row.GetCell(2);
switch (v.CellType)
{
case CellType.Number: Console.WriteLine(v.AsNumber()); break;
case CellType.Text: Console.WriteLine(v.AsText()); break;
case CellType.Date: Console.WriteLine(v.AsDate()); break;
case CellType.Boolean: Console.WriteLine(v.AsBoolean()); break;
case CellType.Error: Console.WriteLine(v.AsError()); break;
case CellType.Formula: Console.WriteLine(v.AsFormula()); break;
case CellType.Empty: break;
}
// Try-pattern accessors never throw
if (v.TryGetNumber(out double d)) { /* ... */ }
if (v.TryGetText(out string? t)) { /* ... */ }
// Shared-string identity — useful for zero-allocation deduplication
if (v.TryGetSharedStringId(out int id)) { /* same id == same string object */ }The input type controls concurrency.
Open(filePath) / OpenAsync(filePath) |
Open(stream) / OpenAsync(stream) |
|
|---|---|---|
| Backing | File-backed | Stream-backed |
| Concurrent operations | Yes. Each read opens a separate ZipArchive. |
No. Run one operation at a time. |
Analyze parallelism |
Scans sheets in parallel by default. | Scans sheets in sequence. |
StreamSheetAsync iterations |
Supports concurrent enumerations. | Supports one enumeration at a time. |
| Non-seekable input | N/A | Buffered into MemoryStream automatically |
Use file-backed opening whenever you can. The stream overload is intended for cases where you already hold an in-memory or network stream.
// File-backed — concurrent reads are safe on this instance
using var workbook = ExcelWorkbook.Open("report.xlsx");
// Stream-backed — only one operation at a time; throws InvalidOperationException otherwise
await using var workbook = await ExcelWorkbook.OpenAsync(networkStream);Note for ASP.NET Core: multiple requests can each hold their own
ExcelWorkbookinstance opened from a file path and call it concurrently with no coordination needed. If you must share a single instance opened from a stream, serialize access yourself.
XLSight scans file-backed sheets in parallel by default. Set maxDegreeOfParallelism to control this work.
// Default: library chooses (one Task per sheet, bounded by processor count)
WorkbookInfo info = workbook.Analyze();
// Sequential — useful in heavily loaded servers to avoid ThreadPool pressure
WorkbookInfo info = workbook.Analyze(maxDegreeOfParallelism: 1);
// Explicit cap
WorkbookInfo info = await workbook.AnalyzeAsync(
AnalysisLevel.Full,
maxDegreeOfParallelism: 4);| Type | Thrown when |
|---|---|
SheetNotFoundException |
Named sheet does not exist in the workbook |
InvalidAddressException |
Cell address or range string cannot be parsed |
RangeTooLargeException |
Requested range exceeds ExcelLimits.MaxCells |
MalformedWorkbookException |
ZIP package or XML structure is corrupt |
ExcelLimits exposes the bounds XLSight enforces:
Console.WriteLine(ExcelLimits.MaxRows); // 1,048,576
Console.WriteLine(ExcelLimits.MaxColumns); // 16,384
Console.WriteLine(ExcelLimits.MaxCells); // 100,000,000All benchmarks were run on a Intel Core i9-14900K, running Linux, with .NET 10.0.11. Release builds, measured 2026-09-05.
XLSight uses GetSheetReader for streaming and ReadRange for bounded reads.
Wall time and peak RSS were measured with a small Python script using psutil: 2 warmups, 5 measured runs, 10 ms sampling. 41,000,041 cells.
Calamine
0af05f4: Rust 1.98.0,opt-level=3, LTO,codegen-units=1,target-cpu=native. ExcelDataReader 3.9.0; MiniExcel 1.46.0.
| Library | Mean time | Stddev | Peak RSS |
|---|---|---|---|
| XLSight reader (.NET 10) | 3.38 s | 0.025 s | 161 MiB |
| calamine (Rust) | 7.73 s | 0.030 s | 160 MiB |
| ExcelDataReader | 17.58 s | 0.265 s | 291 MiB |
| MiniExcel1 | 22.10 s | 0.240 s | 396 MiB |
Measured with BenchmarkDotNet 0.15.8: 3 warmups, 5 measured iterations. The 100 K and 1 M datasets are synthetic xlsx files with numeric and string columns.
| Library | 100 K rows | 1 M rows | Allocated (100 K) | Allocated (1 M) |
|---|---|---|---|---|
| XLSight reader | 53.2 ms | 1.43 s | 279.7 KiB | 1.46 GiB |
| XLSight safe stream | 55.9 ms | 1.42 s | 14.0 MiB | 1.66 GiB |
| ExcelDataReader | 238.5 ms (4.5×) | 5.11 s (3.6×) | 118.9 MiB (435.3×) | 2.71 GiB (1.9×) |
| MiniExcel1 | 434.8 ms (8.2×) | 5.24 s (3.7×) | 1.00 GiB (3748.9×) | 9.53 GiB (6.5×) |
Allocated is total managed heap throughput (BenchmarkDotNet), not peak live RSS.
complex_workbook.xlsx, Scenarios!B10:N20: 11 rows × 13 columns.
| Library | Time | Allocated |
|---|---|---|
XLSight ReadRange |
143.7 μs | 425.6 KiB |
| ExcelDataReader | 640.6 μs (4.5×) | 466.7 KiB (1.1×) |
| MiniExcel1 | 645.5 μs (4.5×) | 946.1 KiB (2.2×) |
XLSight can use a true bounded range API here; MiniExcel and ExcelDataReader still iterate sheet rows and then consume just the requested rectangle.
The same datasets, stopping after 10 rows.
| Library | First 10 of 100 K | First 10 of 1 M | Allocated (100 K) | Allocated (1 M) |
|---|---|---|---|---|
| XLSight reader | 137.6 μs | 307.9 μs | 279.7 KiB | 1.48 MiB |
| XLSight safe stream | 137.5 μs | 304.7 μs | 281.2 KiB | 1.48 MiB |
| ExcelDataReader | 84.3 ms (612.6×) | 2.60 s (8444.3×) | 21.9 MiB (80.2×) | 1.40 GiB (968.6×) |
| MiniExcel1 | 179.5 ms (1304.5×) | 1.22 s (3962.3×) | 482.3 MiB (1765.7×) | 1.51 GiB (1044.8×) |
Why the performance gap?
XLSight: Reads worksheet XML as UTF-8 bytes, reuses a typed row buffer, and creates managed strings on demand. It can stop after the requested rows.
ExcelDataReader: Scans worksheet data upfront to determine row and column counts. It also loads shared strings and styles eagerly, so even a short read requires substantial setup work.
MiniExcel: Loads all shared strings upfront. Query() builds each row as an ExpandoObject, then copies its values into another dictionary. It also allocates a formula dictionary per row, even when no formulas are present. Numeric and other value-type cells are boxed.
Most .xlsx readers use a general-purpose XML parser for worksheet data. This parser must support
the full XML model and expose data through character and string APIs.
XLSight uses purpose-built scanners for worksheet data and shared strings. The scanners read decompressed UTF-8 bytes and handle only the required OOXML elements and attributes.
ReadOnlySpan<byte>.IndexOf and SearchValues<byte> find the boundaries of <row>, <c>, <v>,
<f>, and <t> elements. CellAttributeParser reads the r, t, and s attributes from byte
spans. Utf8Parser.TryParse parses integer and floating-point values without temporary strings.
ScanBuffer rents a 64 KB buffer from ArrayPool<byte> for each open sheet. It grows the buffer
only when a token or asynchronous row exceeds the available space.
ExcelCellValue is a 24-byte readonly struct with no padding. ExcelSheetReader reuses one
ExcelCellValue[] buffer for all rows. The borrowed reader does not allocate a new cell array for
each row.
StreamSheet* and StreamRange* copy each row when the caller selects the safe enumerable API.
RangeResult keeps cells in one flat buffer and exposes cached ExcelRow views. Analysis operations
send cells to generic struct sinks and do not create row objects.
The shared-string parser stores resolved UTF-8 text in 64 KB arena chunks. It rents one 256 KB
staging buffer and reuses it for each <si> element. Each packed long records the global offset
and byte length of one entry.
The parser reads more shared-string entries only when a worksheet requests a higher index. A cache holds at most 131,072 low-index strings, such as headers and category values. High-index entries remain in the UTF-8 arena. XLSight creates their managed strings on demand, and Gen 0 can collect them.
- Zero dependencies — only the .NET 10 BCL.
ZipArchivehandles the OOXML container;XmlReaderparses one-time workbook metadata (styles, relationships); the sheet scanner and SST parser are custom byte-level engines that never invokeXmlReader. - AOT-compatible — annotated for Native AOT and trimming from day one.
- Dual streaming API —
GetSheetReader*exposes the lowest-allocation borrowed reader;StreamSheet*/StreamRange*snapshot rows automatically for safe enumeration and LINQ usage. - Read-only — XLSight reads and analyzes
.xlsx,.xlsm, and.xlsbfiles; it does not write them or execute macros. - Target framework — .NET 10 (
net10.0).
MIT