How to Use stdin and stdout in Rust

Read user input with io::stdin().read_line() and display output with println! in Rust.

The input that never arrives

You're building a command-line tool. You print a prompt asking the user for a number, wait for input, and read the result. It works perfectly the first time. You wrap it in a loop to ask again, and suddenly the program skips the second prompt and crashes. Or you print a message, but the text doesn't appear on the screen until the program exits. The terminal feels broken. It isn't. Rust's I/O model is explicit about buffering, ownership, and error handling. Ignoring those details turns a simple prompt into a debugging session.

Streams, buffers, and the hose analogy

Standard input and standard output are streams of bytes. Think of stdin as a hose delivering water from the keyboard. Think of stdout as a hose carrying water to the screen. Rust doesn't assume you want to drink from the hose every millisecond. It gives you a bucket and lets you decide when to fill it.

The std::io module manages these hoses. stdin() hands you the handle to the input hose. stdout() hands you the handle to the output hose. The key difference from higher-level languages is that Rust makes the buffering visible. The screen isn't updated instantly. Data sits in a buffer until it's full or you force a flush. The keyboard input isn't available character-by-character; it arrives in chunks, usually line-by-line. Rust forces you to acknowledge these mechanics through the type system and error handling.

Reading a single line

The most common task is reading a line of text. std::io::stdin() returns a Stdin struct. You call read_line on it, passing a mutable string to fill.

use std::io;

fn main() {
    // Create a mutable String to hold the input.
    // read_line appends to the string, so it must be mutable.
    let mut buffer = String::new();

    // stdin() returns a Stdin handle.
    // read_line reads until a newline and appends to buffer.
    io::stdin()
        .read_line(&mut buffer)
        .expect("Failed to read line");

    // Print what we got.
    println!("You typed: {buffer}");
}

read_line blocks the program. The CPU waits until the user presses Enter. The function reads bytes, including the newline character \n, and appends them to the provided string. It returns a Result<usize, io::Error>. The usize is the number of bytes read. The Result forces you to handle errors, like a disconnected pipe or a permission issue. expect panics if there's an error, which is acceptable for a quick script but not for production code.

Don't forget the newline. read_line keeps the \n at the end. If you compare the input to a string, the comparison fails. If you parse the input as a number, the parser fails. You almost always need to trim the result.

What happens under the hood

When you call io::stdin(), Rust creates a Stdin object. This is a handle, not the data itself. Calling read_line on the handle acquires an internal lock, reads from the operating system's file descriptor, and releases the lock. The read operation blocks until data is available.

The function appends to the string. It does not replace the string. If you call read_line multiple times on the same string, the new input is added to the end of the old input. This is a common source of bugs in loops. You must clear the string between reads, or create a new string each time.

The return value is a Result. If the read succeeds, you get Ok(n) where n is the byte count. If the input stream ends, you get Ok(0). If an error occurs, you get Err(e). Rust doesn't hide these cases. You have to decide what to do with each one.

The realistic loop

A real tool usually loops, parses input, and handles errors gracefully. This example reads numbers until the user types "quit". It demonstrates trimming, parsing, and error handling.

use std::io;

fn main() {
    println!("Enter numbers. Type 'quit' to stop.");

    loop {
        // Create a fresh string for each iteration.
        // Reusing a string without clearing it causes data to accumulate.
        let mut input = String::new();

        // Read the line. Expect panics on error, which is fine for this demo.
        io::stdin().read_line(&mut input).expect("Read failed");

        // Trim removes the trailing newline and whitespace.
        // Without trim, "123\n" fails to parse as an integer.
        let text = input.trim();

        if text == "quit" {
            break;
        }

        // Parse the string into an i32.
        // parse returns a Result, so we handle the error case.
        match text.parse::<i32>() {
            Ok(n) => println!("Got number: {n}"),
            Err(_) => println!("'{text}' is not a valid number."),
        }
    }
}

The trim() call is essential. It strips the newline and any surrounding spaces. The parse::<i32>() call attempts to convert the string to an integer. It returns Ok(n) on success or Err(e) on failure. The match expression handles both cases without panicking.

Convention aside: trim() is the standard way to clean up read_line output. The community expects you to trim before parsing or comparing. Leaving the newline in place is considered a bug.

