File I/O

Reviewed & published by Brayan K

By the end of this lesson you'll be able to read and write text files in C#, stream large files line by line, manage folders and build cross-platform paths, and parse multi-line data into records — the skills every real app needs to save and load data.

Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

File.ReadAllText is like photocopying an entire document at once — fast and simple, but it all has to fit on the desk (in memory). A StreamReader is like reading a book one page at a time — slower to get going, but you can work through a 1,000-page novel without it ever overwhelming you. And a using block is the librarian who always returns the book to the shelf when you're done, so the next reader isn't locked out. Pick the photocopier for small files, the page-turner for huge ones.

A note on running these in the browser: the in-browser runner doesn't have a real, writable disk, so the File.* examples below are written for you to read and learn from (each has its expected output in comments). The interactive 🎯 YOUR TURN exercises use in-memory StringWriter/StringReader and string Split — the exact same logic, minus the disk — so they actually run. To run the real file code, install the .NET SDK locally.

📊 The File I/O Toolkit

TypeWhat it doesTypical use
FileOne-line read/write/append/exists helpersSmall files, quick jobs
StreamReaderReads a file line by line (or in chunks)Large files, low memory
StreamWriterWrites lines to a file as a streamLogs, large output, appends
PathBuilds & inspects paths as stringsCombine, get name/extension
DirectoryCreates / lists / checks foldersExists, CreateDirectory

All of these live in the System.IO namespace — forgetting using System.IO; at the top is the most common "the name File doesn't exist" error.

1. Reading & Writing Whole Files

The File class gives you one-line methods for the everyday jobs. WriteAllText creates the file (or overwrites it if it exists); AppendAllText adds to the end without erasing anything; ReadAllText loads the whole file into one string; and ReadAllLines gives you a string[] with one element per line. These are perfect for small files — read this worked example, then you'll do the same round-trip in memory.

using System;
using System.IO;          // 👈 File, StreamReader, Path, Directory all live here

class Program
{
    static void Main()
    {
        // A file path is just a string. "example.txt" is RELATIVE — it lands
        // wherever your program runs from (its working directory).
        string filePath = "example.txt";

        // WriteAllText CREATES the file (or OVERWRITES it if it already exists).
        // \n is a newline, so this writes three separate lines.
        File.WriteAllText(filePath, "Hello from C#!\nThis is line 2.\nLine 3 here.");
        Console.WriteLine("File written.");                    // File written.

        // ReadAllText loads the WHOLE file back into one string.
        string content = File.ReadAllText(filePath);
        Console.WriteLine(content);

        // ReadAllLines gives you a string[] — one element per line.
        string[] lines = File.ReadAllLines(filePath);
        Console.WriteLine($"Line count: {lines.Length}");      // Line count: 3
        Console.WriteLine($"First line: {lines[0]}");          // First line: Hello from C#!

        // AppendAllText ADDS to the end without erasing what's there.
        File.AppendAllText(filePath, "\nAppended line 4!");
        Console.WriteLine($"Now {File.ReadAllLines(filePath).Length} lines."); // Now 4 lines.
    }
}

// ✅ Expected output:
//    File written.
//    Hello from C#!
//    This is line 2.
//    Line 3 here.
//    Line count: 3
//    First line: Hello from C#!
//    Now 4 lines.

Your turn. A StringWriter/StringReader behaves just like a file but lives in memory, so this one runs right here. Fill in the two ___ blanks, then run it.

using System;
using System.IO;

class Program
{
    static void Main()
    {
        // 🎯 YOUR TURN — same idea as a file, but IN MEMORY so it runs here.
        // A StringWriter behaves like a file you can write lines to;
        // a StringReader behaves like a file you can read lines from.

        // 1) Write three lines into the StringWriter.
        var writer = new StringWriter();
        writer.WriteLine("apples");
        writer.WriteLine("bananas");
        writer.___("cherries");        // 👉 use the same method: WriteLine

        // 2) Pull everything back out as one big string.
        string text = writer.ToString();

        // 3) Read it back, line by line, with a StringReader.
        var reader = new StringReader(text);
        string line;
        int n = 1;
        while ((line = reader.ReadLine()) != null)
        {
            Console.WriteLine($"{n}: {line}");
            n___;                      // 👉 add 1 to n each loop:  n++
        }

        // ✅ Expected output:
        //    1: apples
        //    2: bananas
        //    3: cherries
    }
}

2. StreamReader, StreamWriter & using

When a file is large, you don't want the whole thing in memory at once. Streams let you process it a line (or chunk) at a time. The catch: a stream holds an open handle on the file, and you must close it — otherwise the file stays locked and your last writes may never be saved. The using statement does this for you: it closes and flushes the stream automatically at the end of the block, even if an exception is thrown. Treat using as mandatory for every stream.

using System;
using System.IO;

class Program
{
    static void Main()
    {
        string filePath = "data.txt";

        // 'using' opens the stream and GUARANTEES it is closed/flushed at the
        // end of the block — even if an error is thrown. Forgetting this is the
        // #1 file bug: the file stays locked and your data may not be saved.
        using (StreamWriter writer = new StreamWriter(filePath))
        {
            for (int i = 1; i <= 5; i++)
                writer.WriteLine($"Record #{i} = {i * 10}");
        } // 👈 writer is flushed and closed automatically right here

        // Read it back one line at a time — ideal for big files because it
        // never loads the whole thing into memory at once.
        using (StreamReader reader = new StreamReader(filePath))
        {
            string line;
            while ((line = reader.ReadLine()) != null)   // null means "end of file"
                Console.WriteLine(line);
        }
    }
}

// ✅ Expected output:
//    Record #1 = 10
//    Record #2 = 20
//    Record #3 = 30
//    Record #4 = 40
//    Record #5 = 50

3. Directories & Paths

Never glue paths together with + and a slash — Windows uses \ and Linux/Mac use /, and hard-coding one breaks the other. Path.Combine inserts the correct separator for you. Directory.Exists / CreateDirectory manage folders, and File.Exists lets you check before you read so you don't crash on a missing file. For literal Windows paths, a verbatim string @"..." means you don't have to escape every backslash.

using System;
using System.IO;

class Program
{
    static void Main()
    {
        // Build paths with Path.Combine — it inserts the correct separator
        // (\ on Windows, / on Linux/Mac), so your code works everywhere.
        string dir = "reports";
        string file = Path.Combine(dir, "march.txt");   // reports/march.txt (or reports\march.txt)
        Console.WriteLine($"Target: {file}");

        // Directory.Exists / CreateDirectory — make the folder only if missing.
        if (!Directory.Exists(dir))
            Directory.CreateDirectory(dir);

        File.WriteAllText(file, "Q1 totals");

        // ALWAYS check before reading — File.Exists returns true/false, never throws.
        if (File.Exists(file))
            Console.WriteLine($"Found it: {File.ReadAllText(file)}"); // Found it: Q1 totals

        // Handy Path helpers that work on the string alone (no disk access):
        Console.WriteLine(Path.GetFileName(file));      // march.txt
        Console.WriteLine(Path.GetExtension(file));     // .txt
        Console.WriteLine(Path.GetDirectoryName(file)); // reports

        // A VERBATIM string @"..." treats backslashes literally — perfect for
        // absolute Windows paths so you don't have to double every backslash.
        string winPath = @"C:\temp\data.txt";
        Console.WriteLine(winPath);                     // C:\temp\data.txt
    }
}

// ✅ Expected output:
//    Target: reports/march.txt
//    Found it: Q1 totals
//    march.txt
//    .txt
//    reports
//    C:\temp\data.txt

Reading a file usually ends with parsing its text into useful pieces. Here you'll take a multi-line, comma-separated string (exactly what ReadAllText would hand you) and split it into records. Fill in the two ___ blanks, then run it.

using System;

class Program
{
    static void Main()
    {
        // 🎯 YOUR TURN — parse multi-line text into records (no real file needed).
        // This is EXACTLY what you'd do after File.ReadAllText, just in memory.

        // One string holding three CSV-style rows separated by \n (newline).
        string csv = "Alice,30\nBob,25\nCarol,41";

        // 1) Split the whole block into individual lines on the newline '\n'.
        string[] rows = csv.Split('___');     // 👉 split on the newline char: '\n'

        foreach (string row in rows)
        {
            // 2) Split each row into its fields on the comma ','.
            string[] fields = row.Split('___'); // 👉 split on the comma char: ','

            string name = fields[0];
            string age  = fields[1];
            Console.WriteLine($"{name} is {age} years old");
        }

        // ✅ Expected output:
        //    Alice is 30 years old
        //    Bob is 25 years old
        //    Carol is 41 years old
    }
}

🔎 Deep Dive: File.* vs streams vs in-memory strings

The File helpers (ReadAllText, WriteAllText) are convenience wrappers: under the hood they open a stream, do the work, and close it for you. That's why they're great for small files — and why you reach for a raw StreamReader/StreamWriter when a file is too big to hold in memory or you want to write thousands of lines without rebuilding one giant string.

The third option — StringWriter / StringReader — uses the same stream API but the "file" is just an in-memory string. It's perfect for tests, for building text to return from a method, and (handily) for learning file logic in an environment with no disk.

// Same shape, three backings:
File.WriteAllText("f.txt", text);              // disk, one shot
using var sw = new StreamWriter("f.txt");      // disk, streamed
using var mw = new StringWriter();             // memory, streamed (no disk!)

For web apps, prefer the async versions — ReadAllTextAsync, WriteAllTextAsync — so a slow disk doesn't block other requests.

Putting It Together: a Tiny Note-Saver

This small program uses everything from the lesson at once — it makes a folder, builds a cross-platform path, writes a header, appends entries through a using stream, then reads it all back and counts the entries. You understand every line now. (It writes to disk, so run it locally.)

using System;
using System.IO;

class Program
{
    static void Main()
    {
        // === A tiny note-saver: write a log, append to it, then read it back ===
        string folder = "app-data";
        if (!Directory.Exists(folder))
            Directory.CreateDirectory(folder);

        string logPath = Path.Combine(folder, "activity.log");

        // Start fresh each run (overwrite), then append entries with a stream.
        File.WriteAllText(logPath, "=== Activity Log ===\n");
        using (StreamWriter log = new StreamWriter(logPath, append: true))
        {
            log.WriteLine("09:00 — App started");
            log.WriteLine("09:05 — User signed in");
            log.WriteLine("09:30 — Report exported");
        } // flushed & closed here

        // Read it all back and report how many entries were logged.
        if (File.Exists(logPath))
        {
            string[] lines = File.ReadAllLines(logPath);
            Console.WriteLine(File.ReadAllText(logPath));
            // Line 0 is the header, so the entry count is lines.Length - 1.
            Console.WriteLine($"Entries logged: {lines.Length - 1}"); // Entries logged: 3
        }
    }
}

// ✅ Expected output:
//    === Activity Log ===
//    09:00 — App started
//    09:05 — User signed in
//    09:30 — Report exported
//
//    Entries logged: 3

Note new StreamWriter(logPath, append: true) — passing append: true adds to the file instead of overwriting it. Leave it off (the default) and you start fresh each time.

Pro Tips

Common Errors (and the fix)

📋 Quick Reference

TaskCodeNotes
Write (overwrite)File.WriteAllText(p, text)Creates or replaces
AppendFile.AppendAllText(p, text)Keeps existing content
Read allFile.ReadAllText(p)Whole file → string
Read linesFile.ReadAllLines(p)→ string[]
Stream writeusing var w = new StreamWriter(p)Big files / logs
Stream readusing var r = new StreamReader(p)r.ReadLine() in a loop
In-memory writenew StringWriter()No disk; .ToString()
Build a pathPath.Combine("a", "b.txt")Cross-platform separator
Folder exists?Directory.Exists(d)true / false
File exists?File.Exists(p)Check before reading

Frequently Asked Questions

Q: When should I use File.ReadAllText versus a StreamReader?

Use ReadAllText/ReadAllLines for small files — it's the simplest code and the size doesn't matter. Switch to a StreamReader when the file is large or you don't know its size, so you process it a line at a time without loading it all into memory.

Q: What does the using statement actually do?

It guarantees the stream is closed and flushed when the block ends — even if an exception is thrown. Without it, the file can stay locked and your final writes may never reach disk. Always wrap streams in using.

Q: Why does my program say it can't find a file that's clearly there?

A relative path like "data.txt" is resolved from the program's working directory (often the build output folder), not your source folder. Print Directory.GetCurrentDirectory() to see where it's looking, or use an absolute path / Path.Combine from a known root.

Q: How do I add to a file without wiping what's already in it?

Use File.AppendAllText, or open a StreamWriter with append: true. Plain File.WriteAllText and new StreamWriter(path) overwrite the whole file.

Mini-Challenge: Total a CSV Column

No blanks this time — just a brief and an outline. Take the in-memory "product,price" CSV string, split it into rows and then fields, parse each price, and add them up. This is the exact pattern you'd run on the text from File.ReadAllText — and it runs right here, no disk needed. Check your output against the expected line.

using System;
using System.Collections.Generic;

class Program
{
    static void Main()
    {
        // 🎯 MINI-CHALLENGE: Total a column from a CSV string
        // The data below is "product,price" — one row per line.
        string csv = "Pen,2\nNotebook,5\nStapler,8";
        //
        // 1. Split csv on '\n' to get the rows.
        // 2. Loop the rows; for each, Split on ',' to get [name, price].
        // 3. Convert the price with int.Parse(...) and add it to a running total.
        //    (Optional: store each name in a List<string> as you go.)
        // 4. Print "Total: X".
        //
        // ✅ Expected output:
        //    Total: 15        (2 + 5 + 8)

        // your code here
    }
}

🎉 Lesson Complete

Practice quiz

What does File.WriteAllText do if the file already exists?

  • Appends to it
  • Throws an exception
  • Overwrites it
  • Does nothing

Answer: Overwrites it. WriteAllText creates the file or overwrites it entirely if it already exists. Use AppendAllText to add without erasing.

What does File.ReadAllLines return?

  • A string[] with one element per line
  • One big string
  • A single line
  • A Stream

Answer: A string[] with one element per line. ReadAllLines returns a string[] — one element per line — whereas ReadAllText returns the whole file as one string.

Which method adds to the end of a file without erasing existing content?

  • File.WriteAllText
  • File.ReadAllText
  • File.Create
  • File.AppendAllText

Answer: File.AppendAllText. AppendAllText adds to the end without overwriting; WriteAllText would replace the whole file.

When should you prefer a StreamReader over File.ReadAllText?

  • For tiny files
  • For large files, so you process a line at a time without loading it all into memory
  • Never — File.ReadAllText is always better
  • Only on Windows

Answer: For large files, so you process a line at a time without loading it all into memory. Streams read a file line by line, ideal for large or unknown-size files because they never load the whole thing into memory.

What does wrapping a stream in a `using` block guarantee?

  • The stream is closed and flushed at the end of the block, even if an exception is thrown
  • The file is encrypted
  • The data is compressed
  • The file is read twice

Answer: The stream is closed and flushed at the end of the block, even if an exception is thrown. using closes and flushes the stream automatically when the block ends, even on error — without it the file can stay locked and final writes may be lost.

What is the symptom of writing with a StreamWriter but never closing it?

  • The file is deleted
  • An immediate crash
  • Data is missing or truncated because the buffer was never flushed
  • The file becomes read-only

Answer: Data is missing or truncated because the buffer was never flushed. If the StreamWriter is never closed/flushed, buffered data never reaches disk — wrap it in using (or call Flush/Dispose).

Why use Path.Combine instead of gluing paths with + and a slash?

  • It's shorter to type
  • It inserts the correct separator for the OS, so code works cross-platform
  • It checks the file exists
  • It compresses the path

Answer: It inserts the correct separator for the OS, so code works cross-platform. Path.Combine inserts the right separator (\ on Windows, / on Linux/Mac), so hard-coding one doesn't break the other OS.

Which method lets you check a file is present before reading, without throwing?

  • File.ReadAllText
  • File.Open
  • Path.GetFileName
  • File.Exists

Answer: File.Exists. File.Exists returns true/false (never throws), so you can guard a read and avoid FileNotFoundException.

What do StringWriter and StringReader give you?

  • Faster disk access
  • The same stream API but backed by an in-memory string (no disk)
  • Automatic encryption
  • A way to delete files

Answer: The same stream API but backed by an in-memory string (no disk). StringWriter/StringReader use the same stream API but the 'file' is just an in-memory string — great for tests and disk-free environments.

A relative path like "data.txt" is resolved from where?

  • The source code folder
  • The system root
  • The program's working directory
  • The user's home folder

Answer: The program's working directory. Relative paths resolve from the program's working directory (often the build output), not the source folder — a common 'file not found' cause.

Continue this course