Intermediate

I/O Operations in Rust

Learn console input, formatted output, and file reading and writing in Rust with Result-based error handling and Docker-ready examples

Input and output is where Rust’s philosophy becomes impossible to ignore: I/O can fail, and Rust refuses to let you pretend otherwise. Where C returns error codes you can silently discard and Python raises exceptions you can forget to catch, Rust wraps every fallible I/O operation in a Result that you must explicitly handle—or explicitly propagate with the ? operator.

As a systems language, Rust gives you fine-grained control over the machinery: standard output is line-buffered, so print! without a newline may not appear until you flush; file reads can be buffered through BufReader to avoid a system call per line; and errors carry the underlying OS error code. At the same time, Rust’s functional heritage shows through in how I/O composes—iterating over lines of a file uses the same iterator machinery as iterating over a vector.

In this tutorial you’ll learn formatted output beyond the basic println! you saw in Hello World, how to read from standard input, and how to write, append to, and read files—including what to do when a file doesn’t exist. Along the way you’ll meet the ? operator, the idiomatic way to propagate I/O errors up the call stack.

Formatted Output

println! can do far more than print a static string. Rust’s formatting mini-language supports positional and named arguments, precision, padding, number bases, and a separate “debug” representation for compound types. There is also print! (no trailing newline) and eprintln! (writes to standard error instead of standard output).

Create a file named formatted_output.rs:

 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
use std::io::{self, Write};

fn main() {
    // print! omits the newline; println! adds one
    print!("Loading");
    print!("...");
    println!("done");

    // Positional arguments can be reused; named arguments read clearly
    println!("{0} and {1}; {1} and {0}", "input", "output");
    println!("{name} first appeared in {year}", name = "Rust", year = 2010);

    // Precision, width/alignment, and alternate bases
    let pi = 3.14159265;
    println!("pi to 2 places: {:.2}", pi);
    println!("padded: {:>8}", 42);
    println!("hex: {:x}, binary: {:b}", 255, 5);

    // {:?} uses the Debug trait - works on tuples, vectors, structs
    let point = (3, 7);
    println!("debug: {:?}", point);

    // eprintln! goes to stderr, keeping it separate from piped stdout
    eprintln!("this goes to standard error");

    // stdout is line-buffered: flush manually if print! must appear now
    io::stdout().flush().expect("failed to flush stdout");
}

The distinction between {} (the Display trait, for user-facing text) and {:?} (the Debug trait, for developer-facing inspection) runs throughout Rust: your own structs get {:?} for free with #[derive(Debug)], but must implement Display by hand.

Reading from Standard Input

Rust reads standard input through std::io::stdin(). The read_line method appends into a String you provide—including the trailing newline, which is why nearly every input line gets .trim() called on it. Parsing text into a number returns another Result, so bad input is caught explicitly rather than crashing mysteriously later.

Create a file named read_input.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use std::io;

fn main() {
    let mut name = String::new();
    println!("What is your name?");
    io::stdin()
        .read_line(&mut name)
        .expect("failed to read from stdin");
    let name = name.trim(); // read_line keeps the trailing newline

    println!("Hello, {}! Enter two numbers, one per line.", name);

    let mut sum: i64 = 0;
    for _ in 0..2 {
        let mut line = String::new();
        io::stdin()
            .read_line(&mut line)
            .expect("failed to read line");
        let n: i64 = line.trim().parse().expect("please enter a whole number");
        sum += n;
    }
    println!("Their sum is {}", sum);
}

Note the ownership at work: read_line borrows the String mutably (&mut name) and appends to it, avoiding an allocation per call if you reuse the buffer. The expect calls convert an error into a controlled crash with a message—fine for small programs; larger ones propagate errors instead, as the next example shows.

File I/O and Error Handling

For files, std::fs offers one-call conveniences (fs::write, fs::read_to_string) alongside lower-level handles (File, OpenOptions) for appending or streaming. This example writes a file, appends to it, reads it back two different ways, then deliberately tries to read a file that doesn’t exist.

Notice the return type of main: because it returns std::io::Result<()>, every fallible call can end with ?, which means “if this failed, return the error from main right now; otherwise unwrap the value.”

Create a file named file_io.rs:

 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
use std::fs::{self, File};
use std::io::{BufRead, BufReader, Write};

fn main() -> std::io::Result<()> {
    // Write an entire string to a file in one call (creates or truncates)
    fs::write(
        "journal.txt",
        "day 1: learned println!\nday 2: learned ownership\n",
    )?;

    // Append through an explicit handle opened with OpenOptions
    let mut file = fs::OpenOptions::new().append(true).open("journal.txt")?;
    writeln!(file, "day 3: learned the ? operator")?;

    // Read the whole file into a String at once
    let contents = fs::read_to_string("journal.txt")?;
    println!("--- journal.txt ({} bytes) ---", contents.len());

    // Read line by line with a buffered reader - each line is a Result
    let reader = BufReader::new(File::open("journal.txt")?);
    for (i, line) in reader.lines().enumerate() {
        println!("{}: {}", i + 1, line?);
    }

    // Handle a missing file gracefully instead of propagating with ?
    match fs::read_to_string("missing.txt") {
        Ok(_) => println!("missing.txt exists after all"),
        Err(e) => println!("could not read missing.txt: {}", e),
    }

    fs::remove_file("journal.txt")?;
    Ok(())
}

The final match shows the alternative to ?: pattern-matching on the Result lets the program recover from a missing file rather than exit. The error’s Display output includes the underlying OS error, so diagnostics come for free.

Running with Docker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Pull the official Rust image
docker pull rust:1.83

# Run the formatted output example
docker run --rm -v $(pwd):/app -w /app rust:1.83 sh -c 'rustc formatted_output.rs && ./formatted_output'

# Run the stdin example, piping in three lines of input
printf 'Ada\n2\n40\n' | docker run --rm -i -v $(pwd):/app -w /app rust:1.83 sh -c 'rustc read_input.rs && ./read_input'

# Run the file I/O example
docker run --rm -v $(pwd):/app -w /app rust:1.83 sh -c 'rustc file_io.rs && ./file_io'

The stdin example needs Docker’s -i flag so the piped input reaches the program; without it, read_line sees an immediate end-of-file.

Expected Output

Running formatted_output (the stderr line appears interleaved in a terminal):

Loading...done
input and output; output and input
Rust first appeared in 2010
pi to 2 places: 3.14
padded:       42
hex: ff, binary: 101
debug: (3, 7)
this goes to standard error

Running read_input with the piped input Ada, 2, 40:

What is your name?
Hello, Ada! Enter two numbers, one per line.
Their sum is 42

Running file_io:

--- journal.txt (79 bytes) ---
1: day 1: learned println!
2: day 2: learned ownership
3: day 3: learned the ? operator
could not read missing.txt: No such file or directory (os error 2)

Key Concepts

  • I/O failures are values, not exceptions — every fallible operation returns io::Result, and the compiler warns if you ignore one
  • The ? operator propagates errors — it unwraps an Ok or returns the Err from the current function, which is why main can be declared as fn main() -> std::io::Result<()>
  • read_line keeps the newline — call .trim() before comparing or parsing input, and remember it appends to the buffer you pass in
  • {} vs {:?}Display formatting is for users, Debug formatting is for developers; derive Debug on your own types to make them printable
  • stdout is line-bufferedprint! without a newline may not appear until you call io::stdout().flush()
  • eprintln! writes to stderr — keep diagnostics out of stdout so your program’s real output can be piped cleanly
  • Choose the right file APIfs::write/fs::read_to_string for one-shot convenience, File plus BufReader for streaming large files line by line, OpenOptions for appending
  • Recover with match, propagate with ? — pattern-match a Result when the program can continue (e.g., a missing optional file), use ? when the caller should decide

Running Today

All examples can be run using Docker:

docker pull rust:1.83
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining