# How to Read Very Large Excel Files in C# with Forward-Only Streaming
A spreadsheet with several hundred thousand rows breaks the usual approach. `WorkBook.Load` builds a complete in-memory model of the workbook, which is what makes editing, styling, formulas and random access possible, and which is also why a 400 MB export can take minutes and consume several gigabytes before the first value is read.
IronXL's forward-only streaming reader, `WorkBook.StreamRows`, exists for the jobs that only need the values. It yields one row at a time straight from disk rather than materialising the workbook, which keeps memory flat no matter how many rows the sheet holds.
Understanding where the line falls matters more than the API itself, because misreading it is the main way the feature gets misused. `StreamRows` is **read-only**, **XLSX-only** and **forward-only**: a fast lane for bulk value extraction. Editing, styling, formulas, charts, random access and non-XLSX formats all remain the job of [`WorkBook.Load`](https://ironsoftware.com/csharp/excel/how-to/load-spreadsheet/).
[[i:(Requires IronXL 2026.9.2 or later. Earlier versions do not expose `StreamRows` and will fail to compile.)]]
*as-heading:2(Quickstart: Stream a Large XLSX File Row by Row)*
Point `StreamRows` at a file path and enumerate the result. Behind the scenes, the string path overload reads the workbook's parts lazily from disk, and only one row is materialised at a time, which is what keeps memory flat across an arbitrarily large sheet.
```cs
:title=Stream Rows Without Loading the Workbook
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx"))
Console.WriteLine(row[0]?.Text);
```
<div class="hsg-featured-snippet">
<h3>Minimal Workflow (5 steps)</h3>
<ol>
<li><a class="js-modal-open" data-modal-id="trial-license-after-download" href="https://nuget.org/packages/IronXL.Excel/">Install or upgrade to IronXL 2026.9.2 or later</a> and apply a valid license key</li>
<li>Call <code>WorkBook.GetSheetNames(path)</code> if the target sheet needs to be discovered</li>
<li>Call <code>WorkBook.StreamRows(path, ...)</code> with a sheet index, sheet name, or <code>StreamReadOptions</code></li>
<li>Enumerate the returned <code>IEnumerable<StreamRow></code>, null-checking every cell access</li>
<li>Check <code>StreamCell.Type</code> before reading <code>Value</code>, and write results out in batches</li>
</ol>
</div>
<br class="clear" />
<hr class="separator" />
## How Do I Choose Between `StreamRows` and `WorkBook.Load`?
The split is a question of what the job needs, not of file size alone. A 50 MB file that needs a cell restyled belongs to `Load`. A 500 MB file that needs three columns pushed into a database belongs to `StreamRows`.
*Choosing between the streaming reader and the full workbook model*
| Requirement | StreamRows | WorkBook.Load |
| --- | --- | --- |
| Bulk value extraction, imports, ETL, validation | Yes | Yes (slower) |
| Editing or saving | No | Yes |
| Styling, formatting objects | No | Yes |
| Formula expressions and recalculation | No (cached results only) | Yes |
| Random access to arbitrary cells | No (forward-only) | Yes |
| XLS, CSV, TSV, encrypted workbooks | No | Yes |
| Charts and images | No | Yes |
The gap is wide enough to change how a job is designed. Streaming all 500,000 rows of a 23.4 MB workbook took about six seconds and held roughly 75 MB, with the first row available in a quarter of a second; `WorkBook.Load` on the same file is dramatically heavier on both counts. Hardware and workbook shape move these numbers, and workbooks with unusually wide rows or heavy shared-string tables land differently again. The [data-loading performance guide](https://ironsoftware.com/csharp/excel/troubleshooting/data-loading-performance/) covers the equivalent trade-offs on the writing side.
<hr class="separator" />
## How Do I Stream Rows from a Workbook?
`WorkBook.StreamRows` is a static method on [`WorkBook`](https://ironsoftware.com/csharp/excel/object-reference/api/IronXL.WorkBook.html). Give it a source and a worksheet, and enumerate what comes back.
#### Input
Half a million rows of transaction data, eight columns wide, 23.4 MB on disk. Excel opens it without complaint; the trouble starts when code tries to pull the whole thing into memory.
<img src="/static-assets/excel/how-to/stream-large-excel-files/stream-large-excel-files-input.webp" alt="Excel showing a Transactions worksheet scrolled to row 250,000 of 500,000, with the frozen header row reading TxnId, Date, Sku, Description, Qty, UnitPrice, Region and Status" class="img-responsive add-shadow" style="margin-bottom: 30px;"/>
```csharp
using IronXL;
using System;
// The string path overload reads the workbook's parts lazily from disk.
// Nothing is loaded up front, and only one row exists in memory at a time.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx"))
{
// Cells holds only the columns that actually have content on this row.
foreach (StreamCell cell in row.Cells)
{
Console.WriteLine("{0}{1} = {2}", cell.ColumnLetter, row.RowNumber + 1, cell.Text);
}
}
```
#### Output
```
A1 = TxnId
B1 = Date
C1 = Sku
D1 = Description
E1 = Qty
F1 = UnitPrice
G1 = Region
H1 = Status
A2 = 1
B2 = 2026-01-01
C2 = SKU-079109
D2 = Item 0
E2 = 24
F2 = 8.62
G2 = North
H2 = OK
A3 = 2
B3 = 2026-02-02
C3 = SKU-011965
D3 = Item 1
E3 = 43
F3 = 460.94
G3 = South
H3 = PENDING
```
One line per cell, because the loop walks `row.Cells` rather than a fixed set of columns. `RowNumber` is zero-based, so the sample adds one to line the output up with Excel's own numbering. Note that the header arrives as an ordinary row here: with no `StreamReadOptions` supplied, `TxnId` and `Date` come through as cell values on row 1 rather than naming the columns.
The return type is `IEnumerable<StreamRow>`, and the distinction that matters is that nothing is read until the sequence is iterated. Rows are produced one at a time as the loop advances, so a `foreach` over a 500,000-row sheet holds a single row in memory rather than half a million. The consequence is that the sequence is single-pass and forward-only: there is no going back to an earlier row and no indexing into the middle of one. LINQ operators compose against it normally, which means `.Take(1000)` reads a thousand rows and stops there.
Each `StreamRow` carries only the cells that have content, which is covered under [working with a `StreamRow`](#anchor-how-do-i-work-with-a-streamrow) below.
### Which Overload Should I Use?
Nine overloads cover three sources and three ways of naming the worksheet. The source is the decision that matters, because **only the `string path` overloads read lazily from disk**. The `Stream` and `byte[]` overloads must hold the package's decompressed contents in memory for the duration of the read.
*The nine `StreamRows` overloads, all returning `IEnumerable<StreamRow>`*
| Overload | Low-memory |
| --- | --- |
| `StreamRows(string path, int sheetIndex = 0)` | Yes |
| `StreamRows(string path, string sheetName)` | Yes |
| `StreamRows(string path, StreamReadOptions options)` | Yes |
| `StreamRows(Stream stream, int sheetIndex = 0)` | No |
| `StreamRows(Stream stream, string sheetName)` | No |
| `StreamRows(Stream stream, StreamReadOptions options)` | No |
| `StreamRows(byte[] data, int sheetIndex = 0)` | No |
| `StreamRows(byte[] data, string sheetName)` | No |
| `StreamRows(byte[] data, StreamReadOptions options)` | No |
The `Stream` and `byte[]` overloads exist for convenience when a file already sits in memory, such as an upload that has been buffered. They still stream row by row, so they avoid building the full workbook model, but the unpacked contents stay resident for the whole read. Expect that to run around ten times the file's size on disk, so a 23.4 MB workbook holds a little over 200 MB rather than the figure its file size suggests. Use a file path for the largest files.
*All three return identical rows and totals; the cost is memory, not speed or correctness*
| Source | Elapsed | Peak memory |
| --- | --- | --- |
| `string path` | 6.3 s | **75 MB** |
| `Stream` | 6.2 s | 309 MB |
| `byte[]` | 6.3 s | 291 MB |
Elapsed time is effectively identical across the three, which is worth noticing: picking the wrong overload does not make the read slower, it makes it heavier. A job that fits comfortably in memory will not feel the difference at all until the file grows or the container's limit is lowered.
### How Do I Pick a Worksheet?
Sheet selection has three forms: a zero-based index, an exact sheet name matched case-insensitively, or a `StreamReadOptions` instance. An unknown sheet name or an out-of-range index throws `ArgumentException`.
```csharp
using IronXL;
using System;
// 1. By zero-based index. Omitting the argument streams the first sheet.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", 2))
{
Console.WriteLine(row[0]?.Text);
}
// 2. By sheet name. Matching is case-insensitive, so "transactions" also works.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "Transactions"))
{
Console.WriteLine(row[0]?.Text);
}
// 3. By StreamReadOptions, which is the only route to header support.
// SheetName takes precedence over SheetIndex whenever it is set.
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
Console.WriteLine(row["Reference"]?.Text);
}
// An unknown sheet name or an out-of-range index throws ArgumentException.
try
{
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "NoSuchSheet"))
{
Console.WriteLine(row.RowNumber);
}
}
catch (ArgumentException ex)
{
Console.WriteLine("Sheet selection failed: {0}", ex.Message);
}
```
Passing no sheet argument at all streams the first sheet, because `sheetIndex` defaults to `0`. `StreamReadOptions` is the only route that supports header rows, so reach for it as soon as columns need names.
<hr class="separator" />
## How Do I Discover Sheets Before Streaming?
`WorkBook.GetSheetNames` lists every sheet in workbook order without loading the workbook's contents. It reads only the workbook's part list, so it stays cheap even on a very large file: about a quarter of a second on the 23.4 MB, 500,000-row workbook, which is the same time it takes on a file of a few kilobytes. That makes it the natural first call for a file arriving from an external system whose layout is not known in advance.
*The three `GetSheetNames` overloads, all returning `IReadOnlyList<string>`*
| Overload | Source |
| --- | --- |
| `GetSheetNames(string path)` | A file on disk |
| `GetSheetNames(Stream stream)` | An open stream |
| `GetSheetNames(byte[] data)` | An in-memory buffer |
Position in the returned list equals the sheet index that `StreamRows` accepts, so the two APIs line up directly: index `2` in this list is the sheet that `StreamRows(path, 2)` reads.
```csharp
using IronXL;
using System;
using System.Collections.Generic;
// GetSheetNames reads only the workbook's part list, not sheet data, so it is cheap
// even on a very large file. List position equals the sheet index StreamRows accepts.
IReadOnlyList<string> sheetNames = WorkBook.GetSheetNames("large-export.xlsx");
for (int i = 0; i < sheetNames.Count; i++)
{
Console.WriteLine("[{0}] {1}", i, sheetNames[i]);
}
// Stream by exact sheet name. Matching is case-insensitive.
// An unknown name, or an out-of-range index, throws ArgumentException.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "Transactions"))
{
StreamCell reference = row[0];
if (reference is not null)
{
Console.WriteLine(reference.Text);
}
}
```
<hr class="separator" />
## How Do I Read Rows by Column Name?
Setting `HasHeaderRow` names the columns and lets rows be read by header name. The header row is consumed for naming and is not yielded as data, and any rows before `HeaderRowIndex` are skipped, which handles the common case of a title or preamble sitting above the real header.
```csharp
using IronXL;
using System;
// This source file carries a title and a blank spacer above the real header,
// so the header sits on Excel row 3, which is HeaderRowIndex 2 (zero-based).
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true,
HeaderRowIndex = 2
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
// The header row is consumed to name columns and is never yielded as data.
// Rows before HeaderRowIndex are skipped.
// Named access requires HasHeaderRow; without it the string indexer throws
// InvalidOperationException.
StreamCell reference = row["Reference"];
StreamCell amount = row["Amount"];
// Both the sparse-row case and a blank cell produce null, so null-check before use.
if (reference is null || amount is null)
{
continue;
}
Console.WriteLine("{0}: {1}", reference.Text, amount.Text);
}
```
[[w:(Named access requires `HasHeaderRow`. Without it, the string indexer and `TryGetCell` both throw `InvalidOperationException`.)]]
Header handling is deliberately strict. If the sheet has no row at or before the configured `HeaderRowIndex`, enumeration throws `InvalidOperationException` with a "header row not found" message, and it throws as soon as a data row overshoots the header index. This ensures that a consumer using `Take` or an early `break` sees the misconfiguration immediately rather than silently reading nothing.
Column name matching is case-insensitive and trims surrounding whitespace. Duplicate header names resolve to the first, left-most matching column.
### What Does StreamReadOptions Configure?
*`StreamReadOptions` properties, set through the object initialiser*
| Property | Type | Default | Behaviour |
| --- | --- | --- | --- |
| `SheetIndex` | `int` | `0` | Zero-based worksheet index. Ignored when `SheetName` is set |
| `SheetName` | `string` | `null` | Worksheet name, matched case-insensitively. Takes precedence over `SheetIndex` |
| `HasHeaderRow` | `bool` | `false` | When true, the row at `HeaderRowIndex` names the columns and is not yielded as data |
| `HeaderRowIndex` | `int` | `0` | Zero-based sheet row holding the header. Rows before it are skipped |
### How Do I Handle Optional Columns?
The two named accessors differ in how they treat a column that is not in the header, and picking the wrong one is what turns a tolerable source file into a crash. The string indexer throws `ArgumentException`; `TryGetCell` returns `false`.
```csharp
using IronXL;
using System;
StreamReadOptions options = new StreamReadOptions
{
SheetIndex = 0,
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
// The string indexer throws ArgumentException when the column name is absent
// from the header. TryGetCell returns false instead, which makes it the correct
// choice for a column that may not exist in every source file.
if (row.TryGetCell("CostCentre", out StreamCell costCentre) && costCentre is not null)
{
Console.WriteLine("Cost centre: {0}", costCentre.Text);
}
else
{
Console.WriteLine("Cost centre: not supplied");
}
// Header is positioned by column, so Header[i] lines up with column i.
// A blank header column is null.
for (int i = 0; i < row.Header.Count; i++)
{
string columnName = row.Header[i];
if (string.IsNullOrEmpty(columnName))
{
continue;
}
StreamCell cell = row[i];
Console.WriteLine(" {0} ({1}) = {2}", columnName, cell?.ColumnLetter, cell?.Text);
}
}
```
`TryGetCell` answers a narrower question than it first appears: it reports whether the **header** contains the column name, not whether this particular row has a value there. A row with a sparse gap in a known column returns `true` with a `null` cell, so a null check is still needed on the way out. Use `TryGetCell` for any column that may be missing from some source files, and the string indexer only where the column is guaranteed.
<hr class="separator" />
## How Do I Work with a StreamRow?
A `StreamRow` is one row produced during enumeration. It exposes its position, the cells it actually contains, and both positional and named lookup.
```csharp
using IronXL;
using System;
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
// RowNumber is zero-based, so Excel row 1 is RowNumber 0.
// With a header consumed, the first data row keeps its real sheet position.
Console.WriteLine("Sheet row {0}, {1} populated cells", row.RowNumber, row.Cells.Count);
// Index access. Rows are sparse, so a column with no content returns null
// rather than a blank cell object.
StreamCell firstColumn = row[0];
Console.WriteLine(" column A: {0}", firstColumn?.Text ?? "(empty)");
// Name access. Requires HasHeaderRow, and throws ArgumentException if the
// header has no such column. Matching is case-insensitive and trims whitespace.
StreamCell reference = row["Reference"];
Console.WriteLine(" Reference: {0}", reference?.Text ?? "(empty)");
// Header is positioned by column: Header[i] names column i.
// A column with a blank header cell is null.
for (int i = 0; i < row.Header.Count; i++)
{
string name = row.Header[i];
Console.WriteLine(" column {0} is named {1}", i, name ?? "(unnamed)");
}
}
```
*`StreamRow` members, with both positional and named access*
| Member | Type | Behaviour |
| --- | --- | --- |
| `RowNumber` | `int` | Zero-based. Excel row 1 is `RowNumber` 0 |
| `Cells` | `IReadOnlyList<StreamCell>` | Sparse. Only columns with content are present |
| `Header` | `IReadOnlyList<string>` | Column names positioned by column, so `Header[i]` names column `i`. A blank header column is `null`, and `Header` itself is `null` when no header was configured |
| `this[int columnIndex]` | `StreamCell` | Cell at a zero-based column index, or `null` for a sparse gap |
| `this[string columnName]` | `StreamCell` | Cell under a named header column. Throws `ArgumentException` when the name is absent |
| `TryGetCell(string, out StreamCell)` | `bool` | `true` when the header contains the name, `false` when it does not |
[[w:(`Header` is `null` unless `StreamReadOptions.HasHeaderRow` was set. Guard it before iterating, or the loop dereferences a null list.)]]
Sparseness is the property that shapes calling code. A row is not a fixed-width record: a sheet with columns A through F produces rows that contain three cells where three columns were filled in, and the positional indexer returns `null` for the gaps. `RowNumber` being zero-based is consistent with the rest of IronXL, where numeric indexers are zero-based while A1-style string addresses are not.
<hr class="separator" />
## How Do I Read Typed Values from a StreamCell?
A `StreamCell` is an immutable snapshot of one cell. Unlike a normal [`Cell`](https://ironsoftware.com/csharp/excel/object-reference/api/IronXL.Cell.html), it is not backed by the workbook and cannot be used to write a value back.
```csharp
using IronXL;
using System;
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "Transactions"))
{
StreamCell cell = row[1];
// Sparse gap: no cell at this column on this row.
if (cell is null)
{
continue;
}
// Position: zero-based index, plus the spreadsheet column letter.
Console.WriteLine("Column {0} (letter {1})", cell.ColumnIndex, cell.ColumnLetter);
// Type classifies the value. For a formula cell it reports the type of the
// cached result, never a "formula" type.
Console.WriteLine(" Type: {0}", cell.Type);
// IsFormula is how formula-ness is exposed. The expression itself is not
// available from the streaming reader, only the cached result.
Console.WriteLine(" IsFormula: {0}", cell.IsFormula);
// Value is the raw typed value, and is null for a blank cell.
Console.WriteLine(" Value: {0}", cell.Value ?? "(null)");
// Text is the formatted display text, which is what Excel shows in the cell.
Console.WriteLine(" Text: {0}", cell.Text);
}
```
*`StreamCell` members, covering position, type and value*
| Member | Type | Behaviour |
| --- | --- | --- |
| `ColumnIndex` | `int` | Zero-based column index |
| `ColumnLetter` | `string` | Spreadsheet column letter, for example "A", "C" or "AA" |
| `Type` | `StreamCellType` | The kind of value held. For a formula cell, the type of the cached result |
| `Value` | `object` | The raw typed value. `null` for a blank cell |
| `Text` | `string` | The formatted display text, as Excel shows it |
| `IsFormula` | `bool` | `true` when the cell contains a formula |
`Value` is boxed, so what you convert it to follows from `Type`:
- `Number` gives a `double`
- `String` gives a `string`
- `Boolean` gives a `bool`
- `Date` gives a `DateTime`
- `Blank` gives `null`
Checking `Type` before touching `Value` is the difference between an import that survives real data and one that throws on row 40,000.
```csharp
using IronXL;
using System;
using System.Globalization;
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
StreamCell amount = row["Amount"];
// Sparse rows and blank cells both yield null. Check before touching Type or Value.
if (amount is null || amount.Type == StreamCellType.Blank)
{
continue;
}
// A formula cell reports IsFormula true, and Type reports the real type of the
// cached result. The formula expression itself is not exposed, and nothing is
// recalculated, which is why StreamCellType has no Formula member.
if (amount.IsFormula)
{
Console.WriteLine("Row {0} column {1} is a cached formula result.",
row.RowNumber, amount.ColumnLetter);
}
switch (amount.Type)
{
case StreamCellType.Number:
// Convert rather than casting directly: the boxed numeric type is not
// guaranteed to be the one you expect.
decimal value = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture);
Console.WriteLine("Amount: {0}", value);
break;
case StreamCellType.Date:
// Date cells honour the workbook's date system, including 1904-based
// workbooks produced by Excel for Mac.
if (amount.Value is DateTime date)
{
Console.WriteLine("Date: {0:yyyy-MM-dd}", date);
}
break;
case StreamCellType.Boolean:
Console.WriteLine("Flag: {0}", Convert.ToBoolean(amount.Value));
break;
case StreamCellType.Error:
Console.WriteLine("Row {0} contains an error value.", row.RowNumber);
break;
default:
// Falls through to the formatted display text for String and anything else.
Console.WriteLine("Text: {0}", amount.Text);
break;
}
}
```
Two habits matter here. The first is null-checking every indexer access, because rows are sparse and `Value` is `null` for a blank cell, so both cases converge on the same check. The second is converting rather than casting: `Value` holds the raw typed value, and code that casts straight from it teaches a bug that surfaces only on the one file where a column happens to be stored differently. Reading `Text` instead is entirely reasonable when the destination is a string column.
### What Does StreamCellType Report?
*`StreamCellType` values, reported by `StreamCell.Type`*
| Value | Reported when |
| --- | --- |
| `Blank` | The cell holds no value. `Value` is `null` |
| `Number` | The cell holds a numeric value |
| `String` | The cell holds text |
| `Boolean` | The cell holds `TRUE` or `FALSE` |
| `Date` | The cell holds a date, honouring the workbook's date system |
| `Error` | The cell holds an Excel error value |
There is deliberately no `Formula` member. A formula cell reports the real type of its cached result here, for example `Number`, and formula-ness is exposed separately through `IsFormula`. Anyone scanning the enum for a formula case will not find one, and that is by design rather than an omission.
<hr class="separator" />
## What Are the Limitations of the Streaming Reader?
Knowing these up front prevents the most common misdiagnoses.
Only the path overloads are genuinely low-memory. This is the most important caveat on the page. A developer who passes a 400 MB upload as a `byte[]`, watches memory climb, and concludes the feature is broken has hit exactly this.
The remaining boundaries are fixed by the design:
- **Read-only:** The streaming reader cannot modify or save. Editing means `WorkBook.Load`.
- **XLSX only:** No `.xls`, no encrypted workbooks, and no writing of any kind.
- **Forward-only and single-pass:** There is no random access and no rewinding. Code that needs to jump around a sheet wants `Load`.
- **No styling or formatting objects:** Cell appearance is not exposed.
- **Cached formula results only:** The formula expression is not exposed and nothing is recalculated.
[[i:(Both `StreamRows` and `GetSheetNames` require a valid IronXL license, enforced when the method is called, exactly as with `WorkBook.Load`.)]]
One further caveat sits between limitation and behaviour: low-memory reading from a path is best-effort. A workbook whose zip central directory cannot be read may fall back to loading the package into memory.
<hr class="separator" />
## How Do I Build a Streaming Import Pipeline?
The pattern that carries production work is a streaming read feeding batched writes, so neither the source nor the destination is held in memory at full size.
```csharp
using IronXL;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
public class TransactionImporter
{
private const int BatchSize = 5000;
public async Task<int> ImportAsync(string path, ITransactionSink sink)
{
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true,
HeaderRowIndex = 0
};
List<TransactionRecord> batch = new List<TransactionRecord>(BatchSize);
int imported = 0;
// Enumeration is lazy: only one row is materialised at a time, so peak memory
// stays flat regardless of how many rows the sheet holds.
foreach (StreamRow row in WorkBook.StreamRows(path, options))
{
TransactionRecord record = MapRow(row);
if (record is null)
{
continue;
}
batch.Add(record);
if (batch.Count == BatchSize)
{
await sink.WriteAsync(batch);
imported += batch.Count;
batch.Clear();
}
}
if (batch.Count > 0)
{
await sink.WriteAsync(batch);
imported += batch.Count;
}
return imported;
}
private static TransactionRecord MapRow(StreamRow row)
{
StreamCell reference = row["Reference"];
StreamCell amount = row["Amount"];
StreamCell posted = row["PostedOn"];
// Required columns missing on this row: skip rather than throw, so one bad
// row does not abort an import of several hundred thousand.
if (reference is null || amount is null)
{
return null;
}
if (amount.Type != StreamCellType.Number)
{
return null;
}
DateTime? postedOn = posted?.Value as DateTime?;
// Optional column that only some source files carry.
string costCentre = row.TryGetCell("CostCentre", out StreamCell centre)
? centre?.Text
: null;
return new TransactionRecord
{
SourceRow = row.RowNumber,
Reference = reference.Text,
Amount = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture),
PostedOn = postedOn,
CostCentre = costCentre
};
}
}
public class TransactionRecord
{
public int SourceRow { get; set; }
public string Reference { get; set; }
public decimal Amount { get; set; }
public DateTime? PostedOn { get; set; }
public string CostCentre { get; set; }
}
public interface ITransactionSink
{
Task WriteAsync(IReadOnlyList<TransactionRecord> batch);
}
```
The batching is what makes this hold up. Rows arrive one at a time, accumulate into a fixed-size buffer, and flush to the database or queue when the buffer fills. Peak memory is bounded by the batch size rather than by the row count, which means the same code handles a 5,000-row file and a 5,000,000-row file identically.
Skipping malformed rows rather than throwing is a deliberate choice for bulk imports. One unparseable row in several hundred thousand should not abort the run, and recording `RowNumber` alongside each record gives operators a way to trace a bad value back to its cell in the source file.
<hr class="separator" />
## What Edge Cases Should I Handle?
`GetSheetNames` returns every sheet in the workbook, including chartsheets. A chartsheet holds no rows, so passing its index to `StreamRows` yields an empty sequence rather than raising an error. Code that loops every sheet index will encounter this, and an empty result there is expected.
```csharp
using IronXL;
using System;
using System.Collections.Generic;
// GetSheetNames returns every sheet in workbook order, including chartsheets.
// List position equals the index that StreamRows accepts.
IReadOnlyList<string> sheetNames = WorkBook.GetSheetNames("large-export.xlsx");
for (int i = 0; i < sheetNames.Count; i++)
{
int rowCount = 0;
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", i))
{
rowCount++;
}
// A chartsheet holds no rows, so it streams as an empty sequence rather than
// raising an error. Zero rows here is expected, not a failure to investigate.
if (rowCount == 0)
{
Console.WriteLine("[{0}] {1}: no rows (chartsheet or empty worksheet)", i, sheetNames[i]);
}
else
{
Console.WriteLine("[{0}] {1}: {2} rows", i, sheetNames[i], rowCount);
}
}
```
Date cells honour the workbook's date system, including the 1904-based workbooks that Excel for Mac produces. No adjustment is needed in calling code.
### Where Do I Go Next?
`StreamRows` covers one job well: getting values out of a large XLSX file quickly, with memory that stays flat. Anything beyond reading values belongs to the full workbook model, and mixing the two in one pipeline is normal. Stream to validate or import, then `Load` the smaller working file when edits are needed.
For the surrounding functionality, the [C# Read XLSX File how-to](https://ironsoftware.com/csharp/excel/how-to/c-sharp-read-xlsx-file/) covers standard reading, the [Import Excel Files how-to](https://ironsoftware.com/csharp/excel/how-to/csharp-import-excel/) covers data import patterns, and the [Read Excel Files in C# tutorial](https://ironsoftware.com/csharp/excel/tutorials/how-to-read-excel-file-csharp/) works through the fundamentals. The [file size limits troubleshooting page](https://ironsoftware.com/csharp/excel/troubleshooting/file-size-limits/) covers format-level row and column ceilings, and the [WorkBook API reference](https://ironsoftware.com/csharp/excel/object-reference/api/IronXL.WorkBook.html) documents the complete class.
A spreadsheet with several hundred thousand rows breaks the usual approach. WorkBook.Load builds a complete in-memory model of the workbook, which is what makes editing, styling, formulas and random access possible, and which is also why a 400 MB export can take minutes and consume several gigabytes before the first value is read.
IronXL's forward-only streaming reader, WorkBook.StreamRows, exists for the jobs that only need the values. It yields one row at a time straight from disk rather than materialising the workbook, which keeps memory flat no matter how many rows the sheet holds.
Understanding where the line falls matters more than the API itself, because misreading it is the main way the feature gets misused. StreamRows is read-only, XLSX-only and forward-only: a fast lane for bulk value extraction. Editing, styling, formulas, charts, random access and non-XLSX formats all remain the job of WorkBook.Load.
Please note: Requires IronXL 2026.9.2 or later. Earlier versions do not expose StreamRows and will fail to compile.
Quickstart: Stream a Large XLSX File Row by Row
Point StreamRows at a file path and enumerate the result. Behind the scenes, the string path overload reads the workbook's parts lazily from disk, and only one row is materialised at a time, which is what keeps memory flat across an arbitrarily large sheet.
Call WorkBook.GetSheetNames(path) if the target sheet needs to be discovered
Call WorkBook.StreamRows(path, ...) with a sheet index, sheet name, or StreamReadOptions
Enumerate the returned IEnumerable<StreamRow>, null-checking every cell access
Check StreamCell.Type before reading Value, and write results out in batches
How Do I Choose Between StreamRows and WorkBook.Load?
The split is a question of what the job needs, not of file size alone. A 50 MB file that needs a cell restyled belongs to Load. A 500 MB file that needs three columns pushed into a database belongs to StreamRows.
Choosing between the streaming reader and the full workbook model
Requirement
StreamRows
WorkBook.Load
Bulk value extraction, imports, ETL, validation
Yes
Yes (slower)
Editing or saving
No
Yes
Styling, formatting objects
No
Yes
Formula expressions and recalculation
No (cached results only)
Yes
Random access to arbitrary cells
No (forward-only)
Yes
XLS, CSV, TSV, encrypted workbooks
No
Yes
Charts and images
No
Yes
The gap is wide enough to change how a job is designed. Streaming all 500,000 rows of a 23.4 MB workbook took about six seconds and held roughly 75 MB, with the first row available in a quarter of a second; WorkBook.Load on the same file is dramatically heavier on both counts. Hardware and workbook shape move these numbers, and workbooks with unusually wide rows or heavy shared-string tables land differently again. The data-loading performance guide covers the equivalent trade-offs on the writing side.
How Do I Stream Rows from a Workbook?
WorkBook.StreamRows is a static method on WorkBook. Give it a source and a worksheet, and enumerate what comes back.
Input
Half a million rows of transaction data, eight columns wide, 23.4 MB on disk. Excel opens it without complaint; the trouble starts when code tries to pull the whole thing into memory.
using IronXL;using System;// The string path overload reads the workbook's parts lazily from disk.// Nothing is loaded up front, and only one row exists in memory at a time.foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx")){ // Cells holds only the columns that actually have content on this row. foreach (StreamCell cell in row.Cells) {Console.WriteLine("{0}{1} = {2}", cell.ColumnLetter, row.RowNumber + 1, cell.Text); }}
using IronXL;
using System;
// The string path overload reads the workbook's parts lazily from disk.
// Nothing is loaded up front, and only one row exists in memory at a time.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx"))
{
// Cells holds only the columns that actually have content on this row.
foreach (StreamCell cell in row.Cells)
{
Console.WriteLine("{0}{1} = {2}", cell.ColumnLetter, row.RowNumber + 1, cell.Text);
}
}
One line per cell, because the loop walks row.Cells rather than a fixed set of columns. RowNumber is zero-based, so the sample adds one to line the output up with Excel's own numbering. Note that the header arrives as an ordinary row here: with no StreamReadOptions supplied, TxnId and Date come through as cell values on row 1 rather than naming the columns.
The return type is IEnumerable<StreamRow>, and the distinction that matters is that nothing is read until the sequence is iterated. Rows are produced one at a time as the loop advances, so a foreach over a 500,000-row sheet holds a single row in memory rather than half a million. The consequence is that the sequence is single-pass and forward-only: there is no going back to an earlier row and no indexing into the middle of one. LINQ operators compose against it normally, which means .Take(1000) reads a thousand rows and stops there.
Each StreamRow carries only the cells that have content, which is covered under working with a StreamRow below.
Which Overload Should I Use?
Nine overloads cover three sources and three ways of naming the worksheet. The source is the decision that matters, because only the string path overloads read lazily from disk. The Stream and byte[] overloads must hold the package's decompressed contents in memory for the duration of the read.
The nine StreamRows overloads, all returning IEnumerable<StreamRow>
The Stream and byte[] overloads exist for convenience when a file already sits in memory, such as an upload that has been buffered. They still stream row by row, so they avoid building the full workbook model, but the unpacked contents stay resident for the whole read. Expect that to run around ten times the file's size on disk, so a 23.4 MB workbook holds a little over 200 MB rather than the figure its file size suggests. Use a file path for the largest files.
All three return identical rows and totals; the cost is memory, not speed or correctness
Source
Elapsed
Peak memory
string path
6.3 s
75 MB
Stream
6.2 s
309 MB
byte[]
6.3 s
291 MB
Elapsed time is effectively identical across the three, which is worth noticing: picking the wrong overload does not make the read slower, it makes it heavier. A job that fits comfortably in memory will not feel the difference at all until the file grows or the container's limit is lowered.
How Do I Pick a Worksheet?
Sheet selection has three forms: a zero-based index, an exact sheet name matched case-insensitively, or a StreamReadOptions instance. An unknown sheet name or an out-of-range index throws ArgumentException.
using IronXL;using System;// 1. By zero-based index. Omitting the argument streams the first sheet.foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", 2)){Console.WriteLine(row[0]?.Text);}// 2. By sheet name. Matching is case-insensitive, so "transactions" also works.foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", "Transactions")){Console.WriteLine(row[0]?.Text);}// 3. By StreamReadOptions, which is the only route to header support.// SheetName takes precedence over SheetIndex whenever it is set.StreamReadOptions options = new StreamReadOptions{SheetName = "Transactions",HasHeaderRow = true};foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", options)){Console.WriteLine(row["Reference"]?.Text);}// An unknown sheet name or an out-of-range index throws ArgumentException.try{ foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", "NoSuchSheet")) {Console.WriteLine(row.RowNumber); }}catch (ArgumentException ex){Console.WriteLine("Sheet selection failed: {0}", ex.Message);}
using IronXL;
using System;
// 1. By zero-based index. Omitting the argument streams the first sheet.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", 2))
{
Console.WriteLine(row[0]?.Text);
}
// 2. By sheet name. Matching is case-insensitive, so "transactions" also works.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "Transactions"))
{
Console.WriteLine(row[0]?.Text);
}
// 3. By StreamReadOptions, which is the only route to header support.
// SheetName takes precedence over SheetIndex whenever it is set.
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
Console.WriteLine(row["Reference"]?.Text);
}
// An unknown sheet name or an out-of-range index throws ArgumentException.
try
{
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "NoSuchSheet"))
{
Console.WriteLine(row.RowNumber);
}
}
catch (ArgumentException ex)
{
Console.WriteLine("Sheet selection failed: {0}", ex.Message);
}
C#
Passing no sheet argument at all streams the first sheet, because sheetIndex defaults to 0. StreamReadOptions is the only route that supports header rows, so reach for it as soon as columns need names.
How Do I Discover Sheets Before Streaming?
WorkBook.GetSheetNames lists every sheet in workbook order without loading the workbook's contents. It reads only the workbook's part list, so it stays cheap even on a very large file: about a quarter of a second on the 23.4 MB, 500,000-row workbook, which is the same time it takes on a file of a few kilobytes. That makes it the natural first call for a file arriving from an external system whose layout is not known in advance.
The three GetSheetNames overloads, all returning IReadOnlyList<string>
Overload
Source
GetSheetNames(string path)
A file on disk
GetSheetNames(Stream stream)
An open stream
GetSheetNames(byte[] data)
An in-memory buffer
Position in the returned list equals the sheet index that StreamRows accepts, so the two APIs line up directly: index 2 in this list is the sheet that StreamRows(path, 2) reads.
using IronXL;using System;using System.Collections.Generic;// GetSheetNames reads only the workbook's part list, not sheet data, so it is cheap// even on a very large file. List position equals the sheet index StreamRows accepts.IReadOnlyList<string> sheetNames = WorkBook.GetSheetNames("large-export.xlsx");for (int i = 0; i < sheetNames.Count; i++){Console.WriteLine("[{0}] {1}", i, sheetNames[i]);}// Stream by exact sheet name. Matching is case-insensitive.// An unknown name, or an out-of-range index, throws ArgumentException.foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", "Transactions")){ StreamCell reference = row[0]; if (reference is not null) {Console.WriteLine(reference.Text); }}
using IronXL;
using System;
using System.Collections.Generic;
// GetSheetNames reads only the workbook's part list, not sheet data, so it is cheap
// even on a very large file. List position equals the sheet index StreamRows accepts.
IReadOnlyList<string> sheetNames = WorkBook.GetSheetNames("large-export.xlsx");
for (int i = 0; i < sheetNames.Count; i++)
{
Console.WriteLine("[{0}] {1}", i, sheetNames[i]);
}
// Stream by exact sheet name. Matching is case-insensitive.
// An unknown name, or an out-of-range index, throws ArgumentException.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "Transactions"))
{
StreamCell reference = row[0];
if (reference is not null)
{
Console.WriteLine(reference.Text);
}
}
C#
How Do I Read Rows by Column Name?
Setting HasHeaderRow names the columns and lets rows be read by header name. The header row is consumed for naming and is not yielded as data, and any rows before HeaderRowIndex are skipped, which handles the common case of a title or preamble sitting above the real header.
using IronXL;using System;// This source file carries a title and a blank spacer above the real header,// so the header sits on Excel row 3, which is HeaderRowIndex 2 (zero-based).StreamReadOptions options = new StreamReadOptions{SheetName = "Transactions",HasHeaderRow = true,HeaderRowIndex = 2};foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", options)){ // The header row is consumed to name columns and is never yielded as data. // Rows before HeaderRowIndex are skipped. // Named access requires HasHeaderRow; without it the string indexer throws // InvalidOperationException. StreamCell reference = row["Reference"]; StreamCell amount = row["Amount"]; // Both the sparse-row case and a blank cell produce null, so null-check before use. if (reference is null || amount is null) { continue; }Console.WriteLine("{0}: {1}", reference.Text, amount.Text);}
using IronXL;
using System;
// This source file carries a title and a blank spacer above the real header,
// so the header sits on Excel row 3, which is HeaderRowIndex 2 (zero-based).
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true,
HeaderRowIndex = 2
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
// The header row is consumed to name columns and is never yielded as data.
// Rows before HeaderRowIndex are skipped.
// Named access requires HasHeaderRow; without it the string indexer throws
// InvalidOperationException.
StreamCell reference = row["Reference"];
StreamCell amount = row["Amount"];
// Both the sparse-row case and a blank cell produce null, so null-check before use.
if (reference is null || amount is null)
{
continue;
}
Console.WriteLine("{0}: {1}", reference.Text, amount.Text);
}
C#
Warning: Named access requires HasHeaderRow. Without it, the string indexer and TryGetCell both throw InvalidOperationException.
Header handling is deliberately strict. If the sheet has no row at or before the configured HeaderRowIndex, enumeration throws InvalidOperationException with a "header row not found" message, and it throws as soon as a data row overshoots the header index. This ensures that a consumer using Take or an early break sees the misconfiguration immediately rather than silently reading nothing.
Column name matching is case-insensitive and trims surrounding whitespace. Duplicate header names resolve to the first, left-most matching column.
What Does StreamReadOptions Configure?
StreamReadOptions properties, set through the object initialiser
Property
Type
Default
Behaviour
SheetIndex
int
0
Zero-based worksheet index. Ignored when SheetName is set
SheetName
string
null
Worksheet name, matched case-insensitively. Takes precedence over SheetIndex
HasHeaderRow
bool
false
When true, the row at HeaderRowIndex names the columns and is not yielded as data
HeaderRowIndex
int
0
Zero-based sheet row holding the header. Rows before it are skipped
How Do I Handle Optional Columns?
The two named accessors differ in how they treat a column that is not in the header, and picking the wrong one is what turns a tolerable source file into a crash. The string indexer throws ArgumentException; TryGetCell returns false.
using IronXL;using System;StreamReadOptions options = new StreamReadOptions{SheetIndex = 0,HasHeaderRow = true};foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", options)){ // The string indexer throws ArgumentException when the column name is absent // from the header. TryGetCell returns false instead, which makes it the correct // choice for a column that may not exist in every source file. if (row.TryGetCell("CostCentre", out StreamCell costCentre) && costCentre is not null) {Console.WriteLine("Cost centre: {0}", costCentre.Text); } else {Console.WriteLine("Cost centre: not supplied"); } // Header is positioned by column, so Header[i] lines up with column i. // A blank header column is null. for (int i = 0; i < row.Header.Count; i++) { string columnName = row.Header[i]; if (string.IsNullOrEmpty(columnName)) { continue; } StreamCell cell = row[i];Console.WriteLine(" {0} ({1}) = {2}", columnName, cell?.ColumnLetter, cell?.Text); }}
using IronXL;
using System;
StreamReadOptions options = new StreamReadOptions
{
SheetIndex = 0,
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
// The string indexer throws ArgumentException when the column name is absent
// from the header. TryGetCell returns false instead, which makes it the correct
// choice for a column that may not exist in every source file.
if (row.TryGetCell("CostCentre", out StreamCell costCentre) && costCentre is not null)
{
Console.WriteLine("Cost centre: {0}", costCentre.Text);
}
else
{
Console.WriteLine("Cost centre: not supplied");
}
// Header is positioned by column, so Header[i] lines up with column i.
// A blank header column is null.
for (int i = 0; i < row.Header.Count; i++)
{
string columnName = row.Header[i];
if (string.IsNullOrEmpty(columnName))
{
continue;
}
StreamCell cell = row[i];
Console.WriteLine(" {0} ({1}) = {2}", columnName, cell?.ColumnLetter, cell?.Text);
}
}
C#
TryGetCell answers a narrower question than it first appears: it reports whether the header contains the column name, not whether this particular row has a value there. A row with a sparse gap in a known column returns true with a null cell, so a null check is still needed on the way out. Use TryGetCell for any column that may be missing from some source files, and the string indexer only where the column is guaranteed.
How Do I Work with a StreamRow?
A StreamRow is one row produced during enumeration. It exposes its position, the cells it actually contains, and both positional and named lookup.
using IronXL;using System;StreamReadOptions options = new StreamReadOptions{SheetName = "Transactions",HasHeaderRow = true};foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", options)){ // RowNumber is zero-based, so Excel row 1 is RowNumber 0. // With a header consumed, the first data row keeps its real sheet position.Console.WriteLine("Sheet row {0}, {1} populated cells", row.RowNumber, row.Cells.Count); // Index access. Rows are sparse, so a column with no content returns null // rather than a blank cell object. StreamCell firstColumn = row[0];Console.WriteLine(" column A: {0}", firstColumn?.Text ?? "(empty)"); // Name access. Requires HasHeaderRow, and throws ArgumentException if the // header has no such column. Matching is case-insensitive and trims whitespace. StreamCell reference = row["Reference"];Console.WriteLine(" Reference: {0}", reference?.Text ?? "(empty)"); // Header is positioned by column: Header[i] names column i. // A column with a blank header cell is null. for (int i = 0; i < row.Header.Count; i++) { string name = row.Header[i];Console.WriteLine(" column {0} is named {1}", i, name ?? "(unnamed)"); }}
using IronXL;
using System;
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
// RowNumber is zero-based, so Excel row 1 is RowNumber 0.
// With a header consumed, the first data row keeps its real sheet position.
Console.WriteLine("Sheet row {0}, {1} populated cells", row.RowNumber, row.Cells.Count);
// Index access. Rows are sparse, so a column with no content returns null
// rather than a blank cell object.
StreamCell firstColumn = row[0];
Console.WriteLine(" column A: {0}", firstColumn?.Text ?? "(empty)");
// Name access. Requires HasHeaderRow, and throws ArgumentException if the
// header has no such column. Matching is case-insensitive and trims whitespace.
StreamCell reference = row["Reference"];
Console.WriteLine(" Reference: {0}", reference?.Text ?? "(empty)");
// Header is positioned by column: Header[i] names column i.
// A column with a blank header cell is null.
for (int i = 0; i < row.Header.Count; i++)
{
string name = row.Header[i];
Console.WriteLine(" column {0} is named {1}", i, name ?? "(unnamed)");
}
}
C#
StreamRow members, with both positional and named access
Member
Type
Behaviour
RowNumber
int
Zero-based. Excel row 1 is RowNumber 0
Cells
IReadOnlyList<StreamCell>
Sparse. Only columns with content are present
Header
IReadOnlyList<string>
Column names positioned by column, so Header[i] names column i. A blank header column is null, and Header itself is null when no header was configured
this[int columnIndex]
StreamCell
Cell at a zero-based column index, or null for a sparse gap
this[string columnName]
StreamCell
Cell under a named header column. Throws ArgumentException when the name is absent
TryGetCell(string, out StreamCell)
bool
true when the header contains the name, false when it does not
Warning: Header is null unless StreamReadOptions.HasHeaderRow was set. Guard it before iterating, or the loop dereferences a null list.
Sparseness is the property that shapes calling code. A row is not a fixed-width record: a sheet with columns A through F produces rows that contain three cells where three columns were filled in, and the positional indexer returns null for the gaps. RowNumber being zero-based is consistent with the rest of IronXL, where numeric indexers are zero-based while A1-style string addresses are not.
How Do I Read Typed Values from a StreamCell?
A StreamCell is an immutable snapshot of one cell. Unlike a normal Cell, it is not backed by the workbook and cannot be used to write a value back.
using IronXL;using System;foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", "Transactions")){ StreamCell cell = row[1]; // Sparse gap: no cell at this column on this row. if (cell is null) { continue; } // Position: zero-based index, plus the spreadsheet column letter.Console.WriteLine("Column {0} (letter {1})", cell.ColumnIndex, cell.ColumnLetter); // Type classifies the value. For a formula cell it reports the type of the // cached result, never a "formula" type.Console.WriteLine(" Type: {0}", cell.Type); // IsFormula is how formula-ness is exposed. The expression itself is not // available from the streaming reader, only the cached result.Console.WriteLine(" IsFormula: {0}", cell.IsFormula); // Value is the raw typed value, and is null for a blank cell.Console.WriteLine(" Value: {0}", cell.Value ?? "(null)"); // Text is the formatted display text, which is what Excel shows in the cell.Console.WriteLine(" Text: {0}", cell.Text);}
using IronXL;
using System;
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", "Transactions"))
{
StreamCell cell = row[1];
// Sparse gap: no cell at this column on this row.
if (cell is null)
{
continue;
}
// Position: zero-based index, plus the spreadsheet column letter.
Console.WriteLine("Column {0} (letter {1})", cell.ColumnIndex, cell.ColumnLetter);
// Type classifies the value. For a formula cell it reports the type of the
// cached result, never a "formula" type.
Console.WriteLine(" Type: {0}", cell.Type);
// IsFormula is how formula-ness is exposed. The expression itself is not
// available from the streaming reader, only the cached result.
Console.WriteLine(" IsFormula: {0}", cell.IsFormula);
// Value is the raw typed value, and is null for a blank cell.
Console.WriteLine(" Value: {0}", cell.Value ?? "(null)");
// Text is the formatted display text, which is what Excel shows in the cell.
Console.WriteLine(" Text: {0}", cell.Text);
}
C#
StreamCell members, covering position, type and value
Member
Type
Behaviour
ColumnIndex
int
Zero-based column index
ColumnLetter
string
Spreadsheet column letter, for example "A", "C" or "AA"
Type
StreamCellType
The kind of value held. For a formula cell, the type of the cached result
Value
object
The raw typed value. null for a blank cell
Text
string
The formatted display text, as Excel shows it
IsFormula
bool
true when the cell contains a formula
Value is boxed, so what you convert it to follows from Type:
Number gives a double
String gives a string
Boolean gives a bool
Date gives a DateTime
Blank gives null
Checking Type before touching Value is the difference between an import that survives real data and one that throws on row 40,000.
using IronXL;using System;using System.Globalization;StreamReadOptions options = new StreamReadOptions{SheetName = "Transactions",HasHeaderRow = true};foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", options)){ StreamCell amount = row["Amount"]; // Sparse rows and blank cells both yield null. Check before touching Type or Value. if (amount is null || amount.Type == StreamCellType.Blank) { continue; } // A formula cell reports IsFormula true, and Type reports the real type of the // cached result. The formula expression itself is not exposed, and nothing is // recalculated, which is why StreamCellType has no Formula member. if (amount.IsFormula) {Console.WriteLine("Row {0} column {1} is a cached formula result.", row.RowNumber, amount.ColumnLetter); } switch (amount.Type) { case StreamCellType.Number: // Convert rather than casting directly: the boxed numeric type is not // guaranteed to be the one you expect. decimal value = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture);Console.WriteLine("Amount: {0}", value); break; case StreamCellType.Date: // Date cells honour the workbook's date system, including 1904-based // workbooks produced by Excel for Mac. if (amount.Valueis DateTime date) {Console.WriteLine("Date: {0:yyyy-MM-dd}", date); } break; case StreamCellType.Boolean:Console.WriteLine("Flag: {0}", Convert.ToBoolean(amount.Value)); break; case StreamCellType.Error:Console.WriteLine("Row {0} contains an error value.", row.RowNumber); break; default: // Falls through to the formatted display text for String and anything else.Console.WriteLine("Text: {0}", amount.Text); break; }}
using IronXL;
using System;
using System.Globalization;
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true
};
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", options))
{
StreamCell amount = row["Amount"];
// Sparse rows and blank cells both yield null. Check before touching Type or Value.
if (amount is null || amount.Type == StreamCellType.Blank)
{
continue;
}
// A formula cell reports IsFormula true, and Type reports the real type of the
// cached result. The formula expression itself is not exposed, and nothing is
// recalculated, which is why StreamCellType has no Formula member.
if (amount.IsFormula)
{
Console.WriteLine("Row {0} column {1} is a cached formula result.",
row.RowNumber, amount.ColumnLetter);
}
switch (amount.Type)
{
case StreamCellType.Number:
// Convert rather than casting directly: the boxed numeric type is not
// guaranteed to be the one you expect.
decimal value = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture);
Console.WriteLine("Amount: {0}", value);
break;
case StreamCellType.Date:
// Date cells honour the workbook's date system, including 1904-based
// workbooks produced by Excel for Mac.
if (amount.Value is DateTime date)
{
Console.WriteLine("Date: {0:yyyy-MM-dd}", date);
}
break;
case StreamCellType.Boolean:
Console.WriteLine("Flag: {0}", Convert.ToBoolean(amount.Value));
break;
case StreamCellType.Error:
Console.WriteLine("Row {0} contains an error value.", row.RowNumber);
break;
default:
// Falls through to the formatted display text for String and anything else.
Console.WriteLine("Text: {0}", amount.Text);
break;
}
}
C#
Two habits matter here. The first is null-checking every indexer access, because rows are sparse and Value is null for a blank cell, so both cases converge on the same check. The second is converting rather than casting: Value holds the raw typed value, and code that casts straight from it teaches a bug that surfaces only on the one file where a column happens to be stored differently. Reading Text instead is entirely reasonable when the destination is a string column.
What Does StreamCellType Report?
StreamCellType values, reported by StreamCell.Type
Value
Reported when
Blank
The cell holds no value. Value is null
Number
The cell holds a numeric value
String
The cell holds text
Boolean
The cell holds TRUE or FALSE
Date
The cell holds a date, honouring the workbook's date system
Error
The cell holds an Excel error value
There is deliberately no Formula member. A formula cell reports the real type of its cached result here, for example Number, and formula-ness is exposed separately through IsFormula. Anyone scanning the enum for a formula case will not find one, and that is by design rather than an omission.
What Are the Limitations of the Streaming Reader?
Knowing these up front prevents the most common misdiagnoses.
Only the path overloads are genuinely low-memory. This is the most important caveat on the page. A developer who passes a 400 MB upload as a byte[], watches memory climb, and concludes the feature is broken has hit exactly this.
The remaining boundaries are fixed by the design:
Read-only: The streaming reader cannot modify or save. Editing means WorkBook.Load.
XLSX only: No .xls, no encrypted workbooks, and no writing of any kind.
Forward-only and single-pass: There is no random access and no rewinding. Code that needs to jump around a sheet wants Load.
No styling or formatting objects: Cell appearance is not exposed.
Cached formula results only: The formula expression is not exposed and nothing is recalculated.
Please note: Both StreamRows and GetSheetNames require a valid IronXL license, enforced when the method is called, exactly as with WorkBook.Load.
One further caveat sits between limitation and behaviour: low-memory reading from a path is best-effort. A workbook whose zip central directory cannot be read may fall back to loading the package into memory.
How Do I Build a Streaming Import Pipeline?
The pattern that carries production work is a streaming read feeding batched writes, so neither the source nor the destination is held in memory at full size.
using IronXL;using System;using System.Collections.Generic;using System.Globalization;using System.Threading.Tasks;public class TransactionImporter{ private const intBatchSize = 5000; public async Task<int> ImportAsync(string path, ITransactionSink sink) { StreamReadOptions options = new StreamReadOptions {SheetName = "Transactions",HasHeaderRow = true,HeaderRowIndex = 0 }; List<TransactionRecord> batch = new List<TransactionRecord>(BatchSize); int imported = 0; // Enumeration is lazy: only one row is materialised at a time, so peak memory // stays flat regardless of how many rows the sheet holds. foreach (StreamRow row inWorkBook.StreamRows(path, options)) { TransactionRecord record = MapRow(row); if (record is null) { continue; } batch.Add(record); if (batch.Count == BatchSize) { await sink.WriteAsync(batch); imported += batch.Count; batch.Clear(); } } if (batch.Count > 0) { await sink.WriteAsync(batch); imported += batch.Count; } return imported; } private static TransactionRecordMapRow(StreamRow row) { StreamCell reference = row["Reference"]; StreamCell amount = row["Amount"]; StreamCell posted = row["PostedOn"]; // Required columns missing on this row: skip rather than throw, so one bad // row does not abort an import of several hundred thousand. if (reference is null || amount is null) { return null; } if (amount.Type != StreamCellType.Number) { return null; } DateTime? postedOn = posted?.Valueas DateTime?; // Optional column that only some source files carry. string costCentre = row.TryGetCell("CostCentre", out StreamCell centre) ? centre?.Text : null; return new TransactionRecord {SourceRow = row.RowNumber,Reference = reference.Text,Amount = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture),PostedOn = postedOn,CostCentre = costCentre }; }}public class TransactionRecord{ public intSourceRow { get; set; } public stringReference { get; set; } public decimalAmount { get; set; } public DateTime? PostedOn { get; set; } public stringCostCentre { get; set; }}public interface ITransactionSink{ TaskWriteAsync(IReadOnlyList<TransactionRecord> batch);}
using IronXL;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
public class TransactionImporter
{
private const int BatchSize = 5000;
public async Task<int> ImportAsync(string path, ITransactionSink sink)
{
StreamReadOptions options = new StreamReadOptions
{
SheetName = "Transactions",
HasHeaderRow = true,
HeaderRowIndex = 0
};
List<TransactionRecord> batch = new List<TransactionRecord>(BatchSize);
int imported = 0;
// Enumeration is lazy: only one row is materialised at a time, so peak memory
// stays flat regardless of how many rows the sheet holds.
foreach (StreamRow row in WorkBook.StreamRows(path, options))
{
TransactionRecord record = MapRow(row);
if (record is null)
{
continue;
}
batch.Add(record);
if (batch.Count == BatchSize)
{
await sink.WriteAsync(batch);
imported += batch.Count;
batch.Clear();
}
}
if (batch.Count > 0)
{
await sink.WriteAsync(batch);
imported += batch.Count;
}
return imported;
}
private static TransactionRecord MapRow(StreamRow row)
{
StreamCell reference = row["Reference"];
StreamCell amount = row["Amount"];
StreamCell posted = row["PostedOn"];
// Required columns missing on this row: skip rather than throw, so one bad
// row does not abort an import of several hundred thousand.
if (reference is null || amount is null)
{
return null;
}
if (amount.Type != StreamCellType.Number)
{
return null;
}
DateTime? postedOn = posted?.Value as DateTime?;
// Optional column that only some source files carry.
string costCentre = row.TryGetCell("CostCentre", out StreamCell centre)
? centre?.Text
: null;
return new TransactionRecord
{
SourceRow = row.RowNumber,
Reference = reference.Text,
Amount = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture),
PostedOn = postedOn,
CostCentre = costCentre
};
}
}
public class TransactionRecord
{
public int SourceRow { get; set; }
public string Reference { get; set; }
public decimal Amount { get; set; }
public DateTime? PostedOn { get; set; }
public string CostCentre { get; set; }
}
public interface ITransactionSink
{
Task WriteAsync(IReadOnlyList<TransactionRecord> batch);
}
C#
The batching is what makes this hold up. Rows arrive one at a time, accumulate into a fixed-size buffer, and flush to the database or queue when the buffer fills. Peak memory is bounded by the batch size rather than by the row count, which means the same code handles a 5,000-row file and a 5,000,000-row file identically.
Skipping malformed rows rather than throwing is a deliberate choice for bulk imports. One unparseable row in several hundred thousand should not abort the run, and recording RowNumber alongside each record gives operators a way to trace a bad value back to its cell in the source file.
What Edge Cases Should I Handle?
GetSheetNames returns every sheet in the workbook, including chartsheets. A chartsheet holds no rows, so passing its index to StreamRows yields an empty sequence rather than raising an error. Code that loops every sheet index will encounter this, and an empty result there is expected.
using IronXL;using System;using System.Collections.Generic;// GetSheetNames returns every sheet in workbook order, including chartsheets.// List position equals the index that StreamRows accepts.IReadOnlyList<string> sheetNames = WorkBook.GetSheetNames("large-export.xlsx");for (int i = 0; i < sheetNames.Count; i++){ int rowCount = 0; foreach (StreamRow row inWorkBook.StreamRows("large-export.xlsx", i)) { rowCount++; } // A chartsheet holds no rows, so it streams as an empty sequence rather than // raising an error. Zero rows here is expected, not a failure to investigate. if (rowCount == 0) {Console.WriteLine("[{0}] {1}: no rows (chartsheet or empty worksheet)", i, sheetNames[i]); } else {Console.WriteLine("[{0}] {1}: {2} rows", i, sheetNames[i], rowCount); }}
using IronXL;
using System;
using System.Collections.Generic;
// GetSheetNames returns every sheet in workbook order, including chartsheets.
// List position equals the index that StreamRows accepts.
IReadOnlyList<string> sheetNames = WorkBook.GetSheetNames("large-export.xlsx");
for (int i = 0; i < sheetNames.Count; i++)
{
int rowCount = 0;
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx", i))
{
rowCount++;
}
// A chartsheet holds no rows, so it streams as an empty sequence rather than
// raising an error. Zero rows here is expected, not a failure to investigate.
if (rowCount == 0)
{
Console.WriteLine("[{0}] {1}: no rows (chartsheet or empty worksheet)", i, sheetNames[i]);
}
else
{
Console.WriteLine("[{0}] {1}: {2} rows", i, sheetNames[i], rowCount);
}
}
C#
Date cells honour the workbook's date system, including the 1904-based workbooks that Excel for Mac produces. No adjustment is needed in calling code.
Where Do I Go Next?
StreamRows covers one job well: getting values out of a large XLSX file quickly, with memory that stays flat. Anything beyond reading values belongs to the full workbook model, and mixing the two in one pipeline is normal. Stream to validate or import, then Load the smaller working file when edits are needed.
Ahmad is a full-stack developer with a strong foundation in C#, Python, and web technologies. He has a deep interest in building scalable software solutions and enjoys exploring how design and functionality meet in real-world applications.