Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

9.1 · Reading, Writing, and Creating Files

Domain 9 — Filesystem Operations Duration: ~15 minutes Library components: std::fs::File, std::fs::OpenOptions, std::fs::read_to_string, std::fs::read, std::fs::write

Introduction

Each file API in std::fs is a thin wrapper around the same OS primitive: open a file with a set of flags. Rust gives you that primitive at three levels of convenience. Most of the skill is to select the level that fits the task:

  • One-line functions: fs::read_to_string, fs::read, fs::write. One call opens the file, transfers the data, and closes the file.
  • Constructors: File::open, File::create, File::create_new. These are named short forms for the three most common flag sets.
  • The builder: OpenOptions. You write each flag explicitly, for all other cases.

This tutorial shows:

  • The three levels.
  • What you get because File implements Read + Write + Seek: random-access I/O with one handle.
  • How to set permissions at creation time.

Each example writes only in a unique scratch directory in std::env::temp_dir(), and removes that directory on exit. A Drop guard does the removal, so the cleanup occurs even if an assertion panics. You can use this RAII pattern in your own tests.

Figure: Three levels of file-opening convenience

The Convenience Layer: fs::write, fs::read_to_string, fs::read

When you need the whole file and do not need to keep it open, the free functions in std::fs are the idiomatic selection. Each function opens the file, transfers the data, and closes the file in one call. You do not manage a handle:

// config: PathBuf, the path of `app.conf` in the scratch directory.
fs::write(&config, "retries = 3\nverbose = true\n")?; // creates or truncates, then writes 27 bytes

let text = fs::read_to_string(&config)?;   // whole file → String (validates UTF-8)
let bytes = fs::read(&config)?;            // whole file → Vec<u8> (raw bytes, no validation)
assert_eq!(bytes, text.as_bytes());        // the two reads give the same 27 bytes

The difference between the two read functions is important. read_to_string requires valid UTF-8, and fails with ErrorKind::InvalidData if the content is not valid UTF-8. fs::read returns the bytes of the file as they are:

// blob: PathBuf, the path of `blob.bin` in the scratch directory.
fs::write(&blob, [0xFF, 0xFE, 0x00, 0x42])?;   // 4 bytes that are not valid UTF-8
let err = fs::read_to_string(&blob).expect_err("0xFF is never valid UTF-8");
assert_eq!(err.kind(), ErrorKind::InvalidData);
assert_eq!(fs::read(&blob)?.len(), 4);         // the raw read succeeds

These one-line functions keep the full content in memory. When a file is large, or when you need only the start of the file, use the streaming APIs (BufReader and Read::read in tutorial 8.1).

File::open, File::create, File::create_new

The three constructors express the three intents that you have 95% of the time:

ConstructorAccessIf the file is missingIf the file is present
File::openreadNotFoundopens the file
File::createwritecreates the filetruncates the file to 0 bytes
File::create_newwritecreates the fileAlreadyExists

Two behaviors are important. First, File::create truncates the file at open time. The old contents are gone before you write the first byte:

// `config` still holds the 27 bytes from the first snippet.
let mut file = File::create(&config)?;               // opens for write and truncates
assert_eq!(fs::metadata(&config)?.len(), 0);         // the file is empty before the first write
file.write_all(b"retries = 5\n")?;                   // writes 12 bytes

Second, File::create_new checks that the file does not exist and creates it as one atomic operation. A manual if !path.exists() { File::create(path) } has a time-of-check-to-time-of-use (TOCTOU) race. A different process can create the file between your check and your creation. With create_new, the OS does the two steps as one. Thus create_new is the tool to use for lock files and "first run" markers:

// `config` exists, so the exclusive creation fails. The file does not change.
let err = File::create_new(&config).expect_err("config already exists");
assert_eq!(err.kind(), ErrorKind::AlreadyExists);

09_01_file_open_create.rs prints:

read_to_string: 27 bytes
read:           27 bytes (same content, unvalidated)
read_to_string on binary blob: InvalidData
File::open on a missing file:  NotFound
File::create truncated the old config, then wrote 12 bytes
File::create_new on existing:  AlreadyExists
File::create_new created first-run.marker

All assertions passed.

The OpenOptions Builder

Some tasks do not fit the constructors:

  • an append to a log
  • a change of bytes in place
  • a handle with read and write access

For these tasks, write the flags explicitly with OpenOptions. The figure shows the decision tree:

Figure: Choosing OpenOptions flags

append(true) implies write access. Each write first moves to the end of the file, and the move and the write are one atomic operation. Thus the mode is safe even when several processes append to the same log:

// log: PathBuf, the path of `app.log`. `create(true)` creates the file on the first use.
let mut file = OpenOptions::new().append(true).create(true).open(&log)?;
writeln!(file, "boot: ok")?;   // adds one line at the end of the file

A common error is write(true) without truncate(true). The writes replace bytes in place from position 0, and the bytes that you do not replace stay in the file:

// state: PathBuf, the path of `state.txt`.
fs::write(&state, "HELLO WORLD")?;                              // 11 bytes
let mut file = OpenOptions::new().write(true).open(&state)?;    // no truncate: the content stays
file.write_all(b"bye")?;                                        // replaces bytes 0..3 only
assert_eq!(fs::read_to_string(&state)?, "byeLO WORLD");         // the old tail "LO WORLD" stays

The standard library rejects flag combinations that have no meaning. It returns ErrorKind::InvalidInput before it calls the OS. The example shows four such combinations:

  • truncate without write access
  • append together with truncate
  • create without write or append access
  • no access mode

