Skip to content

Rust File I/O

Reading and writing files is one of the first places where everything you’ve learned about Result and the ? operator stops being theoretical — almost every file operation can fail (the file might not exist, you might not have permission), so the standard library makes you deal with that directly.

The simplest way to read a file’s contents into a String:

use std::fs;
fn main() {
let contents = fs::read_to_string("hello.txt");
match contents {
Ok(text) => println!("{text}"),
Err(e) => println!("Couldn't read file: {e}"),
}
}

fs::read_to_string returns a Result<String, std::io::Error> — Ok with the text if it worked, Err with a reason if it didn’t (file missing, no permission, and so on).

Matching on every file operation gets repetitive fast. If you’re inside a function that itself returns a Result, the ? operator (from the Result page) cleans this up a lot:

use std::fs;
use std::io;
fn read_file(path: &str) -> Result<String, io::Error> {
let contents = fs::read_to_string(path)?; // returns early on failure
Ok(contents)
}
fn main() {
match read_file("hello.txt") {
Ok(text) => println!("{text}"),
Err(e) => println!("Error: {e}"),
}
}
use std::fs;
fn main() {
let result = fs::write("output.txt", "Hello, file!");
match result {
Ok(()) => println!("Wrote successfully"),
Err(e) => println!("Failed to write: {e}"),
}
}

fs::write creates the file if it doesn’t exist, and overwrites it completely if it does — keep that in mind if you need to add to a file instead of replacing it.

For adding to an existing file rather than overwriting it, use OpenOptions:

use std::fs::OpenOptions;
use std::io::Write;
fn main() -> std::io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
writeln!(file, "A new log line")?;
Ok(())
}

Notice main itself returns Result here — that’s a pattern worth knowing: when main returns a Result, you can use ? directly inside it instead of wrapping everything in a match.

For a large file, reading the whole thing into memory at once isn’t always ideal. Reading line by line instead:

use std::fs::File;
use std::io::{BufRead, BufReader};
fn main() -> std::io::Result<()> {
let file = File::open("hello.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
Ok(())
}

So far, every error has been handled the same way: print it. But often you want to react differently depending on what went wrong. A missing file is usually fine (maybe it’s the first run, and you just start empty). “Permission denied” is a real problem you should report.

An io::Error can tell you its category with .kind(), which returns an ErrorKind enum you can match on:

use std::fs;
use std::io::ErrorKind;
fn main() {
// a file in the system's temp folder that doesn't exist
let path = std::env::temp_dir().join("rusttutorial-missing-notes.txt");
match fs::read_to_string(&path) {
Ok(text) => println!("Loaded {} lines", text.lines().count()),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("No notes yet, starting fresh"),
ErrorKind::PermissionDenied => println!("Not allowed to read the notes file"),
_ => println!("Something else went wrong: {e}"),
},
}
}

Output:

No notes yet, starting fresh
▶ Try it Yourself

std::env::temp_dir() gives you the path of the system’s temporary folder (like /tmp on Linux), and .join(...) adds a file name to it. File functions accept a path like this just as happily as a &str.

Always keep a _ arm at the end: ErrorKind has many variants, and new ones get added over time. The _ arm catches everything you didn’t list.

Both exercises write their own file into the temporary folder first, so they run anywhere, including the Playground.

Write three to-do items to a file with fs::write, one per line. Then read the file back with fs::read_to_string and print each line with a number in front, followed by the total.

Hint: put \n between the items to make separate lines. text.lines() gives you the lines one at a time. Let main return std::io::Result<()> so you can use ?.

Show solution
use std::fs;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-todo.txt");
fs::write(&path, "learn ownership\nwrite a module\nread a file\n")?;
let text = fs::read_to_string(&path)?;
let mut count = 0;
for line in text.lines() {
count += 1;
println!("{count}. {line}");
}
println!("{count} items");
Ok(())
}

Output:

1. learn ownership
2. write a module
3. read a file
3 items
▶ Try it Yourself

Because fs::write replaces the whole file, you get the same three lines no matter how many times you run it. Each ? returns the error from main if writing or reading fails.

Start a log file fresh with a header line, then append two more lines to it with OpenOptions and writeln!. Finally, read the whole file back and print it.

Hint: use fs::write for the header (so each run starts clean), then OpenOptions::new().append(true).open(&path)? for the extra lines. print! (not println!) avoids an extra blank line at the end, because the file already ends with a newline.

Show solution
use std::fs::{self, OpenOptions};
use std::io::Write;
fn main() -> std::io::Result<()> {
let path = std::env::temp_dir().join("rusttutorial-log.txt");
fs::write(&path, "=== log ===\n")?; // start fresh
let mut file = OpenOptions::new().append(true).open(&path)?;
writeln!(file, "started")?;
writeln!(file, "finished")?;
print!("{}", fs::read_to_string(&path)?);
Ok(())
}

Output:

=== log ===
started
finished
▶ Try it Yourself

use std::fs::{self, OpenOptions}; brings in both the fs module itself and OpenOptions. Without the first fs::write, every run would keep adding to the old file, and the log would grow forever. That’s exactly what you want for a real log, but not for an exercise with a fixed output.

  • fs::read_to_string(path) reads a whole file into a String, returning a Result.
  • fs::write(path, contents) writes a file, overwriting it if it already exists.
  • OpenOptions::new().append(true) lets you add to a file instead of replacing it.
  • main() -> std::io::Result<()> lets you use ? directly in main, instead of matching every call.
  • e.kind() tells you which kind of error happened, like ErrorKind::NotFound, so you can handle each one differently.
  • Examples that use files in your project folder are best tried locally; std::env::temp_dir() gives you a folder that works anywhere.

Quick check

1. What happens if you call fs::write on a path that already exists?

2. How do you add to a file instead of overwriting it?

3. What does it enable when main itself returns std::io::Result<()>?

4. What does the ? operator do when fs::read_to_string(path)? fails?

5. Why read a large file line by line with BufReader instead of fs::read_to_string?

Score: 0 / 5