Senior 12 min · March 06, 2026

C# File I/O — Missing `using` Locked Production API

Production API failed with 'file in use' IOException from a missing using block.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Production
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • C# File I/O offers three layers: File static class, StreamReader/StreamWriter, and FileStream
  • File.ReadAllText loads everything into memory – use only for files under ~10MB
  • StreamReader.ReadLine() keeps memory flat regardless of file size, essential for unbounded files
  • Async variants (ReadAllTextAsync, ReadLineAsync) release threads during disk wait, preventing thread-pool starvation under load
  • Not disposing streams leaves files locked – leads to IOException in production bug reports
  • Biggest mistake: using File.ReadAllLines on user-uploaded CSVs – server crashes with OutOfMemoryException
✦ Definition~90s read
What is File I/O in C#?

C# File I/O is the set of APIs in .NET for reading and writing files on disk, spanning System.IO.File, StreamReader/StreamWriter, FileStream, and the newer File static methods in System.IO. The core problem this article addresses is that forgetting to wrap file handles in using statements (or Dispose() calls) leaves file streams open, which on Windows locks the file exclusively until the garbage collector runs — or worse, until the process ends.

Think of your hard drive as a giant filing cabinet.

In production APIs, this manifests as IOException: The process cannot access the file because it is being used by another process, often at the worst possible moment under load. The using pattern ensures deterministic release of OS file handles, which is non-negotiable in server-side code where thousands of concurrent requests may touch the same filesystem.

File I/O in .NET operates in three layers: the high-level static methods (File.ReadAllText, File.WriteAllLines) that handle open/read/close atomically but block the calling thread; the stream-based readers/writers (StreamReader, StreamWriter) that give you line-by-line or buffered control; and the raw FileStream with its FileShare flags for fine-grained locking behavior. The high-level methods are convenient but dangerous in APIs because they block the thread for the entire I/O duration.

The async counterparts (ReadAllTextAsync, WriteAsync) are essential for non-blocking server scenarios — every millisecond a thread spends waiting on disk I/O is a thread that could be handling another request. In high-throughput services like ASP.NET Core endpoints reading CSV files, blocking on synchronous I/O can exhaust the thread pool and cause cascading latency spikes.

Defensive patterns are mandatory: check File.Exists() before reading, wrap in try-catch for DirectoryNotFoundException, UnauthorizedAccessException, and IOException (which covers locks), and use FileShare.Read when opening for concurrent read access. For structured files like CSV, the standard pattern is to open a FileStream with FileShare.Read, wrap it in a StreamReader, then parse lines — all inside using blocks.

For concurrent write scenarios, consider FileShare.None for exclusive access or use a SemaphoreSlim to serialize writes. The alternative to raw file I/O in production is to use a database (SQLite, SQL Server) or a message queue (Azure Queue, RabbitMQ) for shared state — file locking is a distributed systems anti-pattern at scale.

This article walks through a real-world CSV ingestion endpoint and shows exactly where missing using kills your API.

Plain-English First

Think of your hard drive as a giant filing cabinet. Your C# program is the office worker who needs to pull out a document, read it, maybe scribble some notes on it, and then put it back. File I/O is simply the set of instructions that tells that office worker HOW to open the drawer, handle the document carefully, and close the drawer when done — without losing any pages or jamming the cabinet.

File I/O in C# looks simple—until it silently locks your production API at 2 AM. A missing using statement, a blocked thread on a disk read, or a naive one-liner for a text file can tank performance or crash your service entirely. This article walks through the real pitfalls: the three abstraction layers you can’t ignore, the hidden cost of synchronous I/O, defensive patterns for missing files and concurrent access, and the exact spots where memory leaks breed in FileStream. No fluff—just the sharp edges every senior dev hits when working with files in .NET.

Why Missing `using` in C# File I/O Locks Production APIs

C# file I/O is the mechanism for reading from and writing to the filesystem via the System.IO namespace. The core mechanic is that file handles are unmanaged resources: the OS kernel tracks them, and the .NET runtime cannot automatically reclaim them. When you open a file with FileStream, StreamReader, or StreamWriter, you acquire an exclusive or shared lock on that file handle. If you fail to release it—by not calling Dispose() or not wrapping the call in a using block—the handle remains open until the garbage collector runs a finalizer, which is non-deterministic and can take seconds to minutes.

In practice, this means that a production API endpoint that reads or writes a file without a using block will eventually fail under load. The first few requests succeed, but as handles accumulate, subsequent attempts to open the same file throw IOException: The process cannot access the file because it is being used by another process. The lock is per-handle, not per-thread, so even single-threaded code can deadlock itself if it opens a file, doesn't close it, and then tries to reopen it. The using statement compiles to a try/finally that calls Dispose(), which closes the handle immediately—this is O(1) and deterministic.

You must use using for every file I/O operation in production systems, especially in web APIs where concurrent requests are the norm. The pattern is trivial: using var fs = new FileStream(path, FileMode.Open);. Skipping it is not a style choice; it's a reliability defect. In high-throughput scenarios, even a single leaked handle can cascade into a full outage when all available file handles (default 8192 on Windows, often lower in containers) are exhausted.

GC Does Not Save You
Relying on the finalizer to close file handles is a gamble: GC runs on its own schedule, and under memory pressure, it may not run at all before you exhaust the OS handle limit.
Production Insight
A payment processing API that wrote audit logs to a shared file without using caused a production outage after 30 minutes of peak traffic: the process hit the 8192-handle limit, all subsequent file operations threw IOException, and the API returned 500 errors for every request until the process was restarted.
Symptom: Event Viewer shows 'The handle is invalid' or 'Access to the path is denied' errors, with no obvious memory leak because managed memory is fine.
Rule of thumb: Every new FileStream, File.OpenRead, or File.WriteAllText must be wrapped in using or called via a helper that guarantees disposal—no exceptions.
Key Takeaway
File handles are OS resources, not managed memory—they must be disposed deterministically, not left to GC.
Always wrap file I/O in using or try/finally; the using statement is syntactic sugar for correct disposal.
A single leaked file handle under load can lock a file and crash an entire API—this is a top-3 cause of production file I/O failures.
C# File I/O: Missing `using` Locks Production API THECODEFORGE.IO C# File I/O: Missing `using` Locks Production API Flow from FileStream to resource leak and production lock FileStream Creation Opens file handle without `using` Missing Dispose File handle not released on exception Resource Leak Unmanaged handle remains open File Lock Persists Other processes cannot access file Production API Blocked Read/write operations fail Fix: `using` Statement Auto-dispose even on exceptions ⚠ Missing `using` on FileStream locks file indefinitely Always wrap in `using` or call Dispose in finally THECODEFORGE.IO
thecodeforge.io
C# File I/O: Missing `using` Locks Production API
File Io Csharp

The Three Layers of File I/O in C# — and Why They Exist

C# gives you three distinct levels of abstraction for file work, each built on top of the one below it. Understanding this layering is what stops you from grabbing the wrong tool.

At the lowest level you have FileStream — raw bytes, maximum control, maximum verbosity. Above that sit StreamReader and StreamWriter, which wrap a FileStream and add character encoding and line-by-line text handling. At the top sits the static File class, which wraps everything into single-line convenience methods like File.ReadAllText and File.WriteAllLines.

The File class is perfect for small files where simplicity matters — it opens the file, does the work, and closes it all in one call. But it reads the entire file into memory at once, which is a problem when that file is 2 GB of server logs. That's when you drop down to StreamReader and read line by line, keeping your memory footprint flat regardless of file size.

FileStream is the layer you reach for when you need binary data — images, PDFs, serialized objects — or when you need fine-grained control over file sharing modes and access permissions.

Most real-world apps live in the middle layer. Know that the File convenience methods are literally just wrappers around streams — there's no magic, just convenience.

FileLayersDemo.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using System;
using System.IO;

class FileLayersDemo
{
    static void Main()
    {
        string filePath = "sample_log.txt";

        // --- LAYER 3: File class (convenience, small files) ---
        // Writes all content in one shot. File is opened and closed automatically.
        File.WriteAllText(filePath, "Line one\nLine two\nLine three\n");

        // Reads entire file into a single string — fine for small config files
        string entireContent = File.ReadAllText(filePath);
        Console.WriteLine("[File.ReadAllText output]");
        Console.WriteLine(entireContent);

        // --- LAYER 2: StreamReader (line-by-line, memory-efficient) ---
        // 'using' ensures the stream is closed even if an exception is thrown
        Console.WriteLine("[StreamReader line-by-line output]");
        using (StreamReader reader = new StreamReader(filePath))
        {
            string? currentLine;
            int lineNumber = 1;

            // ReadLine returns null when there are no more lines
            while ((currentLine = reader.ReadLine()) != null)
            {
                Console.WriteLine($"  Line {lineNumber++}: {currentLine}");
            }
        } // stream is guaranteed closed here

        // --- LAYER 1: FileStream (raw bytes, binary data) ---
        Console.WriteLine("\n[FileStream byte count]");
        using (FileStream rawStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
        {
            // Length gives total byte count — useful for binary files
            Console.WriteLine($"  File size in bytes: {rawStream.Length}");
        }
    }
}
Output
[File.ReadAllText output]
Line one
Line two
Line three
[StreamReader line-by-line output]
Line 1: Line one
Line 2: Line two
Line 3: Line three
[FileStream byte count]
File size in bytes: 33
Golden Rule:
Use File.ReadAllText / File.WriteAllText for files under ~10 MB where simplicity wins. Switch to StreamReader / StreamWriter the moment file size is unbounded or user-controlled — an uploaded CSV could be 500 MB.
Production Insight
Misusing File.ReadAllText on a large log file caused an out-of-memory crash in a monolith processing 50 MB logs every hour.
The fix swapped to StreamReader.ReadLine(), maintaining constant memory usage with no code complexity cost.
Rule: profile your file sizes and pick the layer that matches, not the one you're most comfortable with.
Key Takeaway
File class is convenience, not safety.
Know your layer before you write code.
Size determines choice — don't guess.

Async File I/O — Why Blocking a Thread on Disk Reads is a Hidden Performance Killer

Here's the thing most tutorials skip: disk I/O is slow. Not 'slightly slower than memory' slow — we're talking microseconds vs milliseconds. On a web server handling 500 concurrent requests, if each request reads a file synchronously, each one blocks a thread for that entire disk-wait time. Thread pool threads are a finite resource. Block enough of them and your server stops accepting new requests even though the CPU is sitting at 2% utilisation.

Async file I/O solves this by releasing the thread back to the pool while it waits for the disk. The thread goes off and serves other requests. When the disk responds, .NET picks up any available thread to continue the work.

File.ReadAllTextAsync and StreamReader.ReadLineAsync are the async counterparts you need. They return Task<string> and Task<string?> respectively, meaning you await them without blocking.

One critical nuance: StreamReader does NOT automatically buffer async reads efficiently when you call ReadLineAsync repeatedly in a tight loop on .NET 5 and earlier. On .NET 6+ this was fixed. If you're on an older runtime, prefer ReadToEndAsync or use FileStream with useAsync: true directly.

Async file operations belong in any application that handles concurrent workloads — ASP.NET Core controllers, background workers, and queue processors absolutely should not use synchronous file APIs.

AsyncFileOperations.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
using System;
using System.IO;
using System.Threading.Tasks;

class AsyncFileOperations
{
    // Simulates writing an application log entry asynchronously
    static async Task WriteLogEntryAsync(string logFilePath, string message)
    {
        // File.AppendAllTextAsync opens, appends, and closes — no stream management needed
        // The thread is released back to the pool while the OS handles the disk write
        string timestampedEntry = $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}";
        await File.AppendAllTextAsync(logFilePath, timestampedEntry);
    }

    // Reads a potentially large report file line by line without blocking
    static async Task<int> CountMatchingLinesAsync(string reportFilePath, string searchTerm)
    {
        int matchCount = 0;

        // StreamReader with 'await using' disposes asynchronously — important for async code
        await using (StreamReader reader = new StreamReader(reportFilePath))
        {
            string? line;
            while ((line = await reader.ReadLineAsync()) != null)
            {
                // Case-insensitive search — realistic for log analysis
                if (line.Contains(searchTerm, StringComparison.OrdinalIgnoreCase))
                {
                    matchCount++;
                }
            }
        }

        return matchCount;
    }