Clippy's nonsensical_open_options lint also finds the first two combinations at compile time.

09_02_openoptions_builder.rs prints:

app.log after two appends:
boot: ok
listen: 127.0.0.1:8080

write(true) over "HELLO WORLD":  "byeLO WORLD"
write(true).truncate(true):      "bye"
second create_new on instance.lock: AlreadyExists
4 nonsensical flag combinations all rejected: InvalidInput

All assertions passed.

File as Read + Write + Seek

File implements all three I/O traits (tutorials 8.1 and 8.3). Thus one handle that you open with .read(true).write(true) can do random-access I/O. Random-access I/O is the base of each database file, index, and archive format. With fixed-width records, slot N always starts at byte N * RECORD_SIZE:

// RECORD_SIZE is 8: each record is a u32 id and a u32 score, as little-endian bytes.
fn write_record(file: &mut File, slot: u64, id: u32, score: u32) -> io::Result<()> {
    file.seek(SeekFrom::Start(slot * RECORD_SIZE))?;   // moves the cursor to the start of the slot
    file.write_all(&id.to_le_bytes())?;                // 4 bytes
    file.write_all(&score.to_le_bytes())               // 4 bytes. This result is the return value.
}

SeekFrom has three variants:

  • Start(u64): an absolute position.
  • Current(i64): a position relative to the cursor.
  • End(i64): a position relative to the end of the file. For example, SeekFrom::End(-8) goes to the last 8-byte record, and you do not need to know the number of records.

stream_position() returns the position of the cursor. rewind() is a short form of seek(SeekFrom::Start(0)).

You can seek past the end of the file. The file grows on the subsequent write, and the gap that you did not write reads as zeros. On many filesystems, the gap is a sparse "hole" that uses no disk space:

// `file` holds 3 records (24 bytes). `read_record(&mut file, slot)` returns (id, score).
write_record(&mut file, 9, 1010, 999)?;         // slots 3..=8 were never written
assert_eq!(file.metadata()?.len(), 80);         // 10 slots of 8 bytes
assert_eq!(read_record(&mut file, 5)?, (0, 0)); // the hole reads as zeros

09_03_read_write_seek.rs prints:

wrote 3 records, cursor at byte 24
slot 1 updated in place: (1002, 500)
SeekFrom::End(-8) reads the last record: id 1003
slot 9 written; file is now 80 bytes; unwritten slot 5 reads as (0, 0)

All assertions passed.

Setting Permissions on Open

On Unix, OpenOptionsExt::mode sets the permission bits of the file atomically at creation. This is important for secrets. If you create the file and then call chmod, the file has the default permissions for a short time. During that time, a different process can open the file. .mode(0o600) removes that interval:

use std::os::unix::fs::OpenOptionsExt;   // adds `mode` to OpenOptions (Unix only)

// secrets: PathBuf, the path of `credentials.toml`.
let mut file = OpenOptions::new()
    .write(true)
    .create_new(true)     // fails if the file exists
    .mode(0o600)          // owner: read and write. Group and other: no access.
    .open(&secrets)?;

The process umask filters the mode that you request. A umask can only clear bits. Thus a 0o600 request guarantees that group and other get no access, in any environment. The example shows two more details:

  • If you create a file with .mode(0o444) (read-only), you can still write to it through the handle that created it. The OS checks the permissions at open time, not for each write. The OS refuses a new open for write access (the root user is an exception).
  • To unlink a file, you need write permission on the directory, not on the file. Thus read-only files do not prevent the cleanup of the scratch directory on Unix.

Windows has no mode bitmask, because it controls access with ACLs. Its std::os::windows::fs::OpenOptionsExt has access_mode and attributes as the alternative. On Windows, the example prints a skip message.

On Unix, 09_04_permissions_on_open.rs prints:

credentials.toml mode: 0o600 (requested 0o600)
receipt.txt readonly:  true
re-open for write:     PermissionDenied   # (varies: the root user gets "allowed")

All assertions passed.

Summary

ConceptKey point
fs::write / fs::read_to_string / fs::readOne-line functions for a whole file. There is no handle to manage.
read_to_string vs readread_to_string requires valid UTF-8 (InvalidData on failure). read returns raw bytes.
File::openRead-only access. The call returns NotFound if the file is missing.
File::createWrite access. The call creates the file or truncates it at open time.
File::create_newExclusive creation: one atomic check and creation, with no TOCTOU race
OpenOptionsThe full flag builder that all three constructors use
append(true)The flag implies write access. Each write first moves to the end of the file.
write without truncateOverwrite in place. Old bytes stay after the bytes that you wrote.
Invalid flag combinationsThe standard library rejects them with ErrorKind::InvalidInput before it calls the OS.
File: Read + Write + SeekOne handle with random access. SeekFrom::{Start, Current, End} specifies the position.
Seek past EOFLegal. The gap reads as zeros (a sparse hole).
OpenOptionsExt::mode (Unix)The method sets the permissions atomically at creation. The umask filters the mode.

Code Examples

FileDescription
09_01_file_open_create.rsFile::open/create/create_new, fs::write/read_to_string/read, UTF-8 vs raw reads
09_02_openoptions_builder.rsOpenOptions flags, append-only logs, the write-without-truncate error, rejected combinations
09_03_read_write_seek.rsA fixed-width record store: random access with one Read + Write + Seek handle
09_04_permissions_on_open.rsUnix OpenOptionsExt::mode: private files from the moment of creation (the example skips on Windows)