The Read and Write Traits
Files, network sockets, stdin, an in-memory buffer, in most languages these are unrelated APIs. In Rust they're unified behind two traits: Read (pull bytes out of something) and Write (push bytes into something). Code written against impl Read works with a file, a TcpStream, or a &[u8] slice, untouched. This tutorial covers the two traits, the buffering wrappers that make them fast, and the flush gotcha that silently eats data, the practical core of I/O in Rust, building directly on the slices and generics from earlier tutorials.
Read and Write: One Abstraction for All I/O
std::io::Read has one required method, read(&mut self, buf: &mut [u8]) -> io::Result<usize>: fill part of a byte buffer, return how many bytes were read. Write mirrors it with write(&mut self, buf: &[u8]) -> io::Result<usize>. You rarely call those directly, the traits provide dozens of convenience methods on top (read_to_string, read_to_end, write_all), and the real payoff is writing functions generic over the trait so they accept any byte source or sink:
use std::io::{self, Read};
// works with a File, a TcpStream, stdin, or a &[u8] — anything that implements Read
fn count_bytes(mut source: impl Read) -> io::Result<usize> {
let mut buf = Vec::new();
source.read_to_end(&mut buf)?;
Ok(buf.len())
}
count_bytes(std::fs::File::open("data.bin")?)?;
count_bytes(&b"in-memory bytes"[..])?; // a &[u8] is itself a Read
This is the same generics-over-a-trait lesson from Trait Objects vs Generics, applied to I/O: impl Read as a parameter decouples your logic from where the bytes come from. A parser written against impl Read can be tested with an in-memory &[u8] and deployed against a socket, no change. (&[u8] implementing Read is exactly why tests don't need real files.)
Why Unbuffered I/O Is Slow
The trap that surprises everyone: calling read/write directly on a File or socket issues a system call every time. Reading a file byte-by-byte, or writing many small chunks, means thousands of syscalls, each one crossing into the kernel, which dominates the runtime.
use std::io::Write;
use std::fs::File;
// SLOW: each write_all is a separate syscall to the OS
let mut file = File::create("out.txt")?;
for line in lines {
file.write_all(line.as_bytes())?; // one syscall per line — thousands of them
}
The fix is a buffer: accumulate many small operations in memory and hit the OS once per large chunk. BufReader and BufWriter wrap any Read/Write and do exactly this, and wrapping is the entire change:
use std::io::{BufWriter, Write};
let mut file = BufWriter::new(File::create("out.txt")?);
for line in lines {
file.write_all(line.as_bytes())?; // buffered in memory; flushed in big chunks
}
file.flush()?; // push the last partial buffer to the OS
BufReader similarly batches reads and adds line-oriented methods (read_line, .lines()). The rule: wrap files and sockets in BufReader/BufWriter whenever you do many small reads or writes. In-memory sources (&[u8], Vec<u8>) are already fast, no syscalls, so buffering them adds nothing.
The Flush Trap
A BufWriter holds not-yet-written data in its in-memory buffer. That buffer is pushed to the underlying file/socket when it fills, when you explicitly flush(), or when the BufWriter is dropped. The danger is in that last one, drop-flushing ignores errors.
Gotcha: if you rely on
BufWriter's drop to flush, any error during that final write is silently discarded, drop can't return aResult, so a failed final flush (disk full, broken pipe) vanishes with no indication, leaving a truncated file that looks complete. Always call.flush()?explicitly before the writer goes out of scope, so a final-write failure surfaces as an error you can handle. The truncated-output-with-no-error bug almost always traces back to a missing explicitflush. (This is the one real weakness of relying onDropfor cleanup, from Drop, RAII, and Resource Cleanup, a fallible flush needs to be done explicitly, not left to the destructor.)
Static Dispatch vs dyn: Box<dyn Write>
impl Read/impl Write parameters are monomorphized (one specialized copy per concrete type). When you need to store a writer whose type is chosen at runtime, log to a file or stdout depending on config, use a trait object, Box<dyn Write>, the same static-vs-dynamic choice from the trait-objects tutorial:
use std::io::Write;
// the concrete type isn't known until runtime → trait object
let mut out: Box<dyn Write> = if log_to_file {
Box::new(BufWriter::new(File::create("app.log")?))
} else {
Box::new(io::stdout().lock())
};
writeln!(out, "started")?; // dispatched dynamically, works for either
Use impl Write for a parameter you pass through (zero cost, monomorphized); reach for Box<dyn Write> when the destination is selected at runtime and must be stored in one variable or struct field. The write!/writeln! macros work on any Write, formatting directly into it without an intermediate String, cheaper than building a string and then writing it.
Read/Write Cheat Sheet
| You want | Use |
|---|---|
| Accept any byte source / sink in a function | impl Read / impl Write parameter |
| Read an entire file/stream into memory | read_to_string / read_to_end |
| Read line by line | BufReader::new(r).lines() |
| Many small reads/writes on a file or socket | wrap in BufReader / BufWriter |
| Guarantee buffered data is written | explicit .flush()? before scope end |
| Write formatted output directly | write! / writeln! macros |
| Store a writer chosen at runtime | Box<dyn Write> |
| Test I/O logic without real files | a &[u8] (Read) or Vec<u8> (Write) |
The throughline: program against the traits, not concrete types, so your I/O code is testable with in-memory buffers and reusable across files, sockets, and stdio, and always buffer real OS handles and flush explicitly.
Key Takeaways
ReadandWriteunify all byte I/O (files, sockets, stdin,&[u8],Vec<u8>) behind two traits; write functions overimpl Read/impl Writeso logic is decoupled from where bytes come from and testable with in-memory buffers.- Direct
read/writeon a file or socket is one syscall per call, byte-by-byte or many-small-writes I/O is dominated by syscall overhead. - Wrap OS handles in
BufReader/BufWriterto batch many small operations into few large syscalls; in-memory sources are already fast and don't need it. - A
BufWriter's drop-flush silently discards errors, always call.flush()?explicitly before it goes out of scope, or a failed final write leaves a truncated file with no error. - Use
impl Writefor pass-through parameters (monomorphized, zero cost) andBox<dyn Write>to store a destination chosen at runtime;write!/writeln!format directly into any writer.