Skip to content

Repository files navigation

XLSight

NuGet .NET 10 License: MIT

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 than ExcelDataReader, and 6.5× faster than MiniExcel.
  • 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 .xls or .csv files. Benchmark tables compare equivalent .xlsx reads unless stated otherwise.

Installation

dotnet add package XLSight

Quick start

Open a workbook

using 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);

Read a cell or range

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");

Stream large sheets safely

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
}

Borrowed high-performance reader

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.

Address and range types

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:D10

Read modes

Pass 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 a workbook

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.

VBA metadata

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.

Column profiles

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);

Infer worksheet layout (XLSight.Layout)

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.Layout
using XLSight.Layout;

SheetLayoutInfo layout = workbook.AnalyzeLayout("Financials");

Layout analysis scans the selected worksheet. Core Analyze and AnalyzeSheet do not run these heuristics.

Query a range (XLSight.Query)

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.Query
using 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 validations

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}");
}

External workbook links

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)}");
}

Formula dependencies

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)");
    }
}

Cell values

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 */ }

File-backed vs stream-backed workbooks

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 ExcelWorkbook instance 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.

Controlling analysis parallelism

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);

Exceptions

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

Limits

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,000

Performance

All 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.

Real-world benchmark — NYC 311 service requests, 1 M rows × 41 cols

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

BenchmarkDotNet — public streaming throughput, all rows

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.

BenchmarkDotNet — bounded mid-sheet range

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.

BenchmarkDotNet — early exit, first 10 rows

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.

How XLSight reduces work

Worksheet data

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.

Row storage

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.

Shared strings

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.

Key design points

  • Zero dependencies — only the .NET 10 BCL. ZipArchive handles the OOXML container; XmlReader parses one-time workbook metadata (styles, relationships); the sheet scanner and SST parser are custom byte-level engines that never invoke XmlReader.
  • AOT-compatible — annotated for Native AOT and trimming from day one.
  • Dual streaming APIGetSheetReader* 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 .xlsb files; it does not write them or execute macros.
  • Target framework — .NET 10 (net10.0).

License

MIT

Footnotes

  1. MiniExcel uses EnableSharedStringCache = false to keep shared strings in memory. 2 3 4

About

High-performance .NET 10 xlsx reader

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages