Polyglot
Languages Rust io
Rust § io

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_string and fs::write for whole-file operations.
  • Use BufReader and BufWriter for line-based and substantial I/O.
  • Use ? operator for error propagation.
  • Use serde plus a format crate for serialisation.
  • Use Path and PathBuf for path manipulation.
  • Use generic R: Read and W: Write parameters for flexibility.
  • Use tokio for async I/O.
  • Lock standard streams (stdin().lock()) for substantial reads.
  • Flush BufWriter explicitly 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.