I/O
The std::io module admits the I/O surface: standard streams (stdin, stdout, stderr), file I/O via std::fs, networking via std::net. The principal traits are Read (for byte-stream reading) and Write (for byte-stream writing); the conventional buffered wrappers are BufReader<R> and BufWriter<W>. Errors are returned as io::Error; the conventional pattern is Result<T, io::Error> with ? propagation. The standard library does not include serialisation; serde plus a format crate (serde_json, bincode, serde_yaml, toml) is the de facto standard.
Standard streams
use std::io::{self, Read, Write, BufRead};
// Print to stdout:
println!("hello");
print!("no newline ");
// Print to stderr:
eprintln!("error message");
eprint!("warning: ");
// Read a line from stdin:
let mut line = String::new();
io::stdin().read_line(&mut line)?;
println!("got: {}", line.trim());
// Read all of stdin:
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
// Read stdin line by line (buffered):
let stdin = io::stdin();
for line in stdin.lock().lines() {
let line = line?;
println!("{}", line);
}
The lock() on stdin() returns a StdinLock admitting buffered reading; without it, each line read incurs locking overhead.
File I/O
The std::fs module admits file operations:
Reading
use std::fs;
use std::io::{self, BufRead, BufReader, Read};
// Whole file as String:
let contents: String = fs::read_to_string("file.txt")?;
// Whole file as bytes:
let bytes: Vec<u8> = fs::read("file.bin")?;
// Streaming read with buffer:
let file = fs::File::open("file.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
process(&line);
}
// Read into a buffer:
let mut file = fs::File::open("file.bin")?;
let mut buffer = [0u8; 1024];
let n = file.read(&mut buffer)?;
println!("read {} bytes", n);
The conventional pattern for line-based processing is BufReader::new(file).lines().
Writing
use std::fs::{self, File};
use std::io::{Write, BufWriter};
// Whole-file write:
fs::write("output.txt", "hello world")?;
fs::write("output.bin", &[0u8, 1, 2, 3])?;
// Streaming write:
let file = File::create("output.txt")?;
let mut writer = BufWriter::new(file);
writeln!(writer, "first line")?;
writeln!(writer, "second line")?;
writer.flush()?; // ensure everything is written
// Append:
let file = fs::OpenOptions::new()
.append(true)
.create(true)
.open("log.txt")?;
let mut writer = BufWriter::new(file);
writeln!(writer, "new entry")?;
The BufWriter is conventional for substantial output; without it, each write is a separate syscall.
Opening with options
use std::fs::OpenOptions;
let file = OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open("file.txt")?;
let file = OpenOptions::new()
.read(true)
.open("readonly.txt")?;
let file = OpenOptions::new()
.write(true)
.append(true)
.create(true)
.open("log.txt")?;
The form admits substantial control over file creation and access modes.
File metadata and directories
use std::fs;
let metadata = fs::metadata("file.txt")?;
println!("size: {}", metadata.len());
println!("is file: {}", metadata.is_file());
println!("is dir: {}", metadata.is_dir());
println!("modified: {:?}", metadata.modified()?);
println!("readonly: {}", metadata.permissions().readonly());
// Directory listing:
for entry in fs::read_dir(".")? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
println!("file: {}", path.display());
} else if path.is_dir() {
println!("dir: {}", path.display());
}
}
// Recursive operations:
fs::create_dir_all("a/b/c")?;
fs::remove_dir_all("a")?;
// File operations:
fs::copy("from.txt", "to.txt")?;
fs::rename("old.txt", "new.txt")?;
fs::remove_file("file.txt")?;
For recursive directory traversal, the third-party walkdir crate is conventional.
The Read and Write traits
The principal I/O traits:
pub trait Read {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
// Provided methods:
fn read_to_end(&mut self, buf: &mut Vec<u8>) -> io::Result<usize>;
fn read_to_string(&mut self, buf: &mut String) -> io::Result<usize>;
fn read_exact(&mut self, buf: &mut [u8]) -> io::Result<()>;
// ...
}
pub trait Write {
fn write(&mut self, buf: &[u8]) -> io::Result<usize>;
fn flush(&mut self) -> io::Result<()>;
// Provided methods:
fn write_all(&mut self, buf: &[u8]) -> io::Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments<'_>) -> io::Result<()>;
// ...
}
The traits admit a uniform API across files, network connections, byte slices, etc.:
fn process<R: Read>(reader: &mut R) -> io::Result<()> {
let mut buf = [0u8; 1024];
let n = reader.read(&mut buf)?;
println!("read {} bytes", n);
Ok(())
}
let mut file = File::open("file.txt")?;
process(&mut file)?;
let mut data = &b"hello"[..];
process(&mut data)?; // &[u8] implements Read
let mut stream = TcpStream::connect("example.com:80")?;
process(&mut stream)?;
The conventional discipline is to take generic R: Read or W: Write parameters for substantial flexibility.
Buffered I/O
Buffered wrappers reduce syscall overhead:
use std::io::{BufReader, BufWriter, BufRead};
// BufReader admits .lines() and .read_line:
let file = File::open("file.txt")?;
let reader = BufReader::new(file);
for line in reader.lines() {
println!("{}", line?);
}
// BufWriter accumulates writes:
let file = File::create("output.txt")?;
let mut writer = BufWriter::new(file);
for i in 0..1000 {
writeln!(writer, "{}", i)?;
}
writer.flush()?; // ensure buffered data written
The BufWriter automatically flushes when dropped, but explicit flush() is conventional when the program needs to handle write errors.
The conventional discipline is to use BufReader for line-based reading and BufWriter for substantial output.
In-memory I/O
The standard library admits I/O on byte slices and vectors:
use std::io::{Cursor, Read, Write};
// Read from a byte slice:
let data = b"hello world";
let mut cursor = Cursor::new(&data[..]);
let mut buffer = String::new();
cursor.read_to_string(&mut buffer)?;
// Write to a Vec<u8>:
let mut vec: Vec<u8> = Vec::new();
write!(vec, "value: {}", 42)?;
println!("{}", String::from_utf8(vec)?);
// Cursor admits seek operations:
use std::io::Seek;
let mut cursor = Cursor::new(b"hello world".to_vec());
cursor.seek(std::io::SeekFrom::Start(6))?;
let mut buf = [0u8; 5];
cursor.read_exact(&mut buf)?; // reads "world"
The Cursor admits using arrays/vectors as Read/Write/Seek implementations.
Networking
use std::net::{TcpStream, TcpListener};
use std::io::{Read, Write};
// TCP client:
let mut stream = TcpStream::connect("example.com:80")?;
stream.write_all(b"GET / HTTP/1.0\r\n\r\n")?;
let mut response = String::new();
stream.read_to_string(&mut response)?;
// TCP server:
let listener = TcpListener::bind("127.0.0.1:8080")?;
for stream in listener.incoming() {
let mut stream = stream?;
let mut buffer = [0u8; 1024];
let n = stream.read(&mut buffer)?;
stream.write_all(&buffer[..n])?;
}
For elaborate networking (HTTP, TLS, async), third-party crates are conventional: reqwest for HTTP clients, tokio for async networking.
std::io::Error
The conventional I/O error type:
use std::io::{self, ErrorKind};
let result = fs::File::open("nonexistent.txt");
match result {
Ok(file) => process(file),
Err(e) => match e.kind() {
ErrorKind::NotFound => println!("file not found"),
ErrorKind::PermissionDenied => println!("permission denied"),
_ => println!("other error: {}", e),
},
}
The ErrorKind enum admits structural error checking; the io::Error itself is structurally similar to a string but admits the error chain.
Path manipulation
use std::path::{Path, PathBuf};
let p = Path::new("/etc/hosts");
println!("{:?}", p.file_name()); // Some("hosts")
println!("{:?}", p.parent()); // Some("/etc")
println!("{:?}", p.extension()); // None
println!("{:?}", p.file_stem()); // Some("hosts")
println!("{}", p.is_absolute()); // true
// Building paths:
let mut buf = PathBuf::from("/etc");
buf.push("hosts");
buf.set_extension("bak"); // "/etc/hosts.bak"
// Cross-platform separator:
let p = Path::new("/usr").join("local").join("bin");
// On Unix: "/usr/local/bin"
// On Windows: "/usr\\local\\bin" — the join handles separators
The Path/PathBuf admit cross-platform path handling.
Format-string macros
The conventional output macros:
println!("{}", value); // println to stdout
print!("{}", value); // print without newline
eprintln!("{}", value); // println to stderr
eprint!("{}", value); // eprint without newline
let s = format!("{}", value); // format to String
// To any Write target:
use std::io::Write;
let mut writer = std::io::stdout();
write!(writer, "value: {}", 42)?;
writeln!(writer, " done")?;
The write!/writeln! macros work with any W: Write, admitting substantial reuse:
fn report<W: Write>(writer: &mut W, n: i32) -> io::Result<()> {
writeln!(writer, "result: {}", n)?;
writeln!(writer, "doubled: {}", n * 2)?;
Ok(())
}
let mut stdout = std::io::stdout();
report(&mut stdout, 5)?;
let mut buf: Vec<u8> = Vec::new();
report(&mut buf, 5)?; // writes to in-memory buffer
Serialisation with serde
The serde crate is the de facto standard:
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Debug)]
struct Person {
name: String,
age: u32,
}
let p = Person { name: "Alice".to_string(), age: 30 };
// JSON:
let json = serde_json::to_string(&p)?; // {"name":"Alice","age":30}
let p: Person = serde_json::from_str(&json)?;
// Pretty JSON:
let pretty = serde_json::to_string_pretty(&p)?;
// To a file:
let file = File::create("person.json")?;
serde_json::to_writer_pretty(file, &p)?;
// From a file:
let file = File::open("person.json")?;
let p: Person = serde_json::from_reader(file)?;
Other format crates compatible with serde:
serde_json— JSON.bincode— efficient binary.serde_yaml— YAML.toml— TOML.postcard— efficient binary, suitable for embedded.ciborium— CBOR.
The pattern is consistent across formats: derive Serialize and Deserialize; use the format crate for the actual encoding.
Common patterns
Read a file line by line
use std::io::{BufRead, BufReader};
use std::fs::File;
fn process_lines(path: &str) -> io::Result<()> {
let file = File::open(path)?;
let reader = BufReader::new(file);
for line in reader.lines() {
let line = line?;
println!("{}", line);
}
Ok(())
}
Write a file line by line
use std::io::{Write, BufWriter};
use std::fs::File;
fn write_log(path: &str, entries: &[&str]) -> io::Result<()> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
for entry in entries {
writeln!(writer, "{}", entry)?;
}
writer.flush()?;
Ok(())
}
Read JSON
use serde::Deserialize;
#[derive(Deserialize)]
struct Config {
host: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&contents)?;
Ok(config)
}
Append to a log file
use std::fs::OpenOptions;
use std::io::Write;
fn log(message: &str) -> io::Result<()> {
let mut file = OpenOptions::new()
.append(true)
.create(true)
.open("app.log")?;
writeln!(file, "{}", message)?;
Ok(())
}
Read process output
use std::process::Command;
fn run_command(cmd: &str, args: &[&str]) -> io::Result<String> {
let output = Command::new(cmd).args(args).output()?;
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
let result = run_command("git", &["status"])?;
Read from stdin
use std::io::{self, BufRead};
fn read_lines() -> Vec<String> {
let stdin = io::stdin();
stdin.lock().lines()
.filter_map(|l| l.ok())
.collect()
}
Copy files
use std::fs;
fs::copy("source.txt", "destination.txt")?;
For substantial files or progress reporting, manual copying:
use std::io::{Read, Write};
fn copy_with_progress<R, W>(reader: &mut R, writer: &mut W) -> io::Result<u64>
where R: Read, W: Write,
{
let mut buf = [0u8; 8192];
let mut total = 0u64;
loop {
let n = reader.read(&mut buf)?;
if n == 0 { break; }
writer.write_all(&buf[..n])?;
total += n as u64;
}
Ok(total)
}
Atomic file write
use std::fs;
use std::io::Write;
fn atomic_write(path: &str, contents: &[u8]) -> io::Result<()> {
let temp_path = format!("{}.tmp", path);
fs::write(&temp_path, contents)?;
fs::rename(&temp_path, path)?;
Ok(())
}
The pattern admits “either the new content is fully written or the old file remains” — the rename is atomic on most filesystems.
A note on async I/O
For high-concurrency I/O, the conventional choice is async with tokio:
use tokio::fs;
use tokio::io::AsyncReadExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let contents = fs::read_to_string("file.txt").await?;
println!("{}", contents);
Ok(())
}
Tokio provides async versions of the principal std::io and std::fs APIs; treated in Concurrency.
A note on the conventional discipline
The contemporary Rust I/O advice:
- Use
fs::read_to_stringandfs::writefor whole-file operations. - Use
BufReaderandBufWriterfor line-based and substantial I/O. - Use
?operator for error propagation. - Use
serdeplus a format crate for serialisation. - Use
PathandPathBuffor path manipulation. - Use generic
R: ReadandW: Writeparameters for flexibility. - Use tokio for async I/O.
- Lock standard streams (
stdin().lock()) for substantial reads. - Flush
BufWriterexplicitly before checking for write errors.
The combination — Read/Write traits, buffered wrappers, Cursor for in-memory I/O, serde for serialisation, async I/O via tokio — is the substance of Rust’s I/O surface. The conventional discipline admits substantial flexibility and substantial robustness.