I/O #

I/O operations in Rust are organized in several complementary layers. std::io defines traits Read and Write as universal abstractions — code that works with files can work with sockets or buffers in memory without modification. std::fs provides high-level functionality for file and directory operations. And std::path handles cross-platform paths correctly. This article covers all of these layers — from reading stdin to efficient file operations with buffering, directory management, and a glimpse of async I/O with tokio.

Trait Read and Write — Core I/O Abstractions #

Before getting into concrete files, it’s important to understand two traits that are the foundation of the entire Rust I/O ecosystem:

flowchart LR
    R["trait Read\n.read(&mut [u8])\n.read_to_string()\n.read_to_end()\n.bytes()"]
    W["trait Write\n.write(&[u8])\n.write_all(&[u8])\n.flush()\n.write_fmt()"]

    R --> F1[File]
    R --> S1[TcpStream]
    R --> B1["&[u8] (slice)"]
    R --> C1[Cursor<Vec<u8>>]

    W --> F2[File]
    W --> S2[TcpStream]
    W --> B2[Vec<u8>]
    W --> C2[BufWriter<W>]

Functions that accept impl Read or impl Write work with all implementors — testability immediately increases because you can replace files with Vec<u8> when testing.


Stdin and Stdout #

Reads Input from User #

use std::io::{self, BufRead, Write};

fn main() {
    // Method 1: read_line — read one line including the newline
    print!("Masukkan nama kamu: ");
    io::stdout().flush().unwrap();  // must be flushed before reading input

    let mut nama = String::new();
    io::stdin().read_line(&mut nama).expect("Gagal membaca input");
    let nama = nama.trim();  // remove newline at the end
    println!("Halo, {}!", nama);

    // Method 2: read multiple lines until EOF (Ctrl+D/Ctrl+Z)
    println!("Masukkan teks (Ctrl+D untuk selesai):");
    let stdin = io::stdin();
    for baris in stdin.lock().lines() {
        let baris = baris.expect("Gagal membaca baris");
        println!("→ {}", baris.to_uppercase());
    }
}

Reading Input and Parsing #

use std::io;

fn baca_angka(prompt: &str) -> i32 {
    loop {
        print!("{}", prompt);
        io::stdout().flush().unwrap();

        let mut input = String::new();
        io::stdin().read_line(&mut input).unwrap();

        match input.trim().parse::<i32>() {
            Ok(n) => return n,
            Err(_) => println!("Bukan angka valid, coba lagi."),
        }
    }
}

fn main() {
    let a = baca_angka("Masukkan angka pertama: ");
    let b = baca_angka("Masukkan angka kedua: ");
    println!("{} + {} = {}", a, b, a + b);
}

Stderr for Error Message #

use std::io::{self, Write};

fn main() {
    // println! → stdout (can be redirected to a file)
    println!("Ini output normal");

    // eprintln! → stderr (for error messages and diagnostics)
    eprintln!("Ini pesan error");
    eprintln!("Error: file tidak ditemukan");

    // Can also write directly to stderr
    let stderr = io::stderr();
    writeln!(&mut stderr.lock(), "Error detail: {}", "koneksi timeout").unwrap();
}

File Operations #

Simple Read and Write #

use std::fs;
use std::io;

fn main() -> io::Result<()> {
    // Write file — create new or overwrite if existing
    fs::write("catatan.txt", "Baris pertama\nBaris kedua\nBaris ketiga\n")?;
    println!("File berhasil ditulis");

    // Read entire file as String
    let isi = fs::read_to_string("catatan.txt")?;
    println!("Isi file:\n{}", isi);

    // Read as bytes (for binary files)
    let bytes = fs::read("catatan.txt")?;
    println!("Ukuran: {} byte", bytes.len());

    // Delete files
    fs::remove_file("catatan.txt")?;
    println!("File dihapus");

    Ok(())
}

OpenOptions — Full Control over File Opening Mode #

The fs::write and fs::read_to_string functions are convenient shortcuts, but for full control use OpenOptions:

use std::fs::OpenOptions;
use std::io::{self, Write, Read};

fn main() -> io::Result<()> {
    // Create new file — fails if existing
    let mut file = OpenOptions::new()
        .write(true)
        .create_new(true)   // error if file already exists
        .open("baru.txt")?;
    writeln!(file, "Konten awal")?;

    // Append — add at the end without deleting old contents
    let mut file = OpenOptions::new()
        .append(true)
        .open("baru.txt")?;
    writeln!(file, "Baris tambahan")?;
    writeln!(file, "Baris lagi")?;

    // Read and write at the same time
    let mut file = OpenOptions::new()
        .read(true)
        .write(true)
        .open("baru.txt")?;
    let mut isi = String::new();
    file.read_to_string(&mut isi)?;
    println!("Isi: {}", isi);

    // Create or truncate (overwrite if present)
    let mut file = OpenOptions::new()
        .write(true)
        .create(true)       // make it if it doesn't exist yet
        .truncate(true)     // empty if existing
        .open("baru.txt")?;
    writeln!(file, "Isi baru")?;

    fs::remove_file("baru.txt")?;
    Ok(())
}
MethodEffects
.read(true)Allow read
.write(true)Allow write
.append(true)Write at the end of the file
.create(true)Create it if it doesn’t already exist
.create_new(true)Create, error if already exists
.truncate(true)Empty file when opened

