Designing a memory-bounded, resumable large-file import: streaming, low-allocation parsing, bounded pipelines, bulk insert, checkpoints and bad-row handling.
Never load the file. Stream it: open with a large buffer and SequentialScan, parse line by line with a fast CSV parser (or PipeReader + UTF-8 parsing), and push rows into a bounded Channel so the reader cannot outrun the writers. Insert in batches (e.g. 10,000โ50,000 rows) with SqlBulkCopy into a staging table, then merge into the real table in one transaction.
Make it resumable (checkpoint byte offset or line number per committed batch), send bad rows to a reject file with line numbers, make the load idempotent (file hash, staging swap), and emit progress metrics. Memory stays at a few hundred MB regardless of file size.
Blob / disk file (20 GB)
โ FileStream, 1 MiB buffer, SequentialScan
โผ
Reader task: parse rows (low allocation)
โ bounded Channel<Row[]> (capacity 4 batches) โ back-pressure
โผ
2โ4 writer tasks: SqlBulkCopy batches โโโบ staging table (no indexes, minimal logging)
โ
โผ
One transaction: validate + MERGE/SWITCH staging โ target
โ
โผ
Checkpoint table: file hash, last committed offset, row counts, rejects
| Concern | Choice | Why |
|---|---|---|
| Memory | Stream, bounded channel, reuse buffers | Memory proportional to batch size, not file size |
| Parsing | Fast parser (Sylvan.Data.Csv, CsvHelper) or PipeReader + Utf8Parser |
Avoid a string per field; correct quote handling |
| Insert | SqlBulkCopy with IDataReader streaming, TableLock, batch size 10โ50k |
10โ100x faster than row-by-row inserts |
| Atomicity | Load to staging, then switch/merge | Readers never see a half-loaded day |
| Restart | Checkpoint per committed batch | A failure at 80% resumes, not restarts |
| Bad data | Reject file with line number and reason; fail if > threshold | One bad row should not kill the run, a bad file should |
| Observability | Rows/s, bytes read, rejects, lag | Know if it will finish before business hours |
We replaced a job that called File.ReadAllLines and inserted with EF Core SaveChanges per 1,000 rows. It needed 30 GB of memory and 6 hours. The new design streamed through a DbDataReader from Sylvan's CSV reader straight into SqlBulkCopy (EnableStreaming = true), with 4 parallel writers into a heap staging table and a final ALTER TABLE ... SWITCH. It ran in 25 minutes with a peak of 300 MB and resumed from the last checkpoint after a node eviction.
A realistic senior design question that combines file I/O, memory, concurrency and database loading.
Green flags
Red flags
ReadAllLines or loading into a List first.File.ReadAllLines(20 GB) โ ~20 GB of UTF-16 strings = ~40 GB + array โ OOMKilled
EF Core Add + SaveChanges โ change tracking per entity, one INSERT per row โ hours
var channel = Channel.CreateBounded<Row[]>(new BoundedChannelOptions(4)
{
FullMode = BoundedChannelFullMode.Wait,
SingleReader = false,
SingleWriter = true
});
When the database is slow, the channel fills and WriteAsync waits, so the reader pauses instead of buffering gigabytes. Memory is roughly capacity ร batch size ร row size.
A CSV row of 20 fields as string.Split creates 21 strings. For 100 million rows that is 2 billion allocations and heavy GC. Options:
ReadOnlySpan<char> and parses numbers and dates directly from spans.PipeReader over the file, find newlines in ReadOnlySequence<byte>, parse UTF-8 with Utf8Parser / int.TryParse(ReadOnlySpan<byte>).CSV is not "split on commas": quoted fields can contain commas and newlines, so use a real parser.
| Approach | When |
|---|---|
| One reader, N writers | Default. Parsing is rarely the bottleneck; the database is |
Split file into byte ranges with RandomAccess |
Parsing is CPU-bound and rows never contain embedded newlines; align each range to the next newline |
| Multiple files in parallel | Upstream can split the export |
StreamReader reads ahead in large blocks, so FileStream.Position is not the position of the current line. Either track offsets yourself (count the UTF-8 bytes of each line plus its newline, or use a PipeReader where you see exact positions), or checkpoint the line number and skip that many lines on restart (simpler, slower to resume).SqlBulkCopy with SqlBulkCopyOptions.TableLock into an empty heap staging table enables minimal logging in the simple or bulk-logged recovery model.ALTER TABLE ... SWITCH requires staging and target to have the same columns, indexes, constraints and filegroup, so the swap is metadata-only and instant.SWITCH is not practical, use a set-based MERGE or INSERT ... SELECT in batches instead.EnableStreaming = true matters when the source is a streaming IDataReader, so rows flow through without buffering.BatchSize 10,000โ50,000 is a good start; measure.File.ReadAllLines and watch memory in dotnet-counters.SqlBulkCopy against a local SQL Server container.public sealed class NightlyImport(string connectionString, ILogger<NightlyImport> log)
{
private const int BatchSize = 20_000;
public async Task RunAsync(string path, long startOffset, CancellationToken ct)
{
var channel = Channel.CreateBounded<List<OrderRow>>(new BoundedChannelOptions(4)
{
FullMode = BoundedChannelFullMode.Wait,
SingleWriter = true
});
var writers = Enumerable.Range(0, 3)
.Select(_ => WriteBatchesAsync(channel.Reader, ct))
.ToArray();
await ReadFileAsync(path, startOffset, channel.Writer, ct);
await Task.WhenAll(writers);
}
private async Task ReadFileAsync(string path, long startOffset,
ChannelWriter<List<OrderRow>> output, CancellationToken ct)
{
try
{
await using var fs = new FileStream(path, new FileStreamOptions
{
Mode = FileMode.Open, Access = FileAccess.Read, Share = FileShare.Read,
BufferSize = 1024 * 1024,
Options = FileOptions.SequentialScan | FileOptions.Asynchronous
});
fs.Seek(startOffset, SeekOrigin.Begin);
using var reader = new StreamReader(fs, Encoding.UTF8);
var batch = new List<OrderRow>(BatchSize);
long lineNo = 0;
while (await reader.ReadLineAsync(ct) is { } line)
{
lineNo++;
if (OrderRow.TryParse(line, out var row)) batch.Add(row);
else log.LogWarning("Rejected line {Line}", lineNo); // also write to a reject file
if (batch.Count == BatchSize)
{
await output.WriteAsync(batch, ct); // waits when writers fall behind
batch = new List<OrderRow>(BatchSize);
}
}
if (batch.Count > 0) await output.WriteAsync(batch, ct);
}
finally
{
output.Complete();
}
}
private async Task WriteBatchesAsync(ChannelReader<List<OrderRow>> input, CancellationToken ct)
{
await foreach (var batch in input.ReadAllAsync(ct))
{
await using var conn = new SqlConnection(connectionString);
await conn.OpenAsync(ct);
using var bulk = new SqlBulkCopy(conn, SqlBulkCopyOptions.TableLock, null)
{
DestinationTableName = "staging.Orders",
BatchSize = batch.Count,
EnableStreaming = true,
BulkCopyTimeout = 0
};
await bulk.WriteToServerAsync(OrderRow.ToDataReader(batch), ct);
// record checkpoint for this batch here
}
}
}
-- Final step, one transaction: swap staging into place
BEGIN TRAN;
TRUNCATE TABLE dbo.Orders_Old;
ALTER TABLE dbo.Orders SWITCH TO dbo.Orders_Old;
ALTER TABLE staging.Orders SWITCH TO dbo.Orders;
COMMIT;
Mistake: Reading the whole file with File.ReadAllLines or ReadAllText.
Why it is wrong: A 20 GB UTF-8 file becomes roughly 40 GB of UTF-16 strings, so the container is OOMKilled long before the import starts.
Instead: Stream with StreamReader or PipeReader and keep only a few batches in memory.
Mistake: Inserting with EF Core SaveChanges or one INSERT per row.
Why it is wrong: Change tracking and a round trip per row turn minutes of work into hours.
Instead: Use SqlBulkCopy into a staging table.
Mistake: Using an unbounded queue between reading and writing.
Why it is wrong: Parsing is faster than the database, so the queue grows until memory runs out.
Instead: Use a bounded Channel with FullMode = Wait.
Mistake: Making the data visible before the load is complete. Why it is wrong: Writing straight into the live table, or switching after a failed batch, exposes partial or duplicated data to users and reports. Instead: Load into staging, wait for every writer to finish successfully, validate counts, then switch or merge in one transaction.
Mistake: Adding parallel parsers or more writers without measuring. Why it is wrong: If the transaction log or index maintenance is the bottleneck, more writers only add lock and log contention. Instead: Profile first (dotnet-counters, SQL wait stats, log write throughput) and tune batch size, minimal logging and staging indexes before adding threads.
The design goal is memory proportional to the batch, not the file, and failure is normal. Streaming and a bounded channel give predictable memory; staging plus an atomic swap gives consistency; checkpoints give resumability; a reject file and thresholds turn bad data into an operational decision.
| Option | Benefit | Cost or risk | When to use it |
|---|---|---|---|
ALTER TABLE ... SWITCH from staging |
Metadata-only, instant publish | Staging must match target schema, indexes and filegroup | Full nightly replace |
| Partition switch into a date partition | Load one day without touching others | Needs a partitioned target and aligned indexes | Append-only history tables |
Set-based MERGE or INSERT ... SELECT |
Works without schema matching | Logged, slower, can block readers | Upserts, small deltas |
| More parallel writers | Higher throughput if the database has headroom | Log and lock contention | After profiling shows spare capacity |
| Columnstore target | Great compression and analytics | Batches under 102,400 rows land in delta stores | Reporting tables |
In Azure, I check the database tier's log rate and IOPS limits (Azure SQL throttles log write throughput), watch the container's memory in Azure Monitor or Prometheus, and emit OpenTelemetry metrics for rows per second and ETA so an alert fires before the run misses its window.
A Stream is a sequence of bytes you read or write in order, without needing it all in memory. Stream is an abstract class with Read/Write (sync and async), optional Seek/Position/Length (only if CanSeek), and Flush/Dispose.
FileStream, MemoryStream, NetworkStream.BufferedStream, GZipStream, BrotliStream, CryptoStream, SslStream.StreamReader, StreamWriter, BinaryReader.Key gotcha: Read may return fewer bytes than you asked for, and 0 means end of stream. Loop, or use ReadExactly (throws EndOfStreamException if the stream ends early) or ReadAtLeast, both available in .NET 10.
In .NET: use a normal FileStream (optionally FileOptions.SequentialScan and a bigger buffer) for sequential work. For random work use RandomAccess.Read/Write with a SafeFileHandle (offset-based, thread-safe, no shared Position), FileOptions.RandomAccess, or a MemoryMappedFile when you want the OS to page data in for you.
RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.
How to tell it (STAR)
**S:** Our nightly [partner import] took [6 hours] on a [32 GB] node and sometimes left half-loaded data. **T:** I owned the redesign with a [1 GB] container limit. **A:** I streamed the file through a proper CSV parser into a bounded channel, used `SqlBulkCopy` into a staging table with three writers, and swapped it in with one transaction. I added checkpoints so an eviction resumes, and a reject file for bad rows. **R:** It now runs in [25 minutes] with [300 MB] peak memory, survived [two] evictions without duplicates, and we retired the large node.
Last updated 23 Oct 2026.