Intermediate

I/O Operations in C#

Learn console output and input, formatted printing, reading and writing files, and I/O error handling in C# with the .NET Base Class Library, all runnable with Docker

Input and output are how a program talks to the outside world — printing results to the terminal, reading what a user types, and saving data to files so it survives after the program exits. You already met Console.WriteLine in Hello World; this tutorial goes deeper into formatting, reading input, and moving data to and from disk.

C# handles I/O through the .NET Base Class Library (BCL), a rich standard library that ships with the runtime. The System.Console class covers the terminal, while the System.IO namespace provides File, StreamReader, and StreamWriter for files. As a multi-paradigm language, C# gives you both high-level convenience methods (File.ReadAllText reads an entire file in one call) and lower-level stream objects (StreamReader reads a huge file one line at a time without loading it all into memory).

Because C# is statically and strongly typed, input that arrives as text must be parsed into the type you want, and the compiler makes you handle the possibility that a value is missing (null) or that a file operation throws. This tutorial covers formatted console output, reading typed input from the keyboard, writing and appending files, reading files back, and handling the errors that I/O inevitably produces — each as a complete, runnable program.

Console Output and Formatting

Beyond simple printing, C# offers string interpolation ($"..."), composite formatting with numbered placeholders, and format specifiers that control decimals and alignment. There are also two output streams: Console.Out for normal results (stdout) and Console.Error for diagnostics (stderr), so errors can be redirected independently of your program’s actual output.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Console.Write adds no newline; Console.WriteLine ends the line
Console.Write("No newline here... ");
Console.WriteLine("but this ends the line.");

// Console.Error writes to the standard error stream (stderr)
Console.Error.WriteLine("Diagnostics go to stderr");

int count = 42;
double price = 19.99;
bool active = true;

// String interpolation ($) is the modern, readable way to build output
Console.WriteLine($"Count: {count}, Price: {price}, Active: {active}");

// Composite formatting uses numbered {0}, {1} placeholders
Console.WriteLine("Composite: {0} items at {1} each", count, price);

// Format specifiers control how values render: F2 = two decimal places
Console.WriteLine($"Two decimals: {price:F2}");
Console.WriteLine($"Three decimals: {Math.PI:F3}");

// Alignment: {value,width} right-aligns; a negative width left-aligns
Console.WriteLine($"|{"Left",-8}|{"Right",8}|");

String interpolation reads left to right and is the idiomatic choice for most output. The :F2 after a value is a format specifierF2 means fixed-point with two digits after the decimal — and ,8 reserves a field eight characters wide so columns line up.

Run it:

1
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 sh -c "cp Program.cs Program.cs.bak && dotnet new console -o . --force >/dev/null 2>&1 && cp Program.cs.bak Program.cs && rm Program.cs.bak && dotnet run"

Expected output (the stderr line appears interleaved in your terminal):

No newline here... but this ends the line.
Diagnostics go to stderr
Count: 42, Price: 19.99, Active: True
Composite: 42 items at 19.99 each
Two decimals: 19.99
Three decimals: 3.142
|Left    |   Right|

Reading Console Input

Console.ReadLine() reads one line from standard input and returns it as a string? — the ? means it can be null when input ends. Because everything arrives as text, you parse numeric input with int.Parse (which throws on bad input) or the safer int.TryParse, which returns false instead of throwing and hands back the parsed value through an out parameter.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// Console.ReadLine() returns a string? - it can be null at end of input
Console.Write("Enter your name: ");
string? name = Console.ReadLine();

// Input always arrives as text; parse it to get a number
Console.Write("Enter your age: ");
string? ageText = Console.ReadLine();
int age = int.Parse(ageText!);

// int.TryParse safely handles input that isn't a valid number
Console.Write("Enter a favorite number: ");
bool ok = int.TryParse(Console.ReadLine(), out int favorite);

Console.WriteLine($"Hello, {name}!");
Console.WriteLine($"Next year you will be {age + 1}.");
Console.WriteLine(ok
    ? $"Your favorite number squared is {favorite * favorite}."
    : "That wasn't a valid number.");

