How to read a file

Read a file in Rust using std::fs::read_to_string with error handling.

When the file isn't just data

You're building a tool that reads a configuration file. You write the code, run it, and it works perfectly. You send the binary to a colleague. They run it, and the program crashes with a panic. Or worse, it silently continues with empty data and corrupts a database three functions downstream. The difference between your machine and theirs is a missing file, a permission denied error, or a disk that filled up.

Rust forces you to make a decision about these failures before the program runs. You cannot write code that assumes the file read will succeed. The compiler blocks you until you explicitly handle the possibility of failure. This design eliminates silent crashes and ensures your error handling logic is complete.

The Result contract

File operations interact with the operating system. The OS controls the disk, the permissions, and the file system state. Rust treats these interactions as fallible. The function std::fs::read_to_string returns a Result<String, std::io::Error>.

A Result is an enumeration with two variants. Ok(T) wraps the success value. Err(E) wraps the error. The compiler sees Result as a distinct type from the inner value. You cannot assign a Result<String> to a variable of type String. You must pattern match on the result or use a combinator method to extract the value. This rule ensures you acknowledge the error case. Your code either handles the error, provides a fallback, or propagates the error to the caller. There is no path where the error vanishes.

Minimal example

The standard approach for a quick script is to read the file and provide a fallback if the read fails. The unwrap_or_else method handles this cleanly. It takes a closure that receives the error and returns a fallback value.

use std::fs;

/// Reads a text file and prints its contents.
/// Falls back to an empty string if the file cannot be read.
fn main() {
    // read_to_string returns Result<String, io::Error>.
    // unwrap_or_else runs the closure only on error.
    let contents = fs::read_to_string("config.txt")
        .unwrap_or_else(|error| {
            // Log the error to stderr and return a fallback String.
            eprintln!("Failed to read config: {error}");
            String::new()
        });

    // contents is now a String, safe to use.
    println!("Config length: {} bytes", contents.len());
}

Convention aside: Use eprintln! for error messages. It writes to standard error, which keeps error output separate from program output. This separation matters when you pipe your program's output to another tool. The error message won't pollute the data stream.

Also note the lazy evaluation of unwrap_or_else. The closure runs only if the result is an error. If you used unwrap_or(String::new()), the argument evaluates eagerly. If the fallback involved expensive computation, unwrap_or_else prevents that cost on the success path.

Under the hood

When you call read_to_string, Rust performs several steps. It resolves the file path. It asks the operating system to open the file. It queries the file size to allocate a buffer. It reads bytes from the disk into that buffer. It checks that every byte sequence is valid UTF-8. If any step fails, the function returns Err(io::Error) immediately. If all steps succeed, it returns Ok(String).

The unwrap_or_else method inspects the Result. On Ok, it extracts the string and returns it. On Err, it calls your closure, passing the error object. The closure must return a String to match the success type. This type constraint guarantees the fallback value is compatible with the rest of your code.

If you forget to handle the result and try to use the variable directly, the compiler rejects the code with E0308 (mismatched types). The variable holds a Result, not a String. You must unwrap or match. Trust the compiler here. It knows you haven't handled the error.

Realistic pattern

In library code or larger applications, swallowing errors with a fallback string is rarely the right choice. You want the caller to decide how to handle failure. The idiomatic approach is to return a Result from your function and use the ? operator to propagate errors.

use std::fs;
use std::io;

/// Loads the configuration content from a file path.
/// Returns an error if the file cannot be read.
fn load_config(path: &str) -> Result<String, io::Error> {
    // The ? operator propagates errors up to the caller.
    // If read_to_string returns Err, this function returns immediately.
    // If it returns Ok, the ? unwraps the String.
    fs::read_to_string(path)
}

fn main() {
    // main can return a Result.
    // If an error occurs, the runtime prints it and exits with code 1.
    match load_config("settings.json") {
        Ok(data) => println!("Loaded {} bytes", data.len()),
        Err(e) => eprintln!("Config error: {e}"),
    }
}

The ? operator is syntactic sugar for a match expression. It checks the result. On Ok, it returns the inner value. On Err, it returns the error from the current function. This turns nested error handling into a single line. The ? operator also performs automatic type conversion via From, allowing you to return a custom error type while using ? on standard library errors.

Convention aside: It is common for main to return Result<(), Box<dyn std::error::Error>> in CLI tools. This allows you to use ? throughout main without writing explicit match blocks. The runtime handles the final error printing. This pattern reduces boilerplate significantly.

Pitfalls and traps

Reading files introduces specific failure modes that differ from pure computation.

UTF-8 validation. read_to_string assumes the file contains valid UTF-8 text. If you read a binary file, an image, or a file encoded in Latin-1, the function returns an error. The compiler cannot check file encoding at compile time. The error surfaces at runtime. If you need to read binary data, use std::fs::read which returns Vec<u8>. This function skips UTF-8 validation and returns raw bytes.

Memory allocation. read_to_string allocates a String large enough to hold the entire file content. Reading a 4GB log file will allocate 4GB of RAM. For large files, this approach causes memory pressure or allocation failures. Use std::fs::File combined with std::io::BufReader to read the file in chunks or line by line. Streaming the data keeps memory usage constant regardless of file size.

Path flexibility. read_to_string accepts any type that implements AsRef<Path>. You can pass a &str, a String, a &Path, or a PathBuf. The compiler converts these types automatically. This flexibility simplifies calling code. You don't need to convert strings to paths manually.

Error types. The error type is std::io::Error. This error contains a kind (like NotFound or PermissionDenied) and an OS-specific error code. You can inspect the kind to decide how to handle the error. For example, you might retry on a temporary network error but abort on a permission denied error. If you try to print a Result directly without unwrapping, the compiler rejects the code with E0277 (trait bound not satisfied), because Result does not implement the Display trait. You must extract the value or the error first.

Don't load the ocean into a cup. Stream large files.

Decision matrix

Use std::fs::read_to_string when you need the entire file content as text and the file size is small enough to fit comfortably in memory. Use std::fs::read when you need raw bytes or the file might not be valid UTF-8. Use std::fs::File combined with std::io::BufReader when you are processing a large file line by line or in chunks to avoid allocating the full content at once. Reach for std::fs::metadata when you need to check file size or permissions before attempting to read.

Where to go next