Mert Özen Further With Every Line
Backend C# Fundamentals — #17

Files and the Console in C#: ReadLine, File, Path, Directory, using and Encoding

Mert Özen Nov 7, 2026 9 min 6 views
Files and the Console in C#: ReadLine, File, Path, Directory, using and Encoding

Why does the same text take 15 and 18 bytes? UTF-8 and the BOM, Console.ReadLine that can return null, Path.Combine versus Path.Join, one-line reads and writes with File and Directory, relative paths and the working directory, and the empty file of a StreamWriter that was never disposed.

Version note: The code in this post uses C# 10 and the .NET 6 APIs; it was built and run with the .NET 10 SDK.

Guess First

We write the same book title to two separate files. The only difference is that on the first line we pass the encoding explicitly as Encoding.UTF8. "Les Misérables" has 14 characters. How many bytes does each file take on disk?

using System.Text;

File.WriteAllText("title-a.txt", "Les Misérables", Encoding.UTF8);
File.WriteAllText("title-b.txt", "Les Misérables");

Console.WriteLine(new FileInfo("title-a.txt").Length);
Console.WriteLine(new FileInfo("title-b.txt").Length);

Same Text, Different Byte Counts

The output is 18 and 15. Neither is 14, and they are not equal to each other either. To see why, let's look at the file's bytes:

Console.WriteLine(BitConverter.ToString(File.ReadAllBytes("title-a.txt")));
// EF-BB-BF-4C-65-73-20-4D-69-73-C3-A9-72-61-62-6C-65-73
Console.WriteLine(File.ReadAllText("title-a.txt").Length);           // 14
Console.WriteLine(File.ReadAllText("title-b.txt", Encoding.Latin1)); // Les Misérables

Files hold bytes, not characters. An encoding is the rule that turns characters into bytes and bytes back into characters. In UTF-8, English letters take one byte each, while "é" takes two: C3-A9. That is why 14 characters became 15 bytes. The three extra bytes in the first file are the EF-BB-BF at the very beginning; this is called a BOM (byte order mark), a signature that says "this file is UTF-8".

The confusing part is this: File.WriteAllText writes UTF-8 even when you pass no encoding, but without a BOM. The Encoding.UTF8 property, on the other hand, returns a UTF-8 object that writes a BOM. Both are valid UTF-8, but some tools, especially scripts on the Unix side and some CSV readers, take the leading BOM for part of the text and corrupt the first field of the first line. Excel is the opposite: it often needs the BOM to open a CSV with non-ASCII characters correctly. Decide which one to write based on who will read the file; if you want UTF-8 without a BOM, use new UTF8Encoding(false).

The last line shows what reading with the wrong encoding does. Reading the two UTF-8 bytes as Latin-1 turned each byte into a separate character, and "é" came out as "é". In Turkish text you see the same bug as familiar garbage such as "ç" and "ı"; all of these are encoding mismatches, not data loss. Read with the right encoding and the text comes back.

Files from old Windows systems may still be written with code pages such as Windows-1254 (Turkish). .NET Framework recognized these on its own; on .NET Core and later you first have to register a provider:

Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Console.WriteLine(Encoding.GetEncoding(1254).EncodingName); // Turkish (Windows)

If you call GetEncoding(1254) without the first line, you get NotSupportedException: No data is available for encoding 1254.

Reading Input From the Console

Before moving on to files, let's look at the console itself. To read a line from the user, there is Console.ReadLine():

Console.Write("Your name: ");
string? name = Console.ReadLine();

if (string.IsNullOrWhiteSpace(name))
{
    Console.WriteLine("No name given.");
    return;
}

Console.Write("How many books? ");
string? input = Console.ReadLine();

if (int.TryParse(input, out int quantity) && quantity > 0)
{
    Console.WriteLine($"Thanks {name.Trim()}, {quantity} book(s) added to your cart.");
}
else
{
    Console.WriteLine($"'{input}' is not a valid quantity.");
}

Notice that the return type is string?. ReadLine returns null when there are no more lines to read. At the keyboard, that is the moment the input is ended with Ctrl+Z and Enter (Windows) or Ctrl+D (macOS, Linux). When input comes from a file or another program, it happens much more often. I ran the program with the input piped in. I removed the NETSDK1138 build warning that dotnet run prints at the very top every time; it says that net6.0 is out of support, and we will come back to it in the debugging post:

$ printf 'Ada\n3\n' | dotnet run
Your name: How many books? Thanks Ada, 3 book(s) added to your cart.

$ printf 'Ada\nthree\n' | dotnet run
Your name: How many books? 'three' is not a valid quantity.

$ printf 'Ada\n' | dotnet run
Your name: How many books? '' is not a valid quantity.

$ printf '' | dotnet run
Your name: No name given.

Because the input came through a pipe, what the user "typed" was not echoed to the screen, which is why the questions appear side by side. In the third run there was no line left for the second question, so input was null; interpolation wrote null as an empty string. The warnings we discussed in the nullable post pay off here: had we not checked for null before reaching name.Trim(), the compiler would have warned with CS8602. When converting input to a number, always use TryParse; you do not want the program to crash with an exception when the user types "three".

Building Paths With Path

It is tempting to build file paths by concatenating strings: "data\\" + fileName. That code works on Windows; on macOS and Linux, which most likely includes the server the application will run on, it produces a single file whose name contains a backslash. Build paths with the Path class:

Console.WriteLine(Path.Combine("data", "2026", "books.txt"));        // data/2026/books.txt
Console.WriteLine(Path.DirectorySeparatorChar);                     // /
Console.WriteLine(Path.GetFileName("data/2026/books.txt"));         // books.txt
Console.WriteLine(Path.GetFileNameWithoutExtension("data/2026/books.txt")); // books
Console.WriteLine(Path.GetExtension("data/2026/books.txt"));        // .txt
Console.WriteLine(Path.GetDirectoryName("data/2026/books.txt"));    // data/2026
Console.WriteLine(Path.ChangeExtension("data/2026/books.txt", ".bak")); // data/2026/books.bak

Console.WriteLine(Path.Combine("data", "/etc/hosts")); // /etc/hosts
Console.WriteLine(Path.Join("data", "/etc/hosts"));    // data/etc/hosts

I got these outputs on macOS; on Windows the separator is \. Path works only on strings and never touches the disk; you can ask for the extension of a file that does not exist.

The last two lines show an important difference. If one of the arguments starts with a root, Path.Combine drops everything before it and returns that path directly. If you combine a file name entered by a user with Combine, someone who types "/etc/hosts" can get out of your folder. Path.Join (available since .NET Core 3.0) does not do that; it only joins the pieces. Still, never trust file names that come from outside; take only the name part with Path.GetFileName.

File and Directory: Reading and Writing in One Line

For small files, the one-line methods of the File class are often enough. Let's write the book list to a folder and read it back:

string dataDir = Path.Combine("data", "2026");
Directory.CreateDirectory(dataDir);
Directory.CreateDirectory(dataDir); // no error if the folder already exists

string booksPath = Path.Combine(dataDir, "books.txt");
string[] books = { "978-0441172719;Dune", "978-0141439518;Emma" };
File.WriteAllLines(booksPath, books);
File.AppendAllText(booksPath, "978-0199535675;Ulysses" + Environment.NewLine);

Console.WriteLine(File.Exists(booksPath));             // True
Console.WriteLine(File.ReadAllText(booksPath).Length); // 63

string[] lines = File.ReadAllLines(booksPath);
Console.WriteLine($"{lines.Length} lines, first: {lines[0]}"); // 3 lines, first: 978-0441172719;Dune

foreach (string line in File.ReadLines(booksPath))
{
    string[] parts = line.Split(';');
    Console.WriteLine($"{parts[1],-8} {parts[0]}");
}
// Dune     978-0441172719
// Emma     978-0141439518
// Ulysses  978-0199535675

File.Copy(booksPath, Path.ChangeExtension(booksPath, ".bak"), overwrite: true);
foreach (string file in Directory.GetFiles(dataDir))
{
    Console.WriteLine(Path.GetFileName(file));
}
// books.txt
// books.bak

Directory.CreateDirectory also creates the intermediate folders and silently moves on if the folder already exists; calling it once before writing is enough. WriteAllText and WriteAllLines overwrite the file if it exists, and AppendAllText adds to the end.

The difference between ReadAllLines and ReadLines shows up with large files. ReadAllLines loads the whole file into memory and returns an array. ReadLines reads the lines one by one as you advance in the loop; on a 2 GB log file the first one runs out of memory, while the second works comfortably. The order returned by Directory.GetFiles is not guaranteed either; if you want a sorted list, sort it yourself.

If you try to read a file that does not exist, you get a FileNotFoundException (Could not find file '.../missing.txt'.); if you try to write to a folder that does not exist, you get a DirectoryNotFoundException (Could not find a part of the path '.../nofolder/a.txt'.). The rule from the exception handling post applies here too: asking File.Exists first is a good habit, but it is not enough. Another program can delete the file between the check and the read; protect real file operations with try/catch anyway.