    static async Task Main()
    {
        string logPath = "application.log";

        // Simulate writing several log entries
        await WriteLogEntryAsync(logPath, "Application started");
        await WriteLogEntryAsync(logPath, "User login: alice@example.com");
        await WriteLogEntryAsync(logPath, "ERROR: Database connection timeout");
        await WriteLogEntryAsync(logPath, "User login: bob@example.com");
        await WriteLogEntryAsync(logPath, "ERROR: Null reference in PaymentService");

        Console.WriteLine($"Log file written to: {logPath}");

        // Count how many ERROR lines are in the log
        int errorCount = await CountMatchingLinesAsync(logPath, "ERROR");
        Console.WriteLine($"Total ERROR entries found: {errorCount}");

        // Read and display the full log to confirm
        string fullLog = await File.ReadAllTextAsync(logPath);
        Console.WriteLine("\n--- Full Log Contents ---");
        Console.WriteLine(fullLog);
    }
}
Output
Log file written to: application.log
Total ERROR entries found: 2
--- Full Log Contents ---
[2024-03-15 09:42:11] Application started
[2024-03-15 09:42:11] User login: alice@example.com
[2024-03-15 09:42:11] ERROR: Database connection timeout
[2024-03-15 09:42:11] User login: bob@example.com
[2024-03-15 09:42:11] ERROR: Null reference in PaymentService
Watch Out:
Using async void instead of async Task for file methods means any exception thrown during the async operation is unobservable — it won't be caught by your try/catch and will silently crash the process. Always return Task or Task<T> from async file methods.
Production Insight
A production API reading a 10KB config file synchronously per request caused thread-pool exhaustion at 500 rps.
Response time spiked from 50ms to 30s. Switching to ReadAllTextAsync released threads during disk wait.
Rule: synchronous file I/O in a concurrent context is a scalability antipattern. Always await the async variant.
Key Takeaway
Synchronous file I/O under load starves the thread pool.
Async variants release threads during disk wait.
In web apps, sync reads = slow death.

Defensive File I/O — Handling Missing Files, Locked Resources and Directory Errors

Production file code fails in ways your dev machine never shows you. The config file doesn't exist on first run. The log directory hasn't been created yet. Another process has locked the file. The disk is full. A relative path resolves to a completely different location when deployed.

Defensive file I/O means anticipating these realities before they become 3am incident alerts.

The key exceptions to know are FileNotFoundException (file doesn't exist), DirectoryNotFoundException (parent directory missing), IOException (file locked, disk full, network drive disconnected), and UnauthorizedAccessException (permissions). Catching the base IOException catches most of them, but be specific when the recovery action differs.

For directories: always call Directory.CreateDirectory before writing — it's idempotent and won't throw if the directory already exists. This one pattern eliminates an entire class of deployment bugs.

For locked files: the right pattern is a retry loop with exponential back-off, not a bare try/catch that swallows the error. A locked file often means another process is actively writing to it and will be done in milliseconds.

For paths: use Path.Combine instead of string concatenation — it handles directory separators correctly across Windows, Linux, and macOS. Hardcoded backslashes are a cross-platform bug waiting to happen.

DefensiveFileIO.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
using System;
using System.IO;
using System.Threading;

class DefensiveFileIO
{
    // Uses Path.Combine — works on Windows (\) and Linux (/) without changes
    static string BuildReportPath(string baseDirectory, string reportName)
    {
        return Path.Combine(baseDirectory, "reports", $"{reportName}.txt");
    }

    // Ensures the directory exists before writing — safe to call multiple times
    static void EnsureDirectoryExists(string filePath)
    {
        string? directory = Path.GetDirectoryName(filePath);
        if (!string.IsNullOrEmpty(directory))
        {
            // CreateDirectory does nothing if directory already exists — no need to check first
            Directory.CreateDirectory(directory);
        }
    }

    // Retries on IOException (file lock) with exponential back-off
    static string ReadWithRetry(string filePath, int maxAttempts = 3)
    {
        for (int attempt = 1; attempt <= maxAttempts; attempt++)
        {
            try
            {
                return File.ReadAllText(filePath);
            }
            catch (FileNotFoundException)
            {
                // No point retrying — file genuinely doesn't exist
                throw;
            }
            catch (IOException ex) when (attempt < maxAttempts)
            {
                // File is locked by another process — wait and retry
                int delayMs = 100 * (int)Math.Pow(2, attempt); // 200ms, 400ms
                Console.WriteLine($"  File locked (attempt {attempt}), retrying in {delayMs}ms: {ex.Message}");
                Thread.Sleep(delayMs);
            }
        }
        throw new IOException($"Could not read '{filePath}' after {maxAttempts} attempts.");
    }

    static void Main()
    {
        string reportPath = BuildReportPath(AppDomain.CurrentDomain.BaseDirectory, "monthly_summary");
        Console.WriteLine($"Target path: {reportPath}");

        // Safe write — creates all missing directories automatically
        EnsureDirectoryExists(reportPath);
        File.WriteAllText(reportPath, "Monthly Revenue: $142,500\nNew Users: 3,421\n");
        Console.WriteLine("Report written successfully.");

        // Safe read with retry
        try
        {
            string reportContent = ReadWithRetry(reportPath);
            Console.WriteLine("\n--- Report Contents ---");
            Console.WriteLine(reportContent);
        }
        catch (FileNotFoundException)
        {
            Console.WriteLine("ERROR: Report file not found. Generate the report first.");
        }
        catch (UnauthorizedAccessException)
        {
            Console.WriteLine("ERROR: No permission to read report. Check file permissions.");
        }

        // Demonstrate safe check before delete
        if (File.Exists(reportPath))
        {
            File.Delete(reportPath);
            Console.WriteLine("\nReport cleaned up.");
        }
    }
}
Output
Target path: /app/reports/monthly_summary.txt
Report written successfully.
--- Report Contents ---
Monthly Revenue: $142,500
New Users: 3,421
Report cleaned up.
Interview Gold:
Directory.CreateDirectory is idempotent — calling it when the directory already exists doesn't throw an exception. This makes it safe as a defensive first step before any file write, no Directory.Exists check required.
Production Insight
A deploy script failed at 3am because the log directory didn't exist on a fresh server. The app threw DirectoryNotFoundException and crashed on startup.
Idempotent Directory.CreateDirectory would have prevented it in one line.
Rule: call CreateDirectory once before every write. It's free insurance.
Key Takeaway
Directory.CreateDirectory is free insurance.
Idempotent, safe, eliminates a class of deployment bugs.
Call it before every write – no check needed.

Working with CSV and Structured Text Files — A Real-World End-to-End Pattern

Almost every business application eventually processes CSV files — imports, exports, data migrations. This is where all the concepts above converge into a pattern you'll actually use.

The key insight for large CSV processing is streaming: read one line at a time, process it, move on. Never ReadAllLines a CSV that users upload — you're handing users a memory exhaustion attack vector. A 100 MB CSV with ReadAllLines allocates all 100 MB at once. With StreamReader.ReadLine you hold one line in memory at a time.

Encoding also matters in the real world. CSVs from Windows systems often arrive in Windows-1252 encoding. CSVs from Excel often have a UTF-8 BOM. StreamReader can auto-detect the BOM if you pass detectEncodingFromByteOrderMarks: true, which saves you from mysterious £ characters replacing £ signs.

For writing, StreamWriter with AutoFlush = false is dramatically faster than flushing after every line — let the OS buffer accumulate and flush at natural boundaries. If the process dies mid-write you'll lose the buffer, so pair this with a write-to-temp-file-then-rename pattern for atomicity.

The temp-file-then-rename pattern is the professional's choice for any file that must not be corrupted if the process dies mid-write: write to report.tmp, then File.Move("report.tmp", "report.csv", overwrite: true). The OS rename is atomic on most filesystems.

CsvProcessor.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
using System;
using System.IO;
using System.Text;

class CsvProcessor
{
    record ProductRecord(string Name, string Category, decimal Price, int StockLevel);

    // Streams through a CSV file line by line — memory stays flat regardless of file size
    static System.Collections.Generic.IEnumerable<ProductRecord> ReadProductCsv(string csvFilePath)
    {
        // detectEncodingFromByteOrderMarks handles UTF-8 BOM from Excel exports automatically
        using StreamReader reader = new StreamReader(csvFilePath, detectEncodingFromByteOrderMarks: true);

        // Skip the header row
        string? headerLine = reader.ReadLine();
        if (headerLine == null) yield break;

        string? dataLine;
        int rowNumber = 1;

        while ((dataLine = reader.ReadLine()) != null)
        {
            rowNumber++;
            string[] columns = dataLine.Split(',');

            // Guard against malformed rows — real CSVs have bad data
            if (columns.Length != 4)
            {
                Console.WriteLine($"  Skipping malformed row {rowNumber}: '{dataLine}'");
                continue;
            }

            if (!decimal.TryParse(columns[2], out decimal price) ||
                !int.TryParse(columns[3], out int stock))
            {
                Console.WriteLine($"  Skipping row {rowNumber} — invalid numeric data");
                continue;
            }

            yield return new ProductRecord(columns[0].Trim(), columns[1].Trim(), price, stock);
        }
    }

    // Writes filtered results using temp-file-then-rename for atomicity
    static void WriteLowStockReport(string outputCsvPath, System.Collections.Generic.IEnumerable<ProductRecord> products)
    {
        string tempPath = outputCsvPath + ".tmp";

        // AutoFlush = false — buffers writes for performance, flushed on Dispose
        using (StreamWriter writer = new StreamWriter(tempPath, append: false, encoding: new UTF8Encoding(encoderShouldEmitUTF8Identifier: true)))
        {
            writer.AutoFlush = false;
            writer.WriteLine("ProductName,Category,Price,StockLevel,StockStatus");

            foreach (ProductRecord product in products)
            {
                if (product.StockLevel < 10)
                {
                    string status = product.StockLevel == 0 ? "OUT_OF_STOCK" : "LOW_STOCK";
                    writer.WriteLine($"{product.Name},{product.Category},{product.Price:F2},{product.StockLevel},{status}");
                }
            }
        } // buffer flushed and file closed here

        // Atomic rename — if process dies during write, original file is untouched
        File.Move(tempPath, outputCsvPath, overwrite: true);
    }

    static void Main()
    {
        string inputPath = "inventory.csv";
        string outputPath = "low_stock_report.csv";

        // Create sample inventory CSV for demonstration
        File.WriteAllText(inputPath,
            "Name,Category,Price,Stock\n" +
            "Wireless Keyboard,Peripherals,49.99,23\n" +
            "USB-C Hub,Peripherals,34.95,3\n" +
            "Webcam HD,Video,89.00,0\n" +
            "Monitor Stand,Accessories,29.50,INVALID\n" +  // bad row — intentional
            "Laptop Stand,Accessories,44.99,7\n" +
            "HDMI Cable,Cables,12.99,145\n");

        Console.WriteLine("Processing inventory CSV...");
        var allProducts = ReadProductCsv(inputPath);
        WriteLowStockReport(outputPath, allProducts);

        Console.WriteLine("\n--- Low Stock Report ---");
        Console.WriteLine(File.ReadAllText(outputPath));
    }
}
Output
Processing inventory CSV...
Skipping row 5 — invalid numeric data
--- Low Stock Report ---
ProductName,Category,Price,StockLevel,StockStatus
USB-C Hub,Peripherals,34.95,3,LOW_STOCK
Webcam HD,Video,89.00,0,OUT_OF_STOCK
Laptop Stand,Accessories,44.99,7,LOW_STOCK
Pro Tip:
The temp-file-then-rename pattern costs almost nothing extra and protects you from corrupted output files during power failures or process crashes. File.Move with overwrite: true (available from .NET 3.0) makes it a one-liner. Use it for any file that another system depends on.
Production Insight
A CSV import system using File.ReadAllLines on user uploads crashed with OutOfMemoryException on a 2GB file.
Replaced with StreamReader.ReadLine, memory dropped to <1MB.
Rule: never ReadAllLines a user upload. StreamReader.ReadLine keeps memory constant.
Key Takeaway
Never ReadAllLines a user upload.
StreamReader.ReadLine keeps memory constant.
Temp-file-rename makes atomicity a one-liner.

File Locking and Concurrent Access — Protecting Shared Resources

When multiple processes or threads try to access the same file, you need to think about concurrency. The default FileShare mode is FileShare.Read, which allows other processes to read the file while your stream is open for writing. But if two threads write to the same file simultaneously, you'll get data corruption or exceptions.

For a single process, use the lock statement to ensure only one thread writes at a time. For cross-process coordination, you'll need a named Mutex or a dedicated file-locking mechanism.

The FileStream constructor accepts a FileShare parameter that controls what other processes can do while your handle is open. Common combinations: - FileMode.Open, FileAccess.Read, FileShare.Read – multiple readers, no writers. - FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read – exclusive write, others can read. - FileMode.Open, FileAccess.ReadWrite, FileShare.None – exclusive access.

For high-concurrency logging, use a dedicated logging library (Serilog, NLog) that handles file locking internally. Writing your own lock-based file access is a recipe for deadlocks and performance issues.

If you must write to a shared file, use File.AppendAllText or File.AppendAllTextAsync – they open, append, and close in one atomic operation, minimising the window for contention.

ConcurrentFileAccess.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
using System;
using System.IO;
using System.Threading;

namespace io.thecodeforge.FileIO
{
    class ConcurrentFileAccess
    {
        private static readonly object _lock = new object();

        static void WriteToSharedLog(string logFilePath, string message)
        {
            lock (_lock)
            {
                File.AppendAllText(logFilePath, $"{DateTime.UtcNow}: {message}{Environment.NewLine}");
            }
        }

        static void Main()
        {
            string logPath = "shared.log";

            // Simulate concurrent writes from multiple threads
            Thread t1 = new Thread(() => {
                for (int i = 0; i < 5; i++)
                    WriteToSharedLog(logPath, $"Thread A - message {i}");
            });

            Thread t2 = new Thread(() => {
                for (int i = 0; i < 5; i++)
                    WriteToSharedLog(logPath, $"Thread B - message {i}");
            });

            t1.Start();
            t2.Start();
            t1.Join();
            t2.Join();

            Console.WriteLine("Shared log content:");
            Console.WriteLine(File.ReadAllText(logPath));
        }
    }
}
Output
Shared log content:
2026-04-22 10:00:00: Thread A - message 0
2026-04-22 10:00:00: Thread B - message 0
2026-04-22 10:00:00: Thread A - message 1
...
Don't Roll Your Own:
For cross-process file synchronization, lock won't work because it's per-process. Use a named Mutex or rely on a file-locking mechanism like FileStream with FileShare.None. But ideally, use a logging library or a message queue instead of sharing files.
Production Insight
Two background services writing to the same log file caused intermittent IOException because both opened FileStream without sharing.
Switching to a dedicated serilog sink that manages file locks eliminated the issue.
Rule: never let multiple processes write to the same file without a coordination mechanism.
Key Takeaway
File locks are per-process, not per-machine.
Cross-process coordination requires a Mutex or a single writer.
For high-concurrency logging, use a library, don't build your own.
Choosing the Right Concurrency Pattern
IfSingle process, multiple threads
UseUse lock statement around file writes. Avoid holding the lock for long operations.
IfMultiple processes need to write
UseUse a named Mutex or employ a dedicated logging library that handles file locking internally.
IfWrites are infrequent and small
UseUse File.AppendAllText – it opens, appends, and closes atomically, reducing contention window.
IfHigh-frequency writing from many sources
UseOffload writes to a background queue (Channel, Dataflow) or use a logging framework with async batching.

The FileStream Class — Where Most Memory Leaks Start

FileStream is the lowest-level managed wrapper around the Win32 CreateFile/ReadFile/WriteFile API. It gives you raw byte access to files. That sounds powerful. It is. It’s also the fastest way to leak handles and corrupt data if you don’t understand what you’re touching.

Every FileStream instance holds an operating system handle. If you forget to Dispose it, that handle stays open until the garbage collector runs finalizers. On a production server under load, GC might not run for minutes. During that window, any other process — including your own app trying to write the same file — gets SHARING_VIOLATION.

The constructor signature gives you control: FileMode, FileAccess, FileShare. The trap is FileShare.None. That locks the file exclusively. If you use FileShare.Read, concurrent reads work, but writes still block. Know your access pattern before you open the stream.

Always wrap FileStream in a using block. Always. There is no excuse. If you need to hold the stream open longer, implement IDisposable and marshal the lifetime explicitly. Your production API will thank you.

FileStreamLeak.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// io.thecodeforge — csharp tutorial
// NEVER do this — handle leak waiting to happen
FileStream leakyStream = new FileStream("/var/log/app.log", FileMode.Open);
byte[] buffer = new byte[1024];
leakyStream.Read(buffer, 0, buffer.Length);
// ... forgot to call Dispose

// CORRECT PATTERN
using (FileStream safeStream = new FileStream("/var/log/app.log", FileMode.Open, FileAccess.Read, FileShare.Read))
{
    byte[] data = new byte[safeStream.Length];
    safeStream.Read(data, 0, data.Length);
}
// handle released *immediately* after scope exit
Output
No output — runtime behavior
Without using: Other processes may get IOException: "The process cannot access the file because it is being used by another process."
With using: No contention. Handle released on scope exit.
Production Trap:
FileStream(bufferSize: 8192) is default for most constructors. For large files (>100MB), consider 64KB or larger buffer to reduce syscall overhead. But don’t go over 64KB — Windows kernel rounds up anyway.
Key Takeaway
Every FileStream must be wrapped in using. If it isn’t, you’ve created a time bomb for handle leaks and file locks.

Reading a Text File — Why Your Colleague's One-Liner Is Slow

You’ve seen it: File.ReadAllText(path). It’s convenient. It also loads the entire file into memory as one string. For config files under 100KB, fine. For production logs that hit 500MB, it’s a memory allocation that triggers GC pressure and a potential OutOfMemoryException.

ReadAllText and ReadAllLines are convenience wrappers over FileStream with StreamReader. They read everything, close the stream, and return. If you only need the first ten lines, you just wasted CPU and memory reading the rest.

The production pattern: use StreamReader and read line-by-line. That gives you incremental processing. Memory stays flat. If the file is small, fine — but make the habit explicit. When your log file grows to 2GB because someone forgot rotation, your one-liner won’t crash the process.

Same applies for writing. File.WriteAllText will buffer and flush in one shot. For writing 10MB of data, that’s a blocking call on the main thread. Use StreamWriter with auto-flush disabled and flush manually after logical batches.

Think about the lifecycle of your data before you type that one-liner.

ReadFileProduction.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — csharp tutorial
// Bad: loads entire file
string allText = File.ReadAllText("config.json");

// Good: stream lines one at a time
string targetLine = null;
using (var reader = new StreamReader("config.json"))
{
    string line;
    while ((line = reader.ReadLine()) != null)
    {
        if (line.Contains(""connectionString""))
        {
            targetLine = line;
            break; // stops reading early
        }
    }
}
Console.WriteLine(targetLine ?? "Not found");
Output
"connectionString": "Server=prod-db;Database=orders;UID=svc;PWD=***",
Senior Shortcut:
Use File.ReadLines() (not ReadAllLines) for lazy enumeration. It returns an IEnumerable<string> backed by a StreamReader. Perfect for large files where you only need to iterate once.
Key Takeaway
Convenience methods like ReadAllText are for throwaway scripts. In production, stream line-by-line to keep memory flat and avoid GC storms.

Directory Traversal — The Hidden Permission Nightmare

Listing files in a folder sounds trivial. Directory.GetFiles(path) does it in one line. Until your app runs as a service account that doesn’t have read permission on a subdirectory. Then you get UnauthorizedAccessException. The entire enumeration fails. You catch nothing.

Directory.GetFiles and Directory.EnumerateFiles stop at the first access denied error. EnumerateFiles is lazy, but still throws on iteration of that specific entry. If you’re crawling a deep directory tree, a single locked folder kills the whole operation.

The fix: manual recursion with try/catch per subdirectory. That means more code, but also more reliability. If you don’t need the tree, don’t recurse — flatten with SearchOption.TopDirectoryOnly.

Another trap: Path.Combine with user input. Always use Path.GetFullPath to prevent directory traversal attacks where someone passes "../../etc/shadow" as a path segment. Even if your app doesn’t run as root, they could overwrite files in parent directories.

Permission checks are I/O operations too. They cost. Don’t call Directory.Exists on every loop — cache results.

TraverseSafely.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// io.thecodeforge — csharp tutorial
static IEnumerable<string> SafeEnumerateFiles(string root, string pattern)
{
    var files = new List<string>();
    try
    {
        files.AddRange(Directory.EnumerateFiles(root, pattern, SearchOption.TopDirectoryOnly));
    }
    catch (UnauthorizedAccessException ex)
    {
        Console.WriteLine($"Skipping {root}: {ex.Message}");
        return files; // return whatever we got
    }

    foreach (var dir in Directory.EnumerateDirectories(root))
    {
        files.AddRange(SafeEnumerateFiles(dir, pattern));
    }
    return files;
}

// Usage
var logs = SafeEnumerateFiles("/var/log/app", "*.log");
Console.WriteLine($"Found {logs.Count()} log files.");
Output
Skipping /var/log/app/archived: Access to the path '/var/log/app/archived' is denied.
Found 12 log files.
Production Trap:
SearchOption.AllDirectories is a shortcut that breaks silently on permission errors. Never use it in production code that crawls user-controlled directories.
Key Takeaway
Directory enumeration must be recursive with per-directory exception handling. One UnauthorizedAccessException kills the entire operation if you don't.

Opening and Closing Files — Where Most Devs Forget the OS Matters

Opening a file in C# isn't just about calling File.OpenRead(). Every open call is a handshake with the Windows or Linux kernel — requesting a handle, setting access modes, and claiming a spot in the system's file table. Close it wrong and you leak handles, lock other processes out, or corrupt data.

The most common production mistake? Depending on the garbage collector to close your file. That's like expecting a janitor to lock your server room door. GC timing is unpredictable, and on a loaded system, your file stays locked long after the method exits. That's why using statements exist — they force deterministic release of the OS handle.

But using alone isn't enough when you're dealing with shared network drives or high-frequency logging. In those cases, explicit Flush() and Close() calls give you surgical control over when data hits the disk. Remember: the OS buffers writes. Your Write() call might return successfully while the data is still in RAM, waiting for a flush that never happens if your app crashes.

FileHandleExample.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// io.thecodeforge — csharp tutorial

using var fs = new FileStream(
    @"C:\Logs\app.log",
    FileMode.Append,
    FileAccess.Write,
    FileShare.Read
);

using var writer = new StreamWriter(fs);
writer.WriteLine("Request processed at " + DateTime.UtcNow);
writer.Flush();  // Force write to OS buffer, not disk

// When using 'using', Close() is called automatically.
// For explicit control, call fs.Flush(true) to flush to disk.
Output
(No console output — file appends a line)
Production Trap:
Never rely on File.Close() inside a finally block without null-checking the stream. If the constructor throws, you crash with NullReferenceException instead of handling the original I/O error.
Key Takeaway
Always pair open with close — use using for safety, add Flush(true) when you need to guarantee the byte is on the platter before the next line runs.

C# I/O Classes — The Hierarchy That Makes or Breaks Your Architecture

Most devs treat File, FileInfo, FileStream, and StreamReader like interchangeable hammers. They're not. Each class exists for a specific performance tradeoff and lifetime pattern. Mixing them up is how you end up with StreamReader holding a 500 MB file in memory because you used ReadToEnd() instead of a buffered loop.

File is a static utility class — fine for one-off reads on small files. FileInfo gives you instance-based metadata caching, critical when you check Exists or Length repeatedly in the same scope. Underneath both sits FileStream, the actual OS handle wrapper. Wrap it in StreamReader/StreamWriter for text, BinaryReader/BinaryWriter for raw bytes, GZipStream for compression.

The senior move? Know when to skip the wrappers. If you're writing raw binary data like protobuf or image files, go straight to FileStream. The text adapters add encoding overhead and a character buffer you don't need. Same logic applies to network streams — wrapper classes add latency that kills throughput on high-frequency trading or real-time dashboards.

ClassSelection.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — csharp tutorial

byte[] rawData = { 0x48, 0x65, 0x6C, 0x6C, 0x6F };

// Wrong: wrapping binary in text classes
using var badWriter = new StreamWriter(@"C:\Temp\data.bin");
badWriter.Write(rawData); // Writes "Hello" as string, not bytes

// Right: direct FileStream for binary
using var goodFile = new FileStream(
    @"C:\Temp\data.bin",
    FileMode.Create
);

goodFile.Write(rawData, 0, rawData.Length);
Console.WriteLine("Binary written without encoding overhead.");
Output
Binary written without encoding overhead.
Senior Shortcut:
Memorize this: File.ReadAllText() is for configs under 1 MB. For anything larger — logs, CSVs, images — use FileStream with an appropriate buffer size (typically 4096 or 8192 bytes).
Key Takeaway
Pick your I/O class by data type and size: static File for tiny jobs, FileInfo for repeated access, FileStream for everything else. Text wrappers are conveniences, not optimizations.

I/O and Security — Why Permissions Fail Silently in Production

Security checks in C# file I/O are not upfront. The ACL is evaluated at the OS kernel level during the actual read or write syscall, not when you construct a FileStream or call File.Exists(). This means you can successfully open a handle only to have the next operation throw an UnauthorizedAccessException. The root cause: .NET caches nothing about identity or rights; each I/O call re-evaluates against the current Windows identity or Linux user context. Impersonation, process elevation, or even a network share's credential mismatch can flip success to failure between statements. Always wrap individual I/O operations, not whole blocks, in structured exception handling. Use WindowsIdentity.RunImpersonated or the equivalent Linux set*id calls only at the boundary. Never assume that a preceding permission check (e.g. File.GetAccessControl) implies success — that creates a TOCTOU race. The practical rule: catch UnauthorizedAccessException separately from IOException and log the WindowsIdentity name at the point of failure. This single habit saves hours of debugging permission-related file locks.

SecureFileAccess.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// io.thecodeforge — csharp tutorial
using System;
using System.IO;
using System.Security.Principal;

public static class FileAccessGuard
{
    public static string ReadWithIdentityLog(string path)
    {
        try
        {
            return File.ReadAllText(path);
        }
        catch (UnauthorizedAccessException ex)
        {
            string identity = WindowsIdentity.GetCurrent().Name;
            throw new IOException(
                $"Failed under identity: {identity}. Path: {path}", ex);
        }
    }
}
Output
On permission failure, exception message will include: "Failed under identity: DOMAIN\user".
Production Trap:
File.Exists() returns false for paths you have no permission to read, misleading you into thinking the file is missing instead of access-denied.
Key Takeaway
Wrap each I/O call individually in try-catch, and log WindowsIdentity.GetCurrent().Name on UnauthorizedAccessException.

Isolated Storage — The Sandbox You Didn't Know You Needed

Isolated storage provides a per-user, per-assembly data silo that bypasses ACL nightmares in shared hosting or partial-trust environments. Instead of computing paths like C:\Users\{user}\AppData, you call IsolatedStorageFile.GetStore() with assembly evidence. The store isolates by user, domain, and assembly strong name — collisions are impossible without impersonation. Two use cases kill it: (1) temporary cache files that must survive app restarts but never leak to other users on a terminal server; (2) configuration data for ClickOnce or XBAP apps where unrestricted FileIOPermission is denied. The API is counterintuitive — you create streams via store.CreateFile(), not File.Create(). The biggest mistake: forgetting that isolated storage is subject to quota limits. The default quota is 9 MB for .NET Framework, and exceeding it throws InsufficientMemoryException. Always call store.IncreaseQuotaTo() with a required size before writing large files. In modern .NET, you should also verify that the assembly is not running under an impersonated token, or the user isolation breaks. Prefer this over manual path construction when safety from cross-user leaks is non-negotiable.

IsolatedCache.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — csharp tutorial
using System.IO.IsolatedStorage;
using System.IO;

public class CacheWriter
{
    public static void SaveSecret(string data)
    {
        using var store = IsolatedStorageFile.GetStore(
            IsolatedStorageScope.User | IsolatedStorageScope.Assembly,
            null, null);
        
        using var stream = new IsolatedStorageFileStream(
            "settings.dat", FileMode.Create, store);
        using var writer = new StreamWriter(stream);
        writer.Write(data);
    }
}
Output
File is created under a hashed path unique to the user + assembly. No direct path needed.
Production Trap:
Isolated storage quota defaults to 9 MB. Writing beyond it throws InsufficientMemoryException without warning — always call store.IncreaseQuotaTo() first.
Key Takeaway
Use isolated storage when you need user-specific data that must never leak across accounts, especially in partial-trust or shared hosting environments.
● Production incidentPOST-MORTEMseverity: high

The Locked Log File: How a Missing `using` Statement Brought Down a Production API

Symptom
API health check failed with IOException: 'The process cannot access the file because it is being used by another process.' After restart, issue returned after a few hours.
Assumption
Developers assumed that creating a StreamWriter and not explicitly closing it would still release resources eventually (due to finalization).
Root cause
The StreamWriter was not wrapped in a using block. The garbage collector eventually finalizes the object, but not before the file handle remains open for an indeterminate time, causing contention with health checks.
Fix
Wrap all StreamWriter/StreamReader in using statements (or await using), ensuring immediate release of the file handle.
Key lesson
  • Always wrap IDisposable file objects in using blocks.
  • Never rely on garbage collection to close file handles — it's non-deterministic.
  • Use File.AppendAllText for simple appends to avoid manual stream management.
Production debug guideCommon failure modes and quick actions to identify the root cause of file-related issues.4 entries
Symptom · 01
File not found at given path
Fix
Verify the directory exists. Use Directory.CreateDirectory(path) before writing. For reading, catch FileNotFoundException and provide a clear error.
Symptom · 02
IOException: file locked by another process
Fix
Use Process Explorer (Windows) or lsof (Linux) to identify the process holding the lock. Implement retry logic with exponential backoff.
Symptom · 03
UnauthorizedAccessException when accessing file
Fix
Check file permissions. Verify the application identity has read/write access. On IIS, check AppPool identity. On containers, ensure running user has permissions.
Symptom · 04
File content garbled with special characters
Fix
Specify encoding when reading. Use StreamReader with detectEncodingFromByteOrderMarks: true to handle UTF-8 BOM. Avoid platform default encoding.
★ Quick Debug Cheat Sheet – File I/O IssuesImmediate commands and fixes for common file I/O problems in production.
File remains locked after code exits
Immediate action
Restart the process or recycle the app pool
Commands
lsof /path/to/file (Linux) or handle.exe -a -FileAccess="filename" (Windows)
Get-Process | Where-Object { $_.Modules.FileName -match 'filename' } (PowerShell)
Fix now
Wrap all stream usage in using blocks immediately. If using async, use await using.
File content partially written after crash+
Immediate action
Restore from the last backup or temp file
Commands
cat file.tmp (check if temp file exists and has complete data)
Head -c 100 /path/to/outputfile (check last few bytes are complete)
Fix now
Implement temp-file-then-rename pattern. Write to .tmp first, then File.Move to final name atomically.
OutOfMemoryException reading a large file+
Immediate action
Kill the process and restart with less aggressive file loading
Commands
wc -l /path/to/file (count lines to estimate file size)
ls -lh /path/to/file (check file size)
Fix now
Replace File.ReadAllLines with StreamReader.ReadLine() in a while loop. Use async variants if concurrent.
File I/O API Comparison
ScenarioBest API ChoiceWhy
Reading a small config file (<1 MB)File.ReadAllText / ReadAllTextAsyncOne-liner, auto-closes, sufficient for small payloads
Reading a large log or CSV fileStreamReader.ReadLine / ReadLineAsyncConstant memory usage regardless of file size
Writing binary data (images, PDFs)FileStream with BinaryWriterByte-level control, no charset encoding overhead
Appending to an existing log fileFile.AppendAllText / AppendAllTextAsyncConcise, safe, handles open/close automatically
High-performance bulk writingStreamWriter with AutoFlush = falseBuffers writes, orders of magnitude faster than line-by-line flush
Reading all lines into a collectionFile.ReadAllLinesReturns string[] directly, clean for small files with line-level iteration
ASP.NET Core controller file readsAny *Async variant + awaitReleases thread pool threads during disk wait, scales under load
Writing a file that must not corruptWrite to .tmp, then File.MoveOS rename is atomic — original untouched if process dies mid-write
Concurrent writes from multiple threadsStreamWriter + lock or File.AppendAllTextPrevents data corruption; lock ensures serial access within a process

Key takeaways

1
The static File class, StreamReader/StreamWriter, and FileStream are three layers of abstraction
pick the layer that matches your file size and control requirements, not just the one you know.
2
Synchronous file reads block a thread for the entire disk-wait duration. In any concurrent application (web APIs, queues, background workers), always use the async variants and await them.
3
Directory.CreateDirectory is idempotent
calling it unconditionally before any file write eliminates an entire class of deployment errors where directories don't exist on first run.
4
The temp-file-then-rename pattern makes file writes atomic at zero meaningful cost. Write to 'file.tmp', then File.Move to 'file.csv'. The OS rename is atomic; your output file is never partially written.
5
Concurrent file access requires explicit coordination
use lock for in-process, named Mutex for cross-process, or rely on dedicated logging libraries. Never assume concurrent writes are safe.

Common mistakes to avoid

3 patterns
×

Not disposing StreamReader/StreamWriter

Symptom
File stays locked after your code finishes; other processes get IOException: 'file is being used by another process'
Fix
Always wrap stream objects in a 'using' block or 'await using' for async. The 'using' statement calls Dispose() even if an exception is thrown, which flushes the buffer and releases the OS file handle.
×

Using File.ReadAllLines on user-uploaded or unbounded files

Symptom
OutOfMemoryException under load, server memory spikes to GBs on large uploads, eventual process crash
Fix
Switch to StreamReader.ReadLine() in a while loop. You hold one line in memory at a time. If you need IEnumerable<string> semantics, wrap it in a generator method with yield return.
×

Building file paths with string concatenation

Symptom
Code works on Windows ('C:\reports\data.csv') but throws DirectoryNotFoundException on Linux because backslash is a literal character in Unix paths
Fix
Always use Path.Combine('baseDir', 'reports', 'data.csv'). It picks the correct separator for the OS automatically and handles trailing slashes correctly.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What's the difference between File.ReadAllText and StreamReader, and whe...
Q02SENIOR
If your ASP.NET Core endpoint reads a file synchronously and your app su...
Q03SENIOR
How would you safely write a file that's read by an external system, ens...
Q01 of 03SENIOR

What's the difference between File.ReadAllText and StreamReader, and when would you choose one over the other in a production application?

ANSWER
File.ReadAllText loads the entire file into a single string – fine for small files (<10 MB) where simplicity matters, but dangerous for large files because it consumes memory proportional to file size. StreamReader reads incrementally (line by line), keeping memory constant regardless of file size. Use StreamReader when file size is unbounded or user-controlled, when you need to process data as it streams (e.g., CSV parsing), or when you need to apply encoding detection (BOM). In production, the rule is: if you can't guarantee the file is small, use StreamReader.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between File.ReadAllText and StreamReader in C#?
02
How do I read a file asynchronously in C# without blocking the thread?
03
Why does my C# file code work on Windows but fail on Linux?
04
How can I prevent file locking issues in a multi-threaded C# application?
05
What is the temp-file-then-rename pattern and why should I use it?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.

Follow
Verified
production tested
May 24, 2026
last updated
1,554
articles · all by Naren
🔥

That's C# Basics. Mark it forged?

12 min read · try the examples if you haven't

Previous
Exception Handling in C#
8 / 11 · C# Basics
Next
Nullable Types in C#