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.
StreamRows and will fail to compile.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.
-
1Install IronXL with NuGet Package Manager
-
2Copy and run this code snippet.
foreach (StreamRow row in WorkBook.StreamRows("large-export.xlsx")) Console.WriteLine(row[0]?.Text);For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx") Console.WriteLine(row(0)?.Text) NextFor Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx") Console.WriteLine(row(0)?.Text) Next -
3Deploy to test on your live environment
Start using IronXL in your project today with a free trial
Minimal Workflow (5 steps)
- Install or upgrade to IronXL 2026.9.2 or later and apply a valid license key
- Call
WorkBook.GetSheetNames(path)if the target sheet needs to be discovered - Call
WorkBook.StreamRows(path, ...)with a sheet index, sheet name, orStreamReadOptions - Enumerate the returned
IEnumerable<StreamRow>, null-checking every cell access - Check
StreamCell.Typebefore readingValue, 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 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);
}
}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);
}
}Imports IronXL
Imports 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.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx")
' Cells holds only the columns that actually have content on this row.
For Each cell As StreamCell In row.Cells
Console.WriteLine("{0}{1} = {2}", cell.ColumnLetter, row.RowNumber + 1, cell.Text)
Next
NextImports IronXL
Imports 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.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx")
' Cells holds only the columns that actually have content on this row.
For Each cell As StreamCell In row.Cells
Console.WriteLine("{0}{1} = {2}", cell.ColumnLetter, row.RowNumber + 1, cell.Text)
Next
NextOutput
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 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.
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);
}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);
}Imports IronXL
Imports System
' 1. By zero-based index. Omitting the argument streams the first sheet.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", 2)
Console.WriteLine(row(0)?.Text)
Next
' 2. By sheet name. Matching is case-insensitive, so "transactions" also works.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "Transactions")
Console.WriteLine(row(0)?.Text)
Next
' 3. By StreamReadOptions, which is the only route to header support.
' SheetName takes precedence over SheetIndex whenever it is set.
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True
}
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", options)
Console.WriteLine(row("Reference")?.Text)
Next
' An unknown sheet name or an out-of-range index throws ArgumentException.
Try
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "NoSuchSheet")
Console.WriteLine(row.RowNumber)
Next
Catch ex As ArgumentException
Console.WriteLine("Sheet selection failed: {0}", ex.Message)
End TryImports IronXL
Imports System
' 1. By zero-based index. Omitting the argument streams the first sheet.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", 2)
Console.WriteLine(row(0)?.Text)
Next
' 2. By sheet name. Matching is case-insensitive, so "transactions" also works.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "Transactions")
Console.WriteLine(row(0)?.Text)
Next
' 3. By StreamReadOptions, which is the only route to header support.
' SheetName takes precedence over SheetIndex whenever it is set.
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True
}
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", options)
Console.WriteLine(row("Reference")?.Text)
Next
' An unknown sheet name or an out-of-range index throws ArgumentException.
Try
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "NoSuchSheet")
Console.WriteLine(row.RowNumber)
Next
Catch ex As ArgumentException
Console.WriteLine("Sheet selection failed: {0}", ex.Message)
End TryPassing 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 in WorkBook.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);
}
}Imports IronXL
Imports System
Imports 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.
Dim sheetNames As IReadOnlyList(Of String) = WorkBook.GetSheetNames("large-export.xlsx")
For i As Integer = 0 To sheetNames.Count - 1
Console.WriteLine("[{0}] {1}", i, sheetNames(i))
Next
' Stream by exact sheet name. Matching is case-insensitive.
' An unknown name, or an out-of-range index, throws ArgumentException.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "Transactions")
Dim reference As StreamCell = row(0)
If reference IsNot Nothing Then
Console.WriteLine(reference.Text)
End If
NextImports IronXL
Imports System
Imports 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.
Dim sheetNames As IReadOnlyList(Of String) = WorkBook.GetSheetNames("large-export.xlsx")
For i As Integer = 0 To sheetNames.Count - 1
Console.WriteLine("[{0}] {1}", i, sheetNames(i))
Next
' Stream by exact sheet name. Matching is case-insensitive.
' An unknown name, or an out-of-range index, throws ArgumentException.
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "Transactions")
Dim reference As StreamCell = row(0)
If reference IsNot Nothing Then
Console.WriteLine(reference.Text)
End If
NextHow 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 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);
}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);
}Imports IronXL
Imports 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).
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True,
.HeaderRowIndex = 2
}
For Each row As StreamRow 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.
Dim reference As StreamCell = row("Reference")
Dim amount As StreamCell = row("Amount")
' Both the sparse-row case and a blank cell produce null, so null-check before use.
If reference Is Nothing OrElse amount Is Nothing Then
Continue For
End If
Console.WriteLine("{0}: {1}", reference.Text, amount.Text)
NextImports IronXL
Imports 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).
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True,
.HeaderRowIndex = 2
}
For Each row As StreamRow 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.
Dim reference As StreamCell = row("Reference")
Dim amount As StreamCell = row("Amount")
' Both the sparse-row case and a blank cell produce null, so null-check before use.
If reference Is Nothing OrElse amount Is Nothing Then
Continue For
End If
Console.WriteLine("{0}: {1}", reference.Text, amount.Text)
NextHasHeaderRow. 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 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);
}
}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);
}
}Imports IronXL
Imports System
Dim options As New StreamReadOptions With {
.SheetIndex = 0,
.HasHeaderRow = True
}
For Each row As StreamRow 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.
Dim costCentre As StreamCell = Nothing
If row.TryGetCell("CostCentre", costCentre) And costCentre IsNot Nothing Then
Console.WriteLine("Cost centre: {0}", costCentre.Text)
Else
Console.WriteLine("Cost centre: not supplied")
End If
' Header is positioned by column, so Header[i] lines up with column i.
' A blank header column is null.
For i As Integer = 0 To row.Header.Count - 1
Dim columnName As String = row.Header(i)
If String.IsNullOrEmpty(columnName) Then
Continue For
End If
Dim cell As StreamCell = row(i)
Console.WriteLine(" {0} ({1}) = {2}", columnName, cell?.ColumnLetter, cell?.Text)
Next
NextImports IronXL
Imports System
Dim options As New StreamReadOptions With {
.SheetIndex = 0,
.HasHeaderRow = True
}
For Each row As StreamRow 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.
Dim costCentre As StreamCell = Nothing
If row.TryGetCell("CostCentre", costCentre) And costCentre IsNot Nothing Then
Console.WriteLine("Cost centre: {0}", costCentre.Text)
Else
Console.WriteLine("Cost centre: not supplied")
End If
' Header is positioned by column, so Header[i] lines up with column i.
' A blank header column is null.
For i As Integer = 0 To row.Header.Count - 1
Dim columnName As String = row.Header(i)
If String.IsNullOrEmpty(columnName) Then
Continue For
End If
Dim cell As StreamCell = row(i)
Console.WriteLine(" {0} ({1}) = {2}", columnName, cell?.ColumnLetter, cell?.Text)
Next
NextTryGetCell 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 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)");
}
}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)");
}
}Imports IronXL
Imports System
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True
}
For Each row As StreamRow 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.
Dim firstColumn As StreamCell = row(0)
Console.WriteLine(" column A: {0}", If(firstColumn?.Text, "(empty)"))
' Name access. Requires HasHeaderRow, and throws ArgumentException if the
' header has no such column. Matching is case-insensitive and trims whitespace.
Dim reference As StreamCell = row("Reference")
Console.WriteLine(" Reference: {0}", If(reference?.Text, "(empty)"))
' Header is positioned by column: Header[i] names column i.
' A column with a blank header cell is null.
For i As Integer = 0 To row.Header.Count - 1
Dim name As String = row.Header(i)
Console.WriteLine(" column {0} is named {1}", i, If(name, "(unnamed)"))
Next
NextImports IronXL
Imports System
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True
}
For Each row As StreamRow 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.
Dim firstColumn As StreamCell = row(0)
Console.WriteLine(" column A: {0}", If(firstColumn?.Text, "(empty)"))
' Name access. Requires HasHeaderRow, and throws ArgumentException if the
' header has no such column. Matching is case-insensitive and trims whitespace.
Dim reference As StreamCell = row("Reference")
Console.WriteLine(" Reference: {0}", If(reference?.Text, "(empty)"))
' Header is positioned by column: Header[i] names column i.
' A column with a blank header cell is null.
For i As Integer = 0 To row.Header.Count - 1
Dim name As String = row.Header(i)
Console.WriteLine(" column {0} is named {1}", i, If(name, "(unnamed)"))
Next
NextStreamRow 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 |
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 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);
}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);
}Imports IronXL
Imports System
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "Transactions")
Dim cell As StreamCell = row(1)
' Sparse gap: no cell at this column on this row.
If cell Is Nothing Then
Continue For
End If
' 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}", If(cell.Value, "(null)"))
' Text is the formatted display text, which is what Excel shows in the cell.
Console.WriteLine(" Text: {0}", cell.Text)
NextImports IronXL
Imports System
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", "Transactions")
Dim cell As StreamCell = row(1)
' Sparse gap: no cell at this column on this row.
If cell Is Nothing Then
Continue For
End If
' 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}", If(cell.Value, "(null)"))
' Text is the formatted display text, which is what Excel shows in the cell.
Console.WriteLine(" Text: {0}", cell.Text)
NextStreamCell 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:
Numbergives adoubleStringgives astringBooleangives aboolDategives aDateTimeBlankgivesnull
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 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;
}
}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;
}
}Imports IronXL
Imports System
Imports System.Globalization
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True
}
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", options)
Dim amount As StreamCell = row("Amount")
' Sparse rows and blank cells both yield null. Check before touching Type or Value.
If amount Is Nothing OrElse amount.Type = StreamCellType.Blank Then
Continue For
End If
' 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 Then
Console.WriteLine("Row {0} column {1} is a cached formula result.",
row.RowNumber, amount.ColumnLetter)
End If
Select Case amount.Type
Case StreamCellType.Number
' Convert rather than casting directly: the boxed numeric type is not
' guaranteed to be the one you expect.
Dim value As Decimal = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture)
Console.WriteLine("Amount: {0}", value)
Case StreamCellType.Date
' Date cells honour the workbook's date system, including 1904-based
' workbooks produced by Excel for Mac.
If TypeOf amount.Value Is DateTime Then
Dim [date] As DateTime = DirectCast(amount.Value, DateTime)
Console.WriteLine("Date: {0:yyyy-MM-dd}", [date])
End If
Case StreamCellType.Boolean
Console.WriteLine("Flag: {0}", Convert.ToBoolean(amount.Value))
Case StreamCellType.Error
Console.WriteLine("Row {0} contains an error value.", row.RowNumber)
Case Else
' Falls through to the formatted display text for String and anything else.
Console.WriteLine("Text: {0}", amount.Text)
End Select
NextImports IronXL
Imports System
Imports System.Globalization
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True
}
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", options)
Dim amount As StreamCell = row("Amount")
' Sparse rows and blank cells both yield null. Check before touching Type or Value.
If amount Is Nothing OrElse amount.Type = StreamCellType.Blank Then
Continue For
End If
' 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 Then
Console.WriteLine("Row {0} column {1} is a cached formula result.",
row.RowNumber, amount.ColumnLetter)
End If
Select Case amount.Type
Case StreamCellType.Number
' Convert rather than casting directly: the boxed numeric type is not
' guaranteed to be the one you expect.
Dim value As Decimal = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture)
Console.WriteLine("Amount: {0}", value)
Case StreamCellType.Date
' Date cells honour the workbook's date system, including 1904-based
' workbooks produced by Excel for Mac.
If TypeOf amount.Value Is DateTime Then
Dim [date] As DateTime = DirectCast(amount.Value, DateTime)
Console.WriteLine("Date: {0:yyyy-MM-dd}", [date])
End If
Case StreamCellType.Boolean
Console.WriteLine("Flag: {0}", Convert.ToBoolean(amount.Value))
Case StreamCellType.Error
Console.WriteLine("Row {0} contains an error value.", row.RowNumber)
Case Else
' Falls through to the formatted display text for String and anything else.
Console.WriteLine("Text: {0}", amount.Text)
End Select
NextTwo 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.
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 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);
}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);
}Imports IronXL
Imports System
Imports System.Collections.Generic
Imports System.Globalization
Imports System.Threading.Tasks
Public Class TransactionImporter
Private Const BatchSize As Integer = 5000
Public Async Function ImportAsync(path As String, sink As ITransactionSink) As Task(Of Integer)
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True,
.HeaderRowIndex = 0
}
Dim batch As New List(Of TransactionRecord)(BatchSize)
Dim imported As Integer = 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.
For Each row As StreamRow In WorkBook.StreamRows(path, options)
Dim record As TransactionRecord = MapRow(row)
If record Is Nothing Then
Continue For
End If
batch.Add(record)
If batch.Count = BatchSize Then
Await sink.WriteAsync(batch)
imported += batch.Count
batch.Clear()
End If
Next
If batch.Count > 0 Then
Await sink.WriteAsync(batch)
imported += batch.Count
End If
Return imported
End Function
Private Shared Function MapRow(row As StreamRow) As TransactionRecord
Dim reference As StreamCell = row("Reference")
Dim amount As StreamCell = row("Amount")
Dim posted As StreamCell = 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 Nothing OrElse amount Is Nothing Then
Return Nothing
End If
If amount.Type <> StreamCellType.Number Then
Return Nothing
End If
Dim postedOn As DateTime? = CType(posted?.Value, DateTime?)
' Optional column that only some source files carry.
Dim costCentre As String = If(row.TryGetCell("CostCentre", Nothing),
If(Nothing Is Nothing, Nothing, Nothing.Text),
Nothing)
Return New TransactionRecord With {
.SourceRow = row.RowNumber,
.Reference = reference.Text,
.Amount = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture),
.PostedOn = postedOn,
.CostCentre = costCentre
}
End Function
End Class
Public Class TransactionRecord
Public Property SourceRow As Integer
Public Property Reference As String
Public Property Amount As Decimal
Public Property PostedOn As DateTime?
Public Property CostCentre As String
End Class
Public Interface ITransactionSink
Function WriteAsync(batch As IReadOnlyList(Of TransactionRecord)) As Task
End InterfaceImports IronXL
Imports System
Imports System.Collections.Generic
Imports System.Globalization
Imports System.Threading.Tasks
Public Class TransactionImporter
Private Const BatchSize As Integer = 5000
Public Async Function ImportAsync(path As String, sink As ITransactionSink) As Task(Of Integer)
Dim options As New StreamReadOptions With {
.SheetName = "Transactions",
.HasHeaderRow = True,
.HeaderRowIndex = 0
}
Dim batch As New List(Of TransactionRecord)(BatchSize)
Dim imported As Integer = 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.
For Each row As StreamRow In WorkBook.StreamRows(path, options)
Dim record As TransactionRecord = MapRow(row)
If record Is Nothing Then
Continue For
End If
batch.Add(record)
If batch.Count = BatchSize Then
Await sink.WriteAsync(batch)
imported += batch.Count
batch.Clear()
End If
Next
If batch.Count > 0 Then
Await sink.WriteAsync(batch)
imported += batch.Count
End If
Return imported
End Function
Private Shared Function MapRow(row As StreamRow) As TransactionRecord
Dim reference As StreamCell = row("Reference")
Dim amount As StreamCell = row("Amount")
Dim posted As StreamCell = 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 Nothing OrElse amount Is Nothing Then
Return Nothing
End If
If amount.Type <> StreamCellType.Number Then
Return Nothing
End If
Dim postedOn As DateTime? = CType(posted?.Value, DateTime?)
' Optional column that only some source files carry.
Dim costCentre As String = If(row.TryGetCell("CostCentre", Nothing),
If(Nothing Is Nothing, Nothing, Nothing.Text),
Nothing)
Return New TransactionRecord With {
.SourceRow = row.RowNumber,
.Reference = reference.Text,
.Amount = Convert.ToDecimal(amount.Value, CultureInfo.InvariantCulture),
.PostedOn = postedOn,
.CostCentre = costCentre
}
End Function
End Class
Public Class TransactionRecord
Public Property SourceRow As Integer
Public Property Reference As String
Public Property Amount As Decimal
Public Property PostedOn As DateTime?
Public Property CostCentre As String
End Class
Public Interface ITransactionSink
Function WriteAsync(batch As IReadOnlyList(Of TransactionRecord)) As Task
End InterfaceThe 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 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);
}
}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);
}
}Imports IronXL
Imports System
Imports System.Collections.Generic
' GetSheetNames returns every sheet in workbook order, including chartsheets.
' List position equals the index that StreamRows accepts.
Dim sheetNames As IReadOnlyList(Of String) = WorkBook.GetSheetNames("large-export.xlsx")
For i As Integer = 0 To sheetNames.Count - 1
Dim rowCount As Integer = 0
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", i)
rowCount += 1
Next
' 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 Then
Console.WriteLine("[{0}] {1}: no rows (chartsheet or empty worksheet)", i, sheetNames(i))
Else
Console.WriteLine("[{0}] {1}: {2} rows", i, sheetNames(i), rowCount)
End If
NextImports IronXL
Imports System
Imports System.Collections.Generic
' GetSheetNames returns every sheet in workbook order, including chartsheets.
' List position equals the index that StreamRows accepts.
Dim sheetNames As IReadOnlyList(Of String) = WorkBook.GetSheetNames("large-export.xlsx")
For i As Integer = 0 To sheetNames.Count - 1
Dim rowCount As Integer = 0
For Each row As StreamRow In WorkBook.StreamRows("large-export.xlsx", i)
rowCount += 1
Next
' 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 Then
Console.WriteLine("[{0}] {1}: no rows (chartsheet or empty worksheet)", i, sheetNames(i))
Else
Console.WriteLine("[{0}] {1}: {2} rows", i, sheetNames(i), rowCount)
End If
NextDate 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 covers standard reading, the Import Excel Files how-to covers data import patterns, and the Read Excel Files in C# tutorial works through the fundamentals. The file size limits troubleshooting page covers format-level row and column ceilings, and the WorkBook API reference documents the complete class.
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.