Pitfalls that trip up beginners

I/O in Rust has a few sharp edges. The compiler catches some, but others only show up at runtime.

The invisible newline

read_line includes the newline character in the result. If you write if input == "yes", the condition is false because the input is "yes\n". The compiler won't warn you. The program just behaves wrong. Always trim.

The buffering trap

stdout is buffered. When you print text, it goes into a buffer first. The buffer is flushed to the screen when it fills up, when the program exits, or when you call flush(). println! adds a newline and flushes automatically. write! does not.

If you use write! to print a prompt and then call read_line, the prompt might not appear. The user sees a blank screen and types blindly. The prompt appears only after the user presses Enter. You must flush the output before reading.

use std::io::{self, Write};

fn main() {
    let mut stdout = io::stdout();

    // write! does not flush. The prompt stays in the buffer.
    write!(stdout, "Name: ").expect("Write failed");

    // Flush forces the buffer to the screen.
    stdout.flush().expect("Flush failed");

    let mut name = String::new();
    io::stdin().read_line(&mut name).expect("Read failed");
    println!("Hello, {}", name.trim());
}

If you forget the flush, the prompt is delayed. Don't fight the buffer. Flush it.

EOF is silent

When the input stream ends, read_line returns Ok(0). It does not return an error. The string is not cleared. If you loop without checking the byte count, you process empty strings forever.

use std::io;

fn main() {
    let mut buffer = String::new();

    loop {
        // Clear the buffer before reading.
        buffer.clear();

        match io::stdin().read_line(&mut buffer) {
            Ok(0) => {
                // 0 bytes means EOF. Break the loop.
                break;
            }
            Ok(_) => {
                println!("Got: {}", buffer.trim());
            }
            Err(e) => {
                eprintln!("Error: {e}");
                break;
            }
        }
    }
}

Check the return value. EOF is silent. If you ignore it, your loop never ends.

Standard error

println! writes to standard output. Errors should go to standard error. Use eprintln! for error messages. Stderr is usually unbuffered, so error messages appear immediately even if stdout is buffered. This keeps error output separate from program output, which matters when piping data.

use std::io;

fn main() {
    let mut input = String::new();
    match io::stdin().read_line(&mut input) {
        Ok(_) => println!("Success: {}", input.trim()),
        Err(e) => eprintln!("Read error: {e}"),
    }
}

Send errors to stderr. It's the convention, and it prevents error messages from polluting data streams.

Performance and conventions

Calling stdin() repeatedly in a loop creates a new handle each time. The handle is cheap, but it involves locking overhead. For tight loops, use stdin().lock(). The lock returns a StdinLock that holds the lock for the duration of the borrow. This avoids repeated locking and unlocking.

use std::io::{self, BufRead};

fn main() {
    let stdin = io::stdin();
    // lock() returns a StdinLock that implements BufRead.
    // This is efficient for repeated reads.
    let handle = stdin.lock();

    // lines() returns an iterator over Result<String>.
    // It handles the loop and trimming for you.
    for line in handle.lines() {
        match line {
            Ok(l) => println!("Line: {l}"),
            Err(e) => eprintln!("Error: {e}"),
        }
    }
}

BufRead is a trait that provides buffered reading methods. stdin().lock() implements BufRead. The lines() method returns an iterator that yields Result<String> for each line. It handles the loop, the trimming, and the EOF check. This is the idiomatic way to process all input.

Convention aside: Use stdin().lock() in loops. The community calls this the "lock once" pattern. It's a small optimization that signals you understand I/O mechanics.

Decision matrix

Use read_line when you need to read a single line of text into a String and handle the result manually. Use lines() when you want to iterate over all input until EOF with automatic trimming and error handling. Use read when you need raw bytes or fixed-size chunks without line processing. Use write! when you need to output text without a newline. Use println! for simple output with automatic flushing. Use stdout().flush() when you print a prompt and need it visible before reading. Use eprintln! for error messages to keep them separate from program output. Use stdin().lock() when reading in a loop to avoid locking overhead.

Trust the Result. Panicking on I/O is a recipe for crashes. Handle the errors, check the byte count, and trim your strings.

Where to go next