A program that talks to the world
You build a command line tool. It asks the user for a file path, reads the contents, transforms the data, and prints a summary. Then you realize the same logic should work on a network stream, or on a string in memory for testing. Rewriting the parsing logic for every source is tedious. Rust solves this with std::io. The module starts with simple helpers for reading a line or writing to a file, but it quickly reveals a deeply unified design. Everything in Rust I/O flows through two traits: Read and Write. Once you understand how they chain together, you stop writing boilerplate and start building reusable pipelines.
The core idea: streams of bytes
Underneath every input and output operation in Rust is a stream. A stream is just a sequence of bytes you can pull from or push to. A file on disk is a stream. The keyboard is a stream. A TCP connection is a stream. Even a slice of bytes sitting in RAM is a stream. The standard library refuses to write separate APIs for each one. Instead, it defines Read for anything you can pull bytes from, and Write for anything you can push bytes to.
Think of it like a universal adapter for plumbing. You do not care if the water comes from a municipal pipe, a rain barrel, or a bottled water dispenser. You just need a hose that fits your nozzle. The payoff is immediate. A function that accepts a &mut dyn Read or a generic R: Read will work on a file, standard input, a network socket, or a &[u8] slice without changing a single line of code. The standard library is built on this abstraction. Most I/O utilities expect a stream, not a specific source.
Write your functions against the traits, not the concrete types. The rest of the ecosystem will follow.
Reading a line from stdin
The most common starting point is asking the user for input. You print a prompt, wait for a keystroke, and grab the text.
Here is the smallest working case: a prompt, a buffer, and a read call.
use std::io;
fn main() {
// Mutable String acts as the buffer. read_line appends to it,
// so it must be mutable and start empty.
let mut input = String::new();
println!("What's your name?");
// stdin() returns a handle to the standard input stream.
// read_line blocks until it finds a newline or EOF.
io::stdin()
.read_line(&mut input)
.expect("failed to read input");
// The buffer now holds the text plus the trailing newline.
// Trim whitespace to get a clean string for display.
let name = input.trim();
println!("Hello, {name}!");
}
What happens under the hood
When you call io::stdin(), Rust creates a handle that points to the operating system's standard input file descriptor. Calling read_line triggers a blocking system call. The OS waits until the user presses Enter, copies the bytes into your String, and returns control. The method appends rather than replaces. If you call it twice with the same buffer, you get both lines concatenated. The newline character stays in the string. Always trim before parsing or comparing.
Convention aside: the community prefers input.trim_end_matches('\n') over trim() when you specifically want to drop the line ending but preserve leading spaces. It signals intent clearly and avoids accidental whitespace stripping.
Treat the buffer as an append-only log until you explicitly clear it.
Writing to stdout and stderr
Plain println! goes to standard output. eprintln! goes to standard error. That covers ninety percent of daily needs. When you need lower level control, like writing raw bytes or interleaving output with reads, you use the Write trait directly.
Here is how you take control of the output stream and keep it fast.
use std::io::{self, Write};
fn main() -> io::Result<()> {
// stdout() returns a handle. We lock it once to avoid
// repeated mutex contention in a tight loop.
let stdout = io::stdout();
let mut handle = stdout.lock();
// write_all blocks until the entire slice is written or an
// error occurs. It handles partial writes automatically.
handle.write_all(b"Hello, ")?;
handle.write_all(b"world!\n")?;
Ok(())
}
Locking is the trick that makes formatted output fast. Every println! implicitly acquires the stdout lock, writes, and releases it. If you are printing thousands of lines, that lock contention adds up. Taking the lock once and reusing the handle removes the overhead. The write_all method is also crucial. The underlying write method can return early if the OS buffer fills up, leaving you with partial data. write_all loops until every byte is flushed or an error stops it.
Convention aside: always use write_all for raw byte slices. The standard library provides it exactly because partial writes are a silent source of data corruption.
Grab the lock once, write everything, and let the OS handle the rest.
Buffering: the speed difference is huge
Reading or writing a single byte at a time is painfully slow. Every call crosses the boundary into the operating system, which switches contexts and updates disk or network buffers. For files, you wrap them in BufReader or BufWriter. These wrappers allocate a chunk of memory, usually eight kilobytes, and batch the system calls.
Here is how buffering turns thousands of tiny reads into a handful of efficient ones.
use std::fs::File;
use std::io::{self, BufRead, BufReader};
fn main() -> io::Result<()> {
let file = File::open("data.txt")?;
// BufReader holds an 8 KiB buffer. It refills from the file
// only when the buffer empties, reducing syscalls dramatically.
let reader = BufReader::new(file);
// BufRead adds high level helpers like lines(), which yields
// Result<String> for each line without manual buffer management.
for line in reader.lines() {
let line = line?;
println!("got: {line}");
}
Ok(())
}
Notice the BufRead trait. Plain Read only gives you read, which fills a byte slice and returns a count. BufRead adds lines, read_line, read_until, and fill_buf. These methods are pleasant to use because they manage the underlying buffer for you. For writes, BufWriter works identically. It collects small writes into its internal buffer and flushes them to the file in one system call when the buffer fills or when you explicitly ask it to.
Convention aside: the community calls explicit flushing a safety net. BufWriter flushes on Drop, but Drop cannot return errors. If a flush fails during cleanup, the error vanishes. Call flush() explicitly when the data matters.
Never trust the drop checker to save your data. Flush on purpose.
The Read trait in practice
Writing functions against Read makes your code reusable across every I/O source. Imagine a routine that counts bytes in any stream. You do not care if it is a file, a network socket, or a test fixture. You just need something that implements Read.
Here is a generic byte counter that works on any stream.
use std::io::{self, Read};
// Generic over any type implementing Read. The mut parameter allows
// the function to call read() which requires mutable access.
fn count_bytes<R: Read>(mut source: R) -> io::Result<u64> {
let mut buf = [0u8; 4096];
let mut total = 0u64;
loop {
// read fills the buffer and returns the number of bytes.
// It returns 0 to signal end of stream, not an error.
let n = source.read(&mut buf)?;
if n == 0 {
return Ok(total);
}
total += n as u64;
}
}
You can call this with File::open(path)?, with io::stdin().lock(), or with b"some bytes" as &[u8] in tests. The same function handles all three. The read method is the foundation. It takes a mutable byte slice, fills as much as it can, and returns the count. A count of zero means the stream is exhausted. Any other count means you should call it again. This pull based design gives you complete control over memory allocation and pacing.
Convention aside: prefer std::io::copy when you just want to move data from one stream to another. It is optimized, handles buffering internally, and returns the total bytes transferred.
Write to the trait, and your code inherits the entire I/O ecosystem.
Error handling shape
Almost every I/O function returns io::Result<T>. This is just an alias for Result<T, io::Error>. The error type carries an io::ErrorKind enum plus an optional inner error from the operating system. You typically match on the kind() to decide whether to recover or bail out.
Here is how you handle a missing file without crashing the whole program.
use std::fs::File;
use std::io::{self, ErrorKind};
fn open_or_create(path: &str) -> io::Result<File> {
match File::open(path) {
Ok(f) => Ok(f),
// If the file simply does not exist, create a fresh one.
Err(e) if e.kind() == ErrorKind::NotFound => File::create(path),
// Any other error like permission denied propagates up.
Err(e) => Err(e),
}
}
The ? operator works seamlessly with io::Result and is the right choice for ninety percent of code. It unwraps the Ok value or returns the Err immediately. Reach for explicit matching only when you need to recover from a specific condition, like a missing file or a broken pipe. If you try to use a method that requires a trait you do not have, the compiler will reject it with E0277 (trait bound not satisfied). That is a compile time guard against mismatched stream types.
Convention aside: log the error kind before propagating. io::ErrorKind tells you if the OS ran out of space, hit a timeout, or lost a connection. It turns cryptic failures into actionable diagnostics.
Let ? do the heavy lifting. Match only when you have a recovery plan.
Common pitfalls
read_line keeps the trailing newline character. If you forget to trim it, parsing the string into a number with str::parse will fail silently or return an error. The buffer also appends rather than replaces. Calling read_line twice on the same String without clearing it merges the inputs.
Mixing print! without a newline and read_line can leave your prompt invisible. Standard output is line buffered when attached to a terminal, but block buffered when piped to a file or another program. The prompt sits in the buffer until a newline arrives or the buffer fills. Force a flush with io::stdout().flush()? if you need the prompt to appear immediately.
Forgetting to flush a BufWriter before the program exits can lose the last batch of writes. The Drop implementation flushes automatically, but it cannot return errors. If the disk is full or the filesystem is read only, the failure is swallowed. Explicit flushing gives you a chance to catch the error and handle it gracefully.
Reading a whole file into memory with std::fs::read or std::fs::read_to_string is convenient, but it loads everything at once. Do not loop with read_to_end unless you specifically need streaming behavior or are processing gigabytes of data. The standard library provides the right tool for each scale.
Check your buffer state before parsing. Flush on purpose. Scale your reading strategy to the data size.
When to reach for what
Use stdin().read_line() when you need a quick interactive prompt and do not mind the trailing newline. Use stdout.lock() when you are writing thousands of lines in a loop and want to avoid mutex overhead. Use BufReader and BufWriter when you are processing files or network streams and want to reduce system call overhead. Use fs::read and fs::write when the entire payload fits comfortably in memory and you want a single allocation. Use std::io::copy when you just need to transfer bytes from one stream to another without parsing. Use explicit flush() calls when data loss is unacceptable and you cannot rely on drop semantics.
Pick the abstraction that matches your data size and your performance needs. The rest is just plumbing.