int.Parse throws a FormatException if the text isn’t a number, while int.TryParse returns a bool telling you whether it succeeded — prefer TryParse for anything a user types. The ! after ageText is the null-forgiving operator, telling the compiler you’re confident the value isn’t null here.

This example needs input on stdin, so pipe values in with printf and add the -i flag to docker run:

1
printf 'Alice\n30\n7\n' | docker run --rm -i -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 sh -c "cp Program.cs Program.cs.bak && dotnet new console -o . --force >/dev/null 2>&1 && cp Program.cs.bak Program.cs && rm Program.cs.bak && dotnet run"

Expected output (the prompts share a line because Console.Write adds no newline and piped input isn’t echoed):

Enter your name: Enter your age: Enter a favorite number: Hello, Alice!
Next year you will be 31.
Your favorite number squared is 49.

Writing to Files

The File class in System.IO provides one-call helpers for the common cases: File.WriteAllText creates or overwrites a file, File.AppendAllText adds to the end, and File.AppendAllLines writes each element of a collection as its own line. For incremental writing, a StreamWriter inside a using block writes piece by piece and automatically flushes and closes the file when the block ends.

Create a file named Program.cs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
string path = "output.txt";

// WriteAllText creates the file (or overwrites it) with the given text
File.WriteAllText(path, "First line\nSecond line\n");

// AppendAllText adds to the end without erasing what's there
File.AppendAllText(path, "Appended line\n");

// AppendAllLines writes each array element as its own line
string[] moreLines = { "Line from array A", "Line from array B" };
File.AppendAllLines(path, moreLines);

// A StreamWriter writes incrementally; the 'using' block flushes and
// closes the file automatically when it goes out of scope.
using (StreamWriter writer = new StreamWriter(path, append: true))
{
    writer.WriteLine("Written via StreamWriter");
    writer.WriteLine("Streams flush on dispose");
}

Console.WriteLine($"Finished writing to {path}");
Console.WriteLine($"The file now has {File.ReadAllLines(path).Length} lines.");

Use File.WriteAllText when you have all the content up front, and a StreamWriter when you build output gradually or want to avoid holding a large string in memory. The using statement is the key detail: it guarantees the file is closed even if an error occurs, so you never leak an open file handle.

Run it with the same Docker command shown above. Expected output:

Finished writing to output.txt
The file now has 7 lines.

Reading Files

Reading mirrors writing. File.ReadAllText slurps the whole file into one string, File.ReadAllLines returns an array of lines, and a StreamReader reads one line at a time — the memory-friendly choice for large files. The while ((line = reader.ReadLine()) != null) loop is the idiomatic streaming pattern, stopping when ReadLine returns null at end of file.

Create a file named Program.cs:

 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
string path = "sample.txt";

// Set up a file to read (see the writing example for these calls)
File.WriteAllText(path, "Alice,30\nBob,25\nCarol,41\n");

// Read the entire file into a single string
string all = File.ReadAllText(path);
Console.WriteLine("--- Whole file ---");
Console.Write(all);

// Read the file as an array of lines and process each one
string[] lines = File.ReadAllLines(path);
Console.WriteLine($"--- {lines.Length} lines ---");
foreach (string line in lines)
{
    string[] parts = line.Split(',');
    Console.WriteLine($"{parts[0]} is {parts[1]} years old");
}

// Stream a file line by line without loading it all into memory
Console.WriteLine("--- Streamed ---");
using (StreamReader reader = new StreamReader(path))
{
    string? current;
    int n = 1;
    while ((current = reader.ReadLine()) != null)
    {
        Console.WriteLine($"{n}: {current}");
        n++;
    }
}

string.Split(',') turns each comma-separated line into an array of fields — a tiny taste of parsing structured text. Choose ReadAllLines for small files where an array is convenient, and a StreamReader loop for files too large to comfortably hold in memory all at once.

Run it with the same Docker command. Expected output:

--- Whole file ---
Alice,30
Bob,25
Carol,41
--- 3 lines ---
Alice is 30 years old
Bob is 25 years old
Carol is 41 years old
--- Streamed ---
1: Alice,30
2: Bob,25
3: Carol,41

