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(())
}
| Method | Effects |
|---|---|
.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 callwriter.flush()when finished writing viaBufWriter. Data in the buffer can be lost if the program terminates or crashes before the buffer is flushed. Drops fromBufWriterare 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
ReadandWriteare core abstractions — code that acceptsimpl Read/impl Writeworks with file, socket,Vec<u8>, and all other types that implement those traits.fs::read_to_stringandfs::writefor simple cases — convenient shortcut to read/write the entire file at once.OpenOptionsfor full control — select any combination ofread,write,append,create,create_new,truncateas needed.- Always use
BufReader/BufWriterfor large files — drastically reduces syscalls. Without buffering, eachread_line()is a separate syscall.- Required
flush()afterBufWriter— data in the buffer is not necessarily written to disk. Drop automatically flushes but the error cannot be caught.- Use
Path/PathBufinstead of strings for paths — handles platform separators automatically and provides ergonomic APIs for joins, extensions, parents, etc.Vec<u8>implementsWrite— 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-concurrency —
tokio::fsprovides an API identical tostd::fsbut non-blocking.