BufReader and BufWriter — Efficient I/O #

Every read() or write() call to a file is a syscall to the OS — expensive. BufReader collects many bytes in a memory buffer and performs one large syscall, BufWriter holds data until the buffer is full or flushed. For large files, the performance difference can be dramatic.

use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Write};

fn salin_dan_transformasi(sumber: &str, tujuan: &str) -> io::Result<()> {
    let file_masuk = File::open(sumber)?;
    let file_keluar = File::create(tujuan)?;

    // BufReader: read with default 8KB buffer
    let reader = BufReader::new(file_masuk);
    // BufWriter: write with default 8KB buffer
    let mut writer = BufWriter::new(file_keluar);

    let mut nomor = 1;
    for baris in reader.lines() {
        let baris = baris?;
        // Transformation: increase line number and change to uppercase
        writeln!(writer, "{:4}: {}", nomor, baris.to_uppercase())?;
        nomor += 1;
    }

    // Mandatory flush BufWriter — data in the buffer is not necessarily written to disk
    writer.flush()?;
    println!("Berhasil menyalin {} baris", nomor - 1);
    Ok(())
}

fn main() -> io::Result<()> {
    // Create an example file
    fs::write("input.txt", "baris satu\nbaris dua\nbaris tiga\n")?;

    salin_dan_transformasi("input.txt", "output.txt")?;

    let hasil = fs::read_to_string("output.txt")?;
    println!("Hasil:\n{}", hasil);

    fs::remove_file("input.txt")?;
    fs::remove_file("output.txt")?;
    Ok(())
}

use std::fs;
Always call writer.flush() when finished writing via BufWriter. Data in the buffer can be lost if the program terminates or crashes before the buffer is flushed. Drops from BufWriter are automatic flushes, but drop errors are ignored — explicit flushes allow proper error handling.

BufReader with Custom Buffer Size #

use std::fs::File;
use std::io::BufReader;

fn main() {
    let file = File::open("data-besar.txt").unwrap();

    // 64KB buffer for large files
    let reader = BufReader::with_capacity(64 * 1024, file);

    // Small buffer for small files that are read frequently
    let file2 = File::open("config.txt").unwrap();
    let reader2 = BufReader::with_capacity(512, file2);
}

Directory Operations #

use std::fs;
use std::io;

fn main() -> io::Result<()> {
    // Create a directory
    fs::create_dir("direktori-baru")?;

    // Create a directory with a parent that may not yet exist
    fs::create_dir_all("a/b/c/d")?;

    // Create some files in it
    fs::write("direktori-baru/file1.txt", "konten 1")?;
    fs::write("direktori-baru/file2.rs", "fn main() {}")?;
    fs::write("direktori-baru/data.json", "{}")?;

    // Read directory contents
    println!("Isi direktori-baru:");
    for entri in fs::read_dir("direktori-baru")? {
        let entri = entri?;
        let path = entri.path();
        let meta = entri.metadata()?;

        println!(
            "  {} ({}, {} byte)",
            path.display(),
            if meta.is_dir() { "direktori" } else { "file" },
            meta.len()
        );
    }

    // Copy files
    fs::copy("direktori-baru/file1.txt", "direktori-baru/file1_backup.txt")?;

    // Rename / move
    fs::rename("direktori-baru/file2.rs", "direktori-baru/main.rs")?;

    // Delete files
    fs::remove_file("direktori-baru/data.json")?;

    // Delete empty directories
    // fs::remove_dir("new-directory")?;  // error if not empty

    // Delete the directory and its contents
    fs::remove_dir_all("direktori-baru")?;
    fs::remove_dir_all("a")?;

    println!("Selesai");
    Ok(())
}

Directory Recursion — Walk Directory Tree #

The standard library does not provide recursive directory walking. For that use the walkdir crate:

[dependencies]
walkdir = "2"
use walkdir::WalkDir;

fn main() {
    // Walk all files and directories recursively
    for entri in WalkDir::new(".").into_iter().filter_map(|e| e.ok()) {
        let path = entri.path();
        let kedalaman = entri.depth();
        let indent = " ".repeat(kedalaman * 2);

        if path.is_dir() {
            println!("{}{}/", indent, path.file_name().unwrap().to_string_lossy());
        } else {
            println!("{}{}", indent, path.file_name().unwrap().to_string_lossy());
        }
    }

    // Filter only files with a specific extension
    let file_rs: Vec<_> = WalkDir::new("src")
        .into_iter()
        .filter_map(|e| e.ok())
        .filter(|e| e.path().extension().map(|ext| ext == "rs").unwrap_or(false))
        .collect();

    println!("\nFile .rs ditemukan: {}", file_rs.len());
}

std::path — Cross-Platform Path Management #