Handling I/O Errors

I/O fails for reasons outside your control: a file is missing, a disk is full, or you lack permission. C# reports these failures as exceptions, and idiomatic code wraps risky operations in try/catch. The System.IO exceptions form a hierarchy — FileNotFoundException derives from IOException — so you can catch the specific case first and fall back to the general one. A finally block runs whether or not an error occurred, making it the right place for cleanup.

Create a file named Program.cs:

 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
// Reading a file that doesn't exist throws FileNotFoundException
string missing = "does_not_exist.txt";
try
{
    string text = File.ReadAllText(missing);
    Console.WriteLine(text);
}
catch (FileNotFoundException)
{
    Console.WriteLine($"Could not read '{missing}' - it does not exist.");
}
catch (IOException ex)
{
    // IOException is the base class for most I/O failures
    Console.WriteLine($"I/O error: {ex.Message}");
}

// File.Exists lets you check before you leap
if (File.Exists(missing))
{
    Console.WriteLine("The file exists.");
}
else
{
    Console.WriteLine($"'{missing}' does not exist - skipping read.");
}

// A write-then-read round trip with guaranteed cleanup in finally
string temp = "temp_data.txt";
try
{
    File.WriteAllText(temp, "temporary content");
    Console.WriteLine($"Read back: {File.ReadAllText(temp)}");
}
finally
{
    if (File.Exists(temp))
    {
        File.Delete(temp);
        Console.WriteLine("Temporary file cleaned up.");
    }
}

Catch the most specific exception you can handle meaningfully — here FileNotFoundException gets a friendly message while other IOExceptions report their raw message. The finally block deletes the temporary file no matter what happens in the try, which is exactly how you avoid leaving stray files behind after an error.

Run it with the same Docker command. Expected output:

Could not read 'does_not_exist.txt' - it does not exist.
'does_not_exist.txt' does not exist - skipping read.
Read back: temporary content
Temporary file cleaned up.

Running with Docker

Each example above is a complete program. Save it as Program.cs and run it with the official .NET SDK image — no local install required. The command preserves your Program.cs, scaffolds a temporary project around it, compiles, and runs it.

1
2
3
4
5
# Pull the official .NET SDK image (one-time)
docker pull mcr.microsoft.com/dotnet/sdk:9.0

# Compile and run Program.cs
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:9.0 sh -c "cp Program.cs Program.cs.bak && dotnet new console -o . --force >/dev/null 2>&1 && cp Program.cs.bak Program.cs && rm Program.cs.bak && dotnet run"

The examples that create files (output.txt, sample.txt) write them into your mounted directory, so you can inspect them afterward. The System and System.IO namespaces are imported automatically by the SDK’s implicit usings, which is why Console, File, and StreamReader work without any using directives.

Key Concepts

  • The BCL handles I/OSystem.Console covers the terminal and System.IO covers files; both ship with the runtime and need no external packages.
  • Formatting is expressive — string interpolation ($"{value:F2}"), composite formatting ({0}), format specifiers (F2, F3), and alignment ({value,8}) shape how output looks.
  • stdout vs stderrConsole.WriteLine writes results to stdout while Console.Error.WriteLine writes diagnostics to stderr, so the two can be redirected separately.
  • Input is text; parse itConsole.ReadLine returns a string?, and you convert to numbers with int.Parse (throws on bad input) or the safer int.TryParse (returns a bool).
  • Convenience vs streamingFile.ReadAllText/WriteAllText handle a whole file in one call, while StreamReader/StreamWriter process it incrementally to keep memory use low on large files.
  • using releases resources — wrapping a stream in a using block flushes and closes the file automatically, even if an exception is thrown mid-operation.
  • I/O errors are exceptions — wrap risky calls in try/catch, catch specific types like FileNotFoundException before the general IOException, and use finally for cleanup that must always run.

Running Today

All examples can be run using Docker:

docker pull mcr.microsoft.com/dotnet/sdk:9.0
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining