Skip to content

Opening a file

Level: 201 · working knowledge

One line: File::open is read-only, File::create truncates, and everything else is OpenOptions — the mode is a decision you make, and the wrong one is not a compile error.

File::open and File::create are the two doors nearly every program uses, and each is one line of OpenOptions underneath. This page is about the line each one hides: create empties an existing file at the moment of opening, append does not create, and the error you get back for any of it names the problem but not the file.

The program below makes a scratch directory, walks through every door inside it, and deletes it — how that stays deterministic is at the bottom.

Verified output of opening_a_file.rs — regenerated by tools/run_examples.py, never hand-typed.

1. open reads what is there, so on a file that is not there it fails
   File::open(notes)            Err(NotFound)

2. create makes the file, and write_all puts bytes in it
   File::create(notes)          0 bytes on disk
   after two write_all calls    23 bytes
   File::open + read_to_string  "first line\nsecond line\n"
   fs::read_to_string(notes)    the same two steps as one call: true

3. The trap: create empties the file at open, before anything is written
   File::create on the 23-byte file, nothing written yet: 0 bytes
   The old contents are already gone. A crash between this open and the
   last write leaves an empty file, and no copy of what was there.

4. What the two doors are made of, and what the builder adds
   File::open   = OpenOptions::new().read(true)
   File::create = OpenOptions::new().write(true).create(true).truncate(true)
   append(true), two writeln!   "first line\nsecond line\nthird line\n"
   create_new(true), file exists   Err(AlreadyExists)
   create_new(true), fresh name    Ok(File)
   write(true), file missing       Err(NotFound)
   append(true), file missing      Err(NotFound)  <- append does not imply create
   no mode at all                  Err(InvalidInput)

5. The error does not carry the path
   Display   No such file or directory (os error 2)
   kind()    NotFound
   put back by hand: settings.toml: No such file or directory (os error 2)

6. A BufWriter holds your bytes until it is flushed
   after write_all, before flush  0 bytes on disk
   after flush()                  10 bytes

7. Scratch directory removed: true

The three doors

Read them off std's own source. This is library/std/src/fs.rs at 1.98.0, doc comments removed:

pub fn open<P: AsRef<Path>>(path: P) -> io::Result<File> {
    OpenOptions::new().read(true).open(path.as_ref())
}

pub fn create<P: AsRef<Path>>(path: P) -> io::Result<File> {
    OpenOptions::new().write(true).create(true).truncate(true).open(path.as_ref())
}

pub fn create_new<P: AsRef<Path>>(path: P) -> io::Result<File> {
    OpenOptions::new().read(true).write(true).create_new(true).open(path.as_ref())
}
door what it sets what happens
File::open read Err(NotFound) if the file is not there; otherwise a handle you can only read from
File::create write + create + truncate makes the file if it is missing — and empties it if it is not
File::create_new (1.77) read + write + create_new makes the file, or Err(AlreadyExists) — the door that refuses to overwrite

The name is the trap. Create reads as "make it if it is missing", and the third setter says what really happens: an existing file is cut to zero bytes at open, before your first write. Section 3 of the run opens a 23-byte file with File::create, writes nothing, and looks — 0 bytes. Every line between that open and your last write is a window in which a crash leaves an empty file and no copy of the old one. When overwriting would be a bug rather than the plan, create_new is the door to take.

OpenOptions, spelled out

The builder has six setters, all taking a bool, and File::options() (1.58) is the short way to start one:

setter what it means
.read(true) reading is allowed
.write(true) writing is allowed, starting at the beginning of the file
.append(true) writing is allowed, and every write lands at the end — it implies write
.truncate(true) cut the file to 0 bytes on open — needs write
.create(true) make the file if it is missing — needs write or append
.create_new(true) make the file, and fail with AlreadyExists if it is there — create and truncate are ignored when this is set

Section 4 of the run exercises the combinations that surprise people. append(true) with two writeln! calls leaves three lines, the original one included. create_new(true) on the existing file is AlreadyExists; on a fresh name it is Ok. write(true) on a file that does not exist is NotFound, because writing does not imply creating — and so is append(true) on a missing file, which is the row that catches anyone arriving from Python, where 'a' creates. A builder with no mode at all is InvalidInput; std's message for it is must specify at least one of read, write, or append access.

The error does not carry the path

Section 5. Display says No such file or directory (os error 2) and kind() says NotFound, and neither says settings.toml. That is not an oversight: an io::Error is built from the number the operating system returned, and the operating system does not hand the name back with it. So the name has to be put back by whoever still has it — the run does it with one format!, and anyhow and context (stub) is the page about doing it properly. The kind, not the message, is what a program branches on: NotFound on a first run is often not an error at all, which is the whole of Missing is not empty (stub).

There is no close()

The drop(file) calls in the run only make the moment visible; a File closes when it goes out of scope, because releasing the descriptor is what its Drop does. The place this bites is section 6: a BufWriter keeps your bytes in an 8 KiB buffer, so after write_all the file on disk is still 0 bytes, and only flush() moves them. Dropping the writer flushes too — but a Drop has nowhere to return an error, so a flush that fails on the way out is lost without a word. Call flush() yourself and read its Result. Read and Write (stub) is the page for the traits themselves; A trait must be in scope is why the use std::io::Write; line cannot be left out.

How this example stays deterministic

Every example in this library is diffed against a recorded answer key, and a file example has two ways to break that: the path differs per machine, and the directory may already hold something from an earlier run. The program above writes only inside a directory it creates under std::env::temp_dir(), named with its own process id; it prints byte counts, error kinds and file contents, never a path; and it removes the directory at the end. The one operating-system string it does print — No such file or directory (os error 2) — is the C library's wording, and the key was diffed against a Linux run (rust:1.98-slim in Docker) before it was committed. On Windows that string would differ, which is why every other line prints kind() and not the message.

If you are coming from another language

Python. The mode string is the same set of decisions with one-letter names, and the Python library's Opening a file ↗ measures the same truncation-at-open trap in its section 2 ↗:

Python Rust
open(p) File::open(p) read only, must exist
open(p, "w") File::create(p) both empty an existing file at open, not at the first write
open(p, "x") File::create_new(p) create, or fail if it exists
open(p, "a") File::options().append(true).create(true).open(p) 'a' creates a missing file; append(true) alone does not — the NotFound row in section 4
open(p, "r+") File::options().read(true).write(true).open(p) read and write, must exist
with open(p) as f: let f = File::open(p)?; closed at the end of the block either way; Rust has no keyword for it

What changes: Python's open() defaults to text mode and transcodes for you, so f.write("…") takes a str; a Rust File is always bytes, and write_all takes &[u8] — that seam is A file is bytes. And Python's "w" is a string you can misspell at run time; Rust's builder is checked, but the choice is still yours, and nothing stops create where append was meant.

ABAP. OPEN DATASET carries the same decisions as clauses, and of the three languages it is the one that makes you close by hand:

ABAP Rust
OPEN DATASET dset FOR INPUT IN TEXT MODE ENCODING UTF-8. File::open(p) sy-subrc = 8 when the file is missing; Err(NotFound)
OPEN DATASET dset FOR OUTPUT … File::create(p) both delete the existing content on open, and both create a missing file
OPEN DATASET dset FOR APPENDING … File::options().append(true).create(true) both create a missing file; READ DATASET on it fails with sy-subrc = 4
OPEN DATASET dset FOR UPDATE … File::options().read(true).write(true) must exist, pointer at the start
TRANSFER line TO dset. writeln!(file, "{line}") text mode adds the platform's line end for you; writeln! is how you ask for one
CLOSE DATASET dset. (nothing) ABAP closes at program end if you forget, and caps open files at 100 per internal session; a File closes at the end of its scope
IF sy-subrc <> 0. after the OPEN the Err that ? returns … MESSAGE msg is where the operating system's reason goes — and like io::Error, it does not name the file

What changes: in ABAP the encoding is a clause on the openIN TEXT MODE ENCODING UTF-8, or IN BINARY MODE for bytes — so the open file knows whether it holds text. A Rust File never knows; it is always the binary mode, and the text decision is made by the read (read_to_string) or by you. (Not machine-checked — CI cannot run ABAP; the clause semantics are from the 7.58 keyword documentation for OPEN DATASET.)

Practice

A log that is never truncated, and a lock that is claimed once. Write record(path, line) that appends one line, creating the file on the first call and never emptying it — then call it three times and read back three lines. Make the mistake first: append(true) with no create(true), on a file that does not exist yet, and read the kind you get. Then put File::create where record opens its file and count the lines that survive.

Then write claim(path) -> io::Result<bool>: Ok(true) if this call created the lock file, Ok(false) if somebody already holds it, and Err for everything else. Claim it twice, remove it, claim it again; then claim a path in a directory that does not exist, and say why that answer must not be false.

Solution

opening_a_file_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.

//! Kata solution: a log that is never truncated, and a lock that is claimed
//! exactly once -- `append` with `create`, and `create_new` matched on
//! `AlreadyExists` rather than on "an error happened".
//!
//!   rustc --edition 2024 opening_a_file_kata.rs -o /tmp/oafk && /tmp/oafk

use std::fs::{self, File};
use std::io::{self, ErrorKind, Write};
use std::path::{Path, PathBuf};

fn scratch_dir() -> io::Result<PathBuf> {
    let dir = std::env::temp_dir().join(format!("opening_a_file_kata_{}", std::process::id()));
    fs::create_dir_all(&dir)?;
    Ok(dir)
}

/// Append one line, creating the file on the first call. Never truncates.
fn record(path: &Path, line: &str) -> io::Result<()> {
    let mut log = File::options().append(true).create(true).open(path)?;
    writeln!(log, "{line}")
}

/// Claim a lock file: Ok(true) if this call created it, Ok(false) if somebody
/// already holds it, Err for anything else -- a missing directory, no permission.
fn claim(path: &Path) -> io::Result<bool> {
    match File::options().write(true).create_new(true).open(path) {
        Ok(_) => Ok(true),
        Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
        Err(e) => Err(e),
    }
}

fn show(r: io::Result<bool>) -> String {
    match r {
        Ok(b) => format!("Ok({b})"),
        Err(e) => format!("Err({:?})", e.kind()),
    }
}

fn main() -> io::Result<()> {
    let dir = scratch_dir()?;
    let log = dir.join("events.log");

    println!("1. The mistake worth making first: append without create");
    match File::options().append(true).open(&log) {
        Ok(_) => println!("   opened"),
        Err(e) => println!("   Err({:?}) -- append means \"at the end\", not \"make it\"", e.kind()),
    }

    println!("\n2. record: three calls, three lines, nothing lost");
    for line in ["started", "step 1 done", "finished"] {
        record(&log, line)?;
    }
    let text = fs::read_to_string(&log)?;
    for line in text.lines() {
        println!("   {line}");
    }
    println!("   {} lines", text.lines().count());

    println!("\n3. The same three calls through File::create instead");
    let wrong = dir.join("events_wrong.log");
    for line in ["started", "step 1 done", "finished"] {
        writeln!(File::create(&wrong)?, "{line}")?;
    }
    let text = fs::read_to_string(&wrong)?;
    println!("   {text:?} -- {} line: each open emptied the file first", text.lines().count());

    println!("\n4. claim: created once, refused once, created again after release");
    let lock = dir.join("build.lock");
    println!("   first claim    {}", show(claim(&lock)));
    println!("   second claim   {}", show(claim(&lock)));
    fs::remove_file(&lock)?;
    println!("   after remove   {}", show(claim(&lock)));
    println!("   A missing parent directory is a different answer, an Err rather than a false:");
    println!("   no such dir    {}", show(claim(&dir.join("no_such_dir").join("build.lock"))));
    println!("   Matching on the kind keeps \"somebody else has it\" apart from \"the lock");
    println!("   cannot be taken at all\". A bare .is_err() folds the two together.");

    fs::remove_dir_all(&dir)?;
    Ok(())
}

Verified output of opening_a_file_kata.rs — regenerated by tools/run_examples.py, never hand-typed.

1. The mistake worth making first: append without create
   Err(NotFound) -- append means "at the end", not "make it"

2. record: three calls, three lines, nothing lost
   started
   step 1 done
   finished
   3 lines

3. The same three calls through File::create instead
   "finished\n" -- 1 line: each open emptied the file first

4. claim: created once, refused once, created again after release
   first claim    Ok(true)
   second claim   Ok(false)
   after remove   Ok(true)
   A missing parent directory is a different answer, an Err rather than a false:
   no such dir    Err(NotFound)
   Matching on the kind keeps "somebody else has it" apart from "the lock
   cannot be taken at all". A bare .is_err() folds the two together.

See also

Po polsku

Najgroźniejsza jest tu sama nazwa. Po polsku „utwórz plik” brzmi jak „załóż go, jeśli jeszcze nie istnieje”, tymczasem File::create otwiera do zapisu i obcina istniejący plik do zera bajtów już w chwili otwarcia — program powyżej otwiera tak 23-bajtowy plik, nie zapisuje nic i patrzy: 0 bajtów. Kto zna C albo Pythona, ma gotową intuicję: create to fopen(ścieżka, "w"), open to "r" (tylko odczyt, plik musi istnieć), a wszystko poza tym — dopisywanie, odczyt z zapisem, create_new — składa się z metod na OpenOptions, czyli z tych samych flag rozpisanych na słowa. Jedna z nich zaskakuje osobno: append(true) samo w sobie nie tworzy brakującego pliku (Pythonowe 'a' tworzy), więc dziennik, do którego się dopisuje, otwiera się przez append(true).create(true).

Druga niespodzianka to io::Error, który nie niesie ze sobą ścieżki: komunikat mówi „No such file or directory” i ani słowa o tym, o który plik chodzi, bo system operacyjny oddaje tylko numer błędu. Nazwę trzeba dołożyć samemu, a decyzje opiera się na rodzaju błędu (e.kind()) — NotFound przy pierwszym uruchomieniu programu często wcale nie jest błędem. Uchwytu do pliku (file handle) nie zamyka się ręcznie: nie ma close(), robi to Drop przy wyjściu z zasięgu, jak ABAP-owe CLOSE DATASET, tylko bez instrukcji. Za to BufWriter trzyma bajty w buforze aż do flush(): po write_all plik na dysku ma 0 bajtów, po flush() 10, a błąd opróżniania bufora przy Drop przepada bez śladu — dlatego flush() wywołuje się samemu i czyta jego Result.

Szukaj po polsku: otwieranie pliku w Ruscie · tryby otwarcia pliku · dopisywanie do pliku · rust File::create truncates · rust OpenOptions append create