Never concatenate paths with regular strings — use Path and PathBuf which handle separator differences (/ vs \) automatically:

use std::path::{Path, PathBuf};
use std::fs;

fn main() {
    // PathBuf: owned, can be modified
    let mut path = PathBuf::from("/home/budi");
    path.push("dokumen");
    path.push("laporan.pdf");
    println!("{}", path.display());  // /home/budi/document/report.pdf

    // Path: borrowed, cannot be modified (like &str vs String)
    let path_ref: &Path = Path::new("/etc/hosts");

    // Path component
    println!("Nama file: {:?}", path.file_name());      // "report.pdf"
    println!("Ekstensi: {:?}", path.extension());        // "pdf"
    println!("Stem: {:?}", path.file_stem());            // "report"
    println!("Parent: {:?}", path.parent());             // /home/budi/document

    // Combine paths with join
    let base = Path::new("/var/log");
    let log_file = base.join("aplikasi").join("error.log");
    println!("{}", log_file.display());  // /var/log/application/error.log

    // Check path status
    let p = Path::new("Cargo.toml");
    println!("Ada: {}", p.exists());
    println!("File: {}", p.is_file());
    println!("Direktori: {}", p.is_dir());

    // Absolute path from relative path
    if let Ok(absolut) = p.canonicalize() {
        println!("Absolut: {}", absolut.display());
    }
}

Write to Vec<u8> — In-Memory I/O #

Because Vec<u8> implements Write, you can use the same write API to save the output to memory instead of a file — very useful for testing:

use std::io::{self, Write, Cursor};

fn tulis_laporan(writer: &mut impl Write) -> io::Result<()> {
    writeln!(writer, "=== LAPORAN ===")?;
    writeln!(writer, "Total item: {}", 42)?;
    writeln!(writer, "Status: OK")?;
    Ok(())
}

fn main() -> io::Result<()> {
    // Write to file
    let mut file = std::fs::File::create("laporan.txt")?;
    tulis_laporan(&mut file)?;

    // Write to memory (useful for tests or buffers)
    let mut buffer: Vec<u8> = Vec::new();
    tulis_laporan(&mut buffer)?;
    println!("Output:\n{}", String::from_utf8_lossy(&buffer));

    // Cursor<Vec<u8>>: can Read AND Write
    let mut cursor = Cursor::new(Vec::new());
    writeln!(cursor, "Data yang bisa dibaca dan ditulis")?;
    cursor.set_position(0);  // rewind to beginning

    let mut hasil = String::new();
    use std::io::Read;
    cursor.read_to_string(&mut hasil)?;
    println!("Dari cursor: {}", hasil);

    std::fs::remove_file("laporan.txt")?;
    Ok(())
}

Async I/O with Tokio #

For applications that handle many concurrent connections or potentially slow I/O, async I/O prevents thread blocking:

[dependencies]
tokio = { version = "1", features = ["full"] }
use tokio::fs;
use tokio::io::{self, AsyncBufReadExt, AsyncWriteExt, BufReader};

#[tokio::main]
async fn main() -> io::Result<()> {
    // Write files async
    fs::write("async_test.txt", "baris satu\nbaris dua\nbaris tiga").await?;

    // Read file async
    let isi = fs::read_to_string("async_test.txt").await?;
    println!("Isi:\n{}", isi);

    // Read line by line async
    let file = fs::File::open("async_test.txt").await?;
    let reader = BufReader::new(file);
    let mut lines = reader.lines();

    while let Some(baris) = lines.next_line().await? {
        println!("Baris: {}", baris);
    }

    // Write async with append
    let mut file = fs::OpenOptions::new()
        .append(true)
        .open("async_test.txt")
        .await?;
    file.write_all(b"\nbaris keempat (async)").await?;

    // Async metadata
    let meta = fs::metadata("async_test.txt").await?;
    println!("Ukuran: {} byte", meta.len());

    fs::remove_file("async_test.txt").await?;
    Ok(())
}

Summary #

  • Traits Read and Write are core abstractions — code that accepts impl Read/impl Write works with file, socket, Vec<u8>, and all other types that implement those traits.
  • fs::read_to_string and fs::write for simple cases — convenient shortcut to read/write the entire file at once.
  • OpenOptions for full control — select any combination of read, write, append, create, create_new, truncate as needed.
  • Always use BufReader/BufWriter for large files — drastically reduces syscalls. Without buffering, each read_line() is a separate syscall.
  • Required flush() after BufWriter — data in the buffer is not necessarily written to disk. Drop automatically flushes but the error cannot be caught.
  • Use Path/PathBuf instead of strings for paths — handles platform separators automatically and provides ergonomic APIs for joins, extensions, parents, etc.
  • Vec<u8> implements Write — write to memory buffer using the exact same API as files. Very useful for testing and caching output.
  • For recursive directory walk, use crate walkdir — the standard library does not provide this.
  • Async I/O (tokio) for servers and high-concurrencytokio::fs provides an API identical to std::fs but non-blocking.


← Previous: Multi Threading   Next: Socket →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact