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 a Result, 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 explicit flush. (This is the one real weakness of relying on Drop for 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 wantUse
Accept any byte source / sink in a functionimpl Read / impl Write parameter
Read an entire file/stream into memoryread_to_string / read_to_end
Read line by lineBufReader::new(r).lines()
Many small reads/writes on a file or socketwrap in BufReader / BufWriter
Guarantee buffered data is writtenexplicit .flush()? before scope end
Write formatted output directlywrite! / writeln! macros
Store a writer chosen at runtimeBox<dyn Write>
Test I/O logic without real filesa &[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

  • Read and Write unify all byte I/O (files, sockets, stdin, &[u8], Vec<u8>) behind two traits; write functions over impl Read/impl Write so logic is decoupled from where bytes come from and testable with in-memory buffers.
  • Direct read/write on 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/BufWriter to 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 Write for pass-through parameters (monomorphized, zero cost) and Box<dyn Write> to store a destination chosen at runtime; write!/writeln! format directly into any writer.