How to Append to a File in Rust

Append to a file in Rust using OpenOptions with append(true) to add data without overwriting.

Appending to a file without overwriting

You're building a background service that tracks events. Every time a user logs in, you want to write a timestamp to a log file. You don't want to overwrite yesterday's entries. You just want to tack the new line onto the end. In Python, you'd open the file with mode 'a'. In Rust, the approach is explicit and safe, but it requires understanding how the standard library handles file modes and why the default behavior is different.

The tape deck analogy

Think of a file like a magnetic tape. When you open a file for writing in the default mode, the tape head sits at the very beginning. If you write, you overwrite whatever is there. The OS doesn't guess your intent. It assumes you want to replace the content.

Append mode is different. It's like a tape deck with a "fast forward to end" button that triggers automatically the moment you press record. The system moves the cursor to the last byte, and every write operation starts from there. The existing content stays untouched. You can't accidentally overwrite the start of the file because the cursor is locked to the end.

Minimal example

use std::fs::OpenOptions;
use std::io::Write;

fn main() -> std::io::Result<()> {
    // OpenOptions is a builder. You configure flags, then call open.
    // Nothing happens on the filesystem until open() is called.
    let mut file = OpenOptions::new()
        .create(true) // Create the file if it doesn't exist.
        .append(true) // Seek to end before every write.
        .open("events.log")?;

    // writeln! formats the string and adds a newline.
    // The ? operator propagates IO errors to the caller.
    writeln!(file, "Event recorded at {}", std::time::SystemTime::now())?;

    Ok(())
}

What happens under the hood

When you call OpenOptions::new(), you get a configuration struct. Chaining .create(true) and .append(true) sets internal flags. No file is opened yet. When you call .open(), Rust passes those flags to the operating system. The OS opens a file descriptor.

The append flag tells the kernel to set the O_APPEND mode. This mode changes how write syscalls behave. Normally, a write happens at the current file offset. With O_APPEND, the kernel atomically seeks to the end of the file and then writes. This happens inside the kernel, not in your Rust code. You don't need to call seek manually. The kernel handles the positioning.

This atomicity is a hidden superpower. If you have multiple threads writing to the same file handle, or even multiple processes opening the same file with append mode, the OS guarantees that writes land at the end. You won't get interleaved bytes. If you tried to replicate this by manually seeking to the end, you'd introduce a race condition. The file could grow between your seek and your write, causing you to overwrite the other thread's data. Append mode solves this at the kernel level.

Trust the atomicity. The kernel handles the positioning.

Realistic usage with buffering

In a real application, you rarely open and close the file for every line. Opening a file involves system calls and disk metadata lookups. You hold the file handle open and write to it repeatedly. When you write many small chunks, you also want to reduce system call overhead. Every call to write on a file triggers a system call. System calls have overhead. If you're logging thousands of lines, that overhead adds up.

Wrapping the file in BufWriter allocates a chunk of memory. Writes fill that memory. The actual system call happens only when the buffer fills or when you flush. This can speed up I/O by orders of magnitude for small writes.

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

/// Appends a log entry to the file using a buffered writer.
/// Returns the writer so the caller can keep writing.
fn open_log_writer(path: &str) -> io::Result<BufWriter<std::fs::File>> {
    let file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?;

    // BufWriter wraps the file.
    // Writes go to the buffer first, reducing syscall frequency.
    Ok(BufWriter::new(file))
}

fn main() -> io::Result<()> {
    let mut writer = open_log_writer("app.log")?;

    // write! fills the buffer.
    // No syscall happens until the buffer is full or flushed.
    write!(writer, "Starting application\n")?;
    write!(writer, "Loading config\n")?;

    // flush() pushes the buffer to the OS.
    // The OS may still cache the data, but it's out of your process.
    writer.flush()?;

    Ok(())
}

Convention aside: flush() pushes data to the OS buffer. sync_all() forces the OS to write to the physical disk. For a log file, flush() is usually sufficient. If you're writing transaction data where losing a byte on power failure is catastrophic, reach for sync_all().

Buffering is free performance. Wrap small writes in BufWriter.

Pitfalls and compiler errors

If you forget .create(true) and the file doesn't exist, open returns an error. You'll see a NotFound error. The file is not created automatically unless you ask for it. This prevents accidental file creation in wrong directories.

If you forget mut on the file variable, you can't write. The compiler rejects this with E0596 (cannot borrow as mutable). Writing requires a mutable reference because it changes the file state. The compiler enforces this. You must mark the variable as mutable.

If you use File::create() instead of OpenOptions, you truncate the file. File::create() wipes the content. That's a common trap. File::create() is equivalent to OpenOptions::new().write(true).create(true).truncate(true). The truncate flag sets the file length to zero. If you want to append, never use File::create().

If you try to write a non-string type without formatting, you get E0277 (trait bound not satisfied). The write! macro requires types that implement Display or Debug. If you pass a raw byte slice or a struct without the trait, the compiler stops you. Use write!(file, "{:?}", value) for debug output, or implement Display for your types.

If you mix append(true) with write(true), the behavior depends on the OS. On most systems, append takes precedence for writes. However, relying on this is fragile. Stick to one mode. If you need to write and append, open two handles or use OpenOptions with the flags you actually need.

Don't reach for File::create() unless you want to nuke the file. Stick to OpenOptions.

When to use append vs alternatives

Use OpenOptions::append(true) when you need to add data to the end of a file without reading or overwriting existing content. Use OpenOptions::write(true) when you need random access writes or want to overwrite specific parts of the file. Use File::create() when you want to create a new file or truncate an existing one to zero length. Use File::open() when you only need to read the file and don't intend to modify it. Use BufWriter wrapped around an append file when you are writing many small chunks and want to reduce system call overhead. Use OpenOptions::create_new(true) when you must ensure the file is fresh and fail if it already exists.

Pick the tool that matches your intent. The compiler will thank you for being explicit.

Where to go next