How to use stdin and stdout

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

The prompt that never appears

You're building a command-line tool. You ask the user for a filename. They type it. Nothing happens. You stare at the blinking cursor. The program is waiting, but the prompt never appeared on the screen. You forgot to flush the output buffer. Or worse, you read the line, tried to parse it, and the compiler rejected your code because you passed an immutable string to a function that needs to mutate it. Standard input and output in Rust look simple on the surface. They hide traps that catch every beginner.

Pipes, bytes, and the standard library

Think of standard input and output as two pipes connected to your program. stdin is the pipe where data flows in, usually from the keyboard. stdout is the pipe where data flows out, usually to the terminal. Rust treats these as streams of bytes. You don't just "get a string." You read bytes, decode them, and handle errors. The standard library gives you std::io to manage these pipes. It forces you to think about what happens when the pipe breaks, when the user stops typing, or when the output buffer fills up.

Rust's std::io module defines traits like Read and Write. These traits abstract over different sources and destinations. A file, a network socket, and standard input all implement Read. This uniformity lets you write functions that work with any source of data. The types Stdin and Stdout are handles to the standard streams. They are singletons. Calling io::stdin() multiple times returns a handle to the same underlying stream.

The minimal read

Start with the simplest case: print a prompt, read a line, print the result. This pattern works for scripts and quick tools.

use std::io;

fn main() {
    // Print the prompt.
    // println! automatically flushes stdout, so the user sees this immediately.
    println!("Enter your name:");

    // Create a buffer to hold the input.
    // We need a mutable string because read_line appends to it.
    let mut input = String::new();

    // Read from stdin into the buffer.
    // read_line blocks until it finds a newline or EOF.
    io::stdin()
        .read_line(&mut input)
        .expect("Failed to read line");

    // Print the result.
    // Note: input still contains the newline character.
    println!("Hello, {input}");
}

Convention aside: Use expect in main for I/O errors. It provides a clear message if the stream fails. In library code, return the Result instead of panicking.

What happens under the hood

When you call io::stdin(), you get a Stdin handle. Calling read_line on this handle blocks execution until the user presses Enter. The method reads bytes from the OS, decodes them as UTF-8, and appends them to your string. If the string already has content, read_line adds to the end. It does not clear the string first.

The method returns a Result<usize, io::Error>. The usize tells you how many bytes were read. The Error variant handles cases like a broken pipe, invalid UTF-8, or the stream closing unexpectedly. If you ignore the Result, the compiler rejects your code. Rust forces you to acknowledge that I/O can fail.

If you pass &input instead of &mut input, the compiler stops you with E0596 (cannot borrow as mutable). read_line needs to modify the string, so it requires a mutable reference. This check prevents accidental data loss where you might expect the string to be replaced rather than appended to.

Don't assume the newline is gone. read_line keeps it. Trim the string before processing.

Real code: loops and buffering

Real tools rarely read just one line. They loop, process input, and handle EOF. Reading line-by-line without buffering causes performance issues. Every read call triggers a system call to the OS. System calls are expensive. Rust provides BufRead to batch reads.

The idiomatic way to get buffered reading is stdin().lock(). This returns a StdinLock, which implements BufRead. It holds a lock on the stdin stream, preventing other threads from interfering, and manages an internal buffer.

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

/// Reads commands from stdin until the user types 'q' or EOF.
fn main() {
    // Lock stdin to get a buffered reader.
    // This avoids repeated system calls and thread contention.
    let stdin = io::stdin();
    let reader = stdin.lock();

    // Lock stdout for manual control.
    // We need this to flush explicitly after write!.
    let stdout = io::stdout();
    let mut writer = stdout.lock();

    loop {
        // Write the prompt without a newline.
        write!(writer, "Command (q to quit): ").expect("Write failed");
        
        // Flush is mandatory here.
        // write! buffers output. Without flush, the prompt might not appear.
        writer.flush().expect("Flush failed");

        let mut line = String::new();
        match reader.read_line(&mut line) {
            Ok(0) => {
                // 0 bytes means EOF. User pressed Ctrl+D or input is exhausted.
                println!("\nGoodbye.");
                break;
            }
            Ok(_) => {
                // Trim whitespace and the trailing newline.
                let command = line.trim();
                
                if command == "q" {
                    break;
                }
                
                if command.is_empty() {
                    continue;
                }
                
                println!("You entered: {command}");
            }
            Err(e) => {
                // Handle read errors gracefully.
                eprintln!("Error reading line: {e}");
                break;
            }
        }
    }
}

Convention aside: Prefer stdin().lock() over wrapping stdin() in BufReader. StdinLock is already optimized for buffered reading. Adding BufReader creates a second buffer layer with no benefit.

Flush the buffer. The user is waiting to see the prompt before they type.

Pitfalls that break your CLI

The newline trap

read_line appends the newline character to the string. If you compare the input to a command string, the comparison fails. Always trim the input.

let input = "quit\n";
if input == "quit" {
    // This branch never executes.
}

// Fix: trim the input.
if input.trim() == "quit" {
    // This works.
}

The append trap

read_line appends to the string. If you reuse the same string in a loop without clearing it, the string grows indefinitely. Memory usage spikes, and parsing logic breaks.

let mut buffer = String::new();
loop {
    // buffer accumulates all lines forever.
    reader.read_line(&mut buffer).expect("Read failed");
}

// Fix: create a new string each iteration, or clear the buffer.
let mut buffer = String::new();
loop {
    buffer.clear();
    reader.read_line(&mut buffer).expect("Read failed");
}

The flush trap

write! does not flush. println! does. If you mix them, output appears out of order or not at all. This is the most common bug in interactive tools.

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

let stdout = io::stdout();
let mut handle = stdout.lock();

write!(handle, "Password: ").expect("Write failed");
// User sees nothing. They type blindly.
// The prompt appears only after the next flush or program exit.

// Fix: flush explicitly.
handle.flush().expect("Flush failed");

The Result trap

I/O operations return Result. Ignoring errors causes panics or silent failures. In main, expect is acceptable. In functions, return the error or handle it.

If you try to use a value after a move, the compiler rejects you with E0382 (use of moved value). This often happens when you extract a value from a Result and try to use the Result again.

Treat Result as a conversation. Handle the error, or the program dies.

Choosing your I/O strategy

Use io::stdin().read_line for simple scripts where you read one line and exit. Use BufRead::read_line via stdin().lock() when you're reading many lines in a loop; the buffer reduces system calls and speeds up I/O. Use write! with manual flush when you need precise control over output, like progress bars or interactive prompts without newlines. Use println! for standard logging and messages; it handles newlines and flushing for you. Use read_to_string when you need to consume the entire remaining stream at once, such as reading a file piped into your tool. Use stdin().bytes() when you're processing raw binary data or character-by-character input, skipping UTF-8 decoding overhead.

Where to go next