Relative Paths and the Working Directory

In the examples above we used relative paths such as "data". A relative path is resolved against the working directory (current directory), not against the folder where the program lives. You can print the two side by side:

Console.WriteLine(Environment.CurrentDirectory);
Console.WriteLine(AppContext.BaseDirectory);

When I ran dotnet run in the project folder, the first line showed the project folder and the second the bin/Debug/net6.0/ folder. When I ran the same program from the parent folder with dotnet run --project, the first line switched to that parent folder, and the data folder was looked up there. IDEs also pick the working directory according to their own settings. Most "the file was found on my machine" bugs come from here.

Find files you ship with the program (sample data, templates) relative to AppContext.BaseDirectory. Resolve paths the user picks or passes on the command line against the working directory. For the application's own data, using the operating system's application data folder via Environment.GetFolderPath is better than writing into the folder where the program is installed.

A First Look at Streams: Why using Is a Must

Under methods like File.ReadAllText, streams are at work. A stream is a flow of bytes: you read or write it piece by piece, from a file, the network or memory. To write and read text, StreamWriter and StreamReader sit on top of it. Look at this experiment:

string path = "orders.log";

StreamWriter writer = new StreamWriter(path);
writer.WriteLine("1001;Dune;2");
writer.WriteLine("1002;Emma;1");
Console.WriteLine($"Before Dispose: {new FileInfo(path).Length} bytes"); // Before Dispose: 0 bytes
writer.Dispose();
Console.WriteLine($"After Dispose: {new FileInfo(path).Length} bytes");  // After Dispose: 24 bytes

using (StreamWriter appender = new StreamWriter(path, append: true))
{
    appender.WriteLine("1003;Ulysses;3");
}

using StreamReader reader = new StreamReader(path);
string? line;
int count = 0;
while ((line = reader.ReadLine()) != null)
{
    count++;
    Console.WriteLine($"{count}: {line}");
}
// 1: 1001;Dune;2
// 2: 1002;Emma;1
// 3: 1003;Ulysses;3

We wrote two lines, yet the file is still 0 bytes. For performance, StreamWriter first collects what you write in a buffer in memory and sends it to disk in bulk. Calling Dispose() flushes the buffer and closes the file. If you forget Dispose, the last things you wrote can be lost, and the file stays open until the program exits; on Windows, no other program can write to it or delete it in the meantime.

using guarantees this. When a using (...) { } block ends, Dispose is called even if an exception gets in the way. With the using StreamReader reader = ...; form that came with C# 8 there are no braces; the object is disposed when the scope it lives in ends. We will look at IDisposable and object lifetime in detail in the OOP series. For now the rule is simple: if you create a stream, reader or writer with new, put using in front of it.

All the methods in this post also have async counterparts: File.ReadAllTextAsync, WriteAllLinesAsync, ReadLineAsync. You use them when reading files in a web application; we will talk about why they matter in the Asynchronous Programming series.

Common Pitfalls

Assuming Console.ReadLine() always returns a string. It returns null when input ends. Do not turn off nullable warnings; they warn you about exactly this bug.

Joining paths by hand with "\\" or "/". Use Path.Combine or Path.Join. Clean a file name coming from the user with Path.GetFileName before you pass it to Combine.

Assuming a relative path finds the file next to the program. The working directory depends on how you started the program. For files that ship with the program, use AppContext.BaseDirectory.

Opening streams without using. The data stays in the buffer, and the file stays locked. Rule: wherever you see new StreamWriter, you should see a using.

Not thinking about encoding. Use UTF-8 for files you write and read yourself, and pass the same encoding on both sides. If you see characters like "ç" in a file from outside, try reading it with the right encoding before trying to fix the data.

Reading large files with ReadAllText or ReadAllLines. For files whose size you do not know, go line by line with File.ReadLines or a StreamReader.

A Small Experiment

★☆☆ Write a program that reads book titles from the user until an empty line is entered and writes them all to wishlist.txt with File.WriteAllLines. Do not forget that the input can be null.

★★☆ Read books.txt with File.ReadLines, split each line on ;, and print only the books whose title starts with "U". If the file does not exist, show the user a proper message.

★★★ Write the same CSV content once with new UTF8Encoding(false) and once with Encoding.UTF8, and compare the first three bytes of the two files with File.ReadAllBytes. Then read the file with the BOM using Encoding.Latin1 and note what you see at the start of the first line.

In this post we wrote all the code in a single Program.cs. As a program grows, that file becomes unmanageable. In the next post we organize code with namespaces, file-scoped namespaces and global using, gather several projects under one solution, and add a NuGet package to a project.