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
FileimplementsRead + 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:
| Constructor | Access | If the file is missing | If the file is present |
|---|---|---|---|
File::open | read | NotFound | opens the file |
File::create | write | creates the file | truncates the file to 0 bytes |
File::create_new | write | creates the file | AlreadyExists |
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:
truncatewithout write accessappendtogether withtruncatecreatewithout 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
| Concept | Key point |
|---|---|
fs::write / fs::read_to_string / fs::read | One-line functions for a whole file. There is no handle to manage. |
read_to_string vs read | read_to_string requires valid UTF-8 (InvalidData on failure). read returns raw bytes. |
File::open | Read-only access. The call returns NotFound if the file is missing. |
File::create | Write access. The call creates the file or truncates it at open time. |
File::create_new | Exclusive creation: one atomic check and creation, with no TOCTOU race |
OpenOptions | The 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 truncate | Overwrite in place. Old bytes stay after the bytes that you wrote. |
| Invalid flag combinations | The standard library rejects them with ErrorKind::InvalidInput before it calls the OS. |
File: Read + Write + Seek | One handle with random access. SeekFrom::{Start, Current, End} specifies the position. |
| Seek past EOF | Legal. 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
| File | Description |
|---|---|
09_01_file_open_create.rs | File::open/create/create_new, fs::write/read_to_string/read, UTF-8 vs raw reads |
09_02_openoptions_builder.rs | OpenOptions flags, append-only logs, the write-without-truncate error, rejected combinations |
09_03_read_write_seek.rs | A fixed-width record store: random access with one Read + Write + Seek handle |
09_04_permissions_on_open.rs | Unix OpenOptionsExt::mode: private files from the moment of creation (the example skips on Windows) |