ZetCode

Rust writing to files

last modified October 25, 2026

In this article we show how to write to files in Rust.

File operations are provided by the std::fs and std::io modules. The fs::write function writes a whole file in one call. For more control, we open a file with File::create or OpenOptions and write to it with the methods of the Write trait and the write! and writeln! macros. The BufWriter type collects small writes into larger blocks.

Writing can fail: the directory may not exist, the disk may be full, or we may lack permissions. The functions return io::Result, and the examples pass the errors up with the ? operator. A file is closed automatically when its File value goes out of scope; Rust has no explicit close method.

The examples were tested with Rust 1.97 and the 2024 edition. They are run as Cargo projects with cargo run -q. Each example runs in a directory that contains a notes.txt file with the line first note; the cat command shows the written files.

Writing a whole file

The fs::write function creates a file, writes the given data to it, and closes it. If the file exists, its contents are replaced.

main.rs
use std::fs;
use std::io;

fn main() -> io::Result<()> {
    fs::write("words.txt", "sky\nblue\nfalcon\n")?;

    let words = ["rock", "water", "storm"];
    fs::write("more.txt", words.join("\n") + "\n")?;

    println!("files written");

    Ok(())
}

We write two text files.

fs::write("words.txt", "sky\nblue\nfalcon\n")?;

The function accepts anything that can be viewed as bytes: a string slice, a String, a byte slice, or a vector of bytes. It does not add a newline at the end; we write it ourselves.

fs::write("more.txt", words.join("\n") + "\n")?;

The words are joined with newlines into a String.

$ cargo run -q
files written
$ cat words.txt
sky
blue
falcon
$ cat more.txt
rock
water
storm

Creating a file

The File::create function opens a file for writing. It creates the file if it does not exist and truncates it if it does.

main.rs
use std::fs::File;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    let mut file = File::create("hello.txt")?;

    file.write_all(b"Hello there!\n")?;
    file.write_all("Žltý kôň\n".as_bytes())?;

    let n = file.write(b"partial writes are possible\n")?;
    println!("wrote {n} bytes in the last call");

    Ok(())
}

We create a file and write bytes to it.

use std::io::{self, Write};

The write methods come from the Write trait, which must be in scope.

file.write_all(b"Hello there!\n")?;
file.write_all("Žltý kôň\n".as_bytes())?;

The write_all method writes the whole buffer. A byte string literal b"..." can contain only ASCII characters; other text is converted with as_bytes, which returns its UTF-8 bytes.

let n = file.write(b"partial writes are possible\n")?;

The write method makes a single attempt and returns the number of bytes that were written, which may be less than the length of the buffer. In most cases, we use write_all, which repeats the writes until all data is written.

$ cargo run -q
wrote 28 bytes in the last call
$ cat hello.txt
Hello there!
Žltý kôň
partial writes are possible

Formatted output

The write! and writeln! macros work like print! and println!, but they write to any value that implements Write.

main.rs
use std::fs::File;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    let mut file = File::create("report.txt")?;

    let items = [("apples", 3, 1.25), ("plums", 12, 0.4), ("pears", 5, 0.95)];

    writeln!(file, "{:<8} {:>5} {:>8}", "item", "count", "total")?;
    writeln!(file, "{}", "-".repeat(23))?;

    for (name, count, price) in items {
        writeln!(file, "{name:<8} {count:>5} {:>8.2}", count as f64 * price)?;
    }

    write!(file, "end of report")?;
    writeln!(file)?;

    Ok(())
}

We write a small table into a text file.

writeln!(file, "{:<8} {:>5} {:>8}", "item", "count", "total")?;

The first argument is the destination. The format string supports the same width and alignment specifiers as println!.

writeln!(file, "{name:<8} {count:>5} {:>8.2}", count as f64 * price)?;

The total is printed with two decimal places.

write!(file, "end of report")?;
writeln!(file)?;

The write! macro does not add a newline. Called without a format string, writeln! writes only the newline. The macros return io::Result<()>, so each call ends with ?.

$ cargo run -q
$ cat report.txt
item     count    total
-----------------------
apples       3     3.75
plums       12     4.80
pears        5     4.75
end of report

Appending to a file

To add data to the end of an existing file, we open it with OpenOptions in the append mode.

main.rs
use std::fs::OpenOptions;
use std::io::{self, Write};

fn main() -> io::Result<()> {
    let mut file = OpenOptions::new()
        .append(true)
        .create(true)
        .open("notes.txt")?;

    writeln!(file, "another note")?;

    Ok(())
}

Each run of the program adds a line to the notes.txt file.

let mut file = OpenOptions::new()
    .append(true)
    .create(true)
    .open("notes.txt")?;

The OpenOptions builder sets the mode in which the file is opened. With append(true), every write goes to the end of the file. The create(true) option creates the file if it does not exist; without it, opening a missing file fails.

$ cargo run -q
$ cat notes.txt
first note
another note
$ cargo run -q
$ cat notes.txt
first note
another note
another note

Truncation

Opening an existing file with File::create deletes its contents. This is a common source of lost data.

main.rs
use std::fs::{self, File};
use std::io::{self, Write};

fn main() -> io::Result<()> {
    println!("before: {:?}", fs::read_to_string("notes.txt")?);

    let mut file = File::create("notes.txt")?;
    writeln!(file, "replaced")?;

    println!("after: {:?}", fs::read_to_string("notes.txt")?);

    Ok(())
}

We print the contents of the notes.txt file, open it with File::create, and write a new line.

let mut file = File::create("notes.txt")?;

The file is truncated to zero length when it is opened, before anything is written. The original line is gone.

$ cargo run -q
before: "first note\n"
after: "replaced\n"

Creating a new file only

The File::create_new function creates a file only if it does not exist yet. It fails with the AlreadyExists error kind otherwise, so existing data is never overwritten.

main.rs
use std::fs::File;
use std::io::{ErrorKind, Write};

fn main() {
    for name in ["config.txt", "notes.txt"] {
        match File::create_new(name) {
            Ok(mut file) => {
                writeln!(file, "debug = false").unwrap();
                println!("{name}: created");
            }
            Err(e) if e.kind() == ErrorKind::AlreadyExists => {
                println!("{name}: already exists, not overwritten");
            }
            Err(e) => println!("{name}: {e}"),
        }
    }
}

We try to create two files; one of them already exists.

match File::create_new(name) {
    Ok(mut file) => {
        writeln!(file, "debug = false").unwrap();
        println!("{name}: created");
    }
    Err(e) if e.kind() == ErrorKind::AlreadyExists => {
        println!("{name}: already exists, not overwritten");
    }
    Err(e) => println!("{name}: {e}"),
}

The check and the creation are a single atomic operation of the operating system. Checking with Path::exists first and creating the file afterwards would leave a window in which another process could create it.

$ cargo run -q
config.txt: created
notes.txt: already exists, not overwritten
$ cat config.txt
debug = false
$ cat notes.txt
first note

Buffered writing

Every write call on a File is a system call. Writing many small pieces is slow; BufWriter collects them in a buffer and writes larger blocks.

main.rs
use std::fs::File;
use std::io::{self, BufWriter, Write};

fn main() -> io::Result<()> {
    let file = File::create("squares.txt")?;
    let mut writer = BufWriter::new(file);

    for i in 1..=10_000 {
        writeln!(writer, "{i} {}", i * i)?;
    }

    writer.flush()?;

    let size = std::fs::metadata("squares.txt")?.len();
    println!("squares.txt: {size} bytes");

    Ok(())
}

We write ten thousand lines with numbers and their squares.

let mut writer = BufWriter::new(file);

The writer has a buffer of 8 KiB. It implements Write, so the writeln! macro works with it in the same way as with a file. Without the buffer, the program would make several system calls for each line, because writeln! writes the formatted pieces one by one.

writer.flush()?;

The flush method writes the remaining data in the buffer to the file. The buffer is also flushed when the writer is dropped, but errors that occur during the drop are ignored. An explicit flush reports them.

$ cargo run -q
squares.txt: 134276 bytes

Writing CSV data

In this example, we write a vector of structures as comma-separated values.

main.rs
use std::fs::File;
use std::io::{self, BufWriter, Write};

struct User {
    name: String,
    occupation: String,
    salary: u32,
}

fn write_users(path: &str, users: &[User]) -> io::Result<()> {
    let mut w = BufWriter::new(File::create(path)?);

    writeln!(w, "name,occupation,salary")?;
    for u in users {
        writeln!(w, "{},{},{}", u.name, u.occupation, u.salary)?;
    }

    w.flush()
}

fn main() -> io::Result<()> {
    let users = vec![
        User { name: "John Doe".into(), occupation: "gardener".into(), salary: 1200 },
        User { name: "Roger Roe".into(), occupation: "driver".into(), salary: 1500 },
        User { name: "Lucy Smith".into(), occupation: "teacher".into(), salary: 1900 },
    ];

    write_users("users.csv", &users)?;
    println!("{} users written", users.len());

    Ok(())
}

The write_users function writes a header and a line for each user.

fn write_users(path: &str, users: &[User]) -> io::Result<()> {

Writing is moved into a function that returns io::Result, so that the caller decides how to handle errors.

w.flush()

The function returns the result of the final flush as its own result. The values are written without quoting; for real CSV files with commas or quotes in the values, use the csv crate.

$ cargo run -q
3 users written
$ cat users.csv
name,occupation,salary
John Doe,gardener,1200
Roger Roe,driver,1500
Lucy Smith,teacher,1900

Writing binary data

Numbers can be stored in a binary format instead of text. The to_le_bytes and from_le_bytes methods convert numbers to and from bytes in the little-endian order.

main.rs
use std::fs;
use std::io;

fn main() -> io::Result<()> {
    let header = [0x52u8, 0x55, 0x53, 0x54];
    let value: u32 = 1_000_000;
    let temp: f32 = -12.5;

    let mut data = Vec::new();
    data.extend_from_slice(&header);
    data.extend_from_slice(&value.to_le_bytes());
    data.extend_from_slice(&temp.to_le_bytes());

    fs::write("data.bin", &data)?;

    let buf = fs::read("data.bin")?;
    println!("{} bytes: {:02x?}", buf.len(), buf);

    let magic = String::from_utf8_lossy(&buf[0..4]);
    let value = u32::from_le_bytes(buf[4..8].try_into().unwrap());
    let temp = f32::from_le_bytes(buf[8..12].try_into().unwrap());
    println!("{magic} {value} {temp}");

    Ok(())
}

We write a four-byte header, an integer, and a float into a binary file and read them back.

data.extend_from_slice(&value.to_le_bytes());
data.extend_from_slice(&temp.to_le_bytes());

A u32 and an f32 take four bytes each. The byte order is specified explicitly, so the file is read correctly on any platform.

println!("{} bytes: {:02x?}", buf.len(), buf);

The {:02x?} format prints the bytes in hexadecimal. The header bytes are the ASCII codes of "RUST"; the number 1000000 is 0x000f4240, stored with the lowest byte first.

let value = u32::from_le_bytes(buf[4..8].try_into().unwrap());

The from_le_bytes function takes an array of four bytes. The try_into call converts the four-byte slice into an array.

$ cargo run -q
12 bytes: [52, 55, 53, 54, 40, 42, 0f, 00, 00, 00, 48, c1]
RUST 1000000 -12.5

Writing to any destination

A function that takes a Write implementation can write to a file, to the standard output, or to memory.

main.rs
use std::fs::File;
use std::io::{self, Write};

fn write_table(out: &mut impl Write, rows: &[(&str, u32)]) -> io::Result<()> {
    for (name, n) in rows {
        writeln!(out, "{name:<6}{n:>4}")?;
    }
    Ok(())
}

fn main() -> io::Result<()> {
    let rows = [("sky", 3), ("rock", 12), ("storm", 7)];

    write_table(&mut io::stdout(), &rows)?;
    write_table(&mut File::create("table.txt")?, &rows)?;

    let mut buf: Vec<u8> = Vec::new();
    write_table(&mut buf, &rows)?;
    println!("{} bytes in memory", buf.len());

    let mut out: Box<dyn Write> = if std::env::args().any(|a| a == "--stdout") {
        Box::new(io::stdout())
    } else {
        Box::new(File::create("table2.txt")?)
    };
    writeln!(out, "written through a trait object")?;

    Ok(())
}

The write_table function writes the rows to a destination given by the caller.

fn write_table(out: &mut impl Write, rows: &[(&str, u32)]) -> io::Result<()> {

The parameter accepts a mutable reference to any type that implements Write.

write_table(&mut io::stdout(), &rows)?;
write_table(&mut File::create("table.txt")?, &rows)?;

let mut buf: Vec<u8> = Vec::new();
write_table(&mut buf, &rows)?;

We write the table to the console, to a file, and into a vector of bytes. A Vec<u8> is useful in tests, where we check the output without creating files.

let mut out: Box<dyn Write> = if std::env::args().any(|a| a == "--stdout") {
    Box::new(io::stdout())
} else {
    Box::new(File::create("table2.txt")?)
};

When the destination is chosen at run time, a trait object Box<dyn Write> holds either of the two types. Here the choice depends on a command-line option.

$ cargo run -q
sky      3
rock    12
storm    7
33 bytes in memory
$ cat table.txt
sky      3
rock    12
storm    7
$ cat table2.txt
written through a trait object
$ cargo run -q -- --stdout
sky      3
rock    12
storm    7
33 bytes in memory
written through a trait object

Creating directories

Creating a file fails if its directory does not exist. The fs::create_dir_all function creates a directory together with all missing parent directories.

main.rs
use std::fs;
use std::io;
use std::path::Path;

fn main() -> io::Result<()> {
    let dir = Path::new("output/2026/10");
    fs::create_dir_all(dir)?;

    let path = dir.join("log.txt");
    fs::write(&path, "created in a nested directory\n")?;

    println!("{}", path.display());
    println!("{}", path.exists());

    Ok(())
}

We create a nested directory and write a file into it.

let dir = Path::new("output/2026/10");
fs::create_dir_all(dir)?;

The function succeeds also when the directories already exist.

let path = dir.join("log.txt");

The join method builds a PathBuf with the correct separator for the platform.

$ cargo run -q
output/2026/10/log.txt
true
$ cat output/2026/10/log.txt
created in a nested directory

Safe saving

If a program crashes while writing a file, the file may be left incomplete. A common pattern is to write the data to a temporary file and then rename it to the final name.

main.rs
use std::fs::{self, File};
use std::io::{self, Write};

fn save(path: &str, content: &str) -> io::Result<()> {
    let tmp = format!("{path}.tmp");

    let mut file = File::create(&tmp)?;
    file.write_all(content.as_bytes())?;
    file.sync_all()?;

    fs::rename(&tmp, path)
}

fn main() -> io::Result<()> {
    save("settings.txt", "theme = dark\nfont_size = 14\n")?;

    println!("{}", fs::exists("settings.txt.tmp")?);

    Ok(())
}

The save function writes the new contents safely.

let mut file = File::create(&tmp)?;
file.write_all(content.as_bytes())?;
file.sync_all()?;

The data is written to a temporary file. The sync_all method waits until the operating system has stored the data on the disk; normally, written data may stay in the system cache for some time.

fs::rename(&tmp, path)

On the same file system, renaming replaces the old file in one step. Readers see either the old or the new contents, never a mix of them. The fs::exists function confirms that the temporary file is gone.

$ cargo run -q
false
$ cat settings.txt
theme = dark
font_size = 14

Reading and writing a file

A file opened with both read(true) and write(true) can be read and written through the same handle.

main.rs
use std::fs::OpenOptions;
use std::io::{self, Read, Seek, SeekFrom, Write};

fn main() -> io::Result<()> {
    let mut file = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open("counter.txt")?;

    let mut content = String::new();
    file.read_to_string(&mut content)?;

    let count: u32 = content.trim().parse().unwrap_or(0) + 1;
    println!("run number {count}");

    file.set_len(0)?;
    file.seek(SeekFrom::Start(0))?;
    writeln!(file, "{count}")?;

    Ok(())
}

The program keeps a counter of its runs in a file.

let mut file = OpenOptions::new()
    .read(true)
    .write(true)
    .create(true)
    .truncate(false)
    .open("counter.txt")?;

The file is created if it does not exist, and its contents are kept. The explicit truncate(false) documents that we do not want to lose the old value.

file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
writeln!(file, "{count}")?;

After reading, the position is at the end of the file. We truncate the file, move to its start, and write the new value.

$ cargo run -q
run number 1
$ cat counter.txt
1
$ cargo run -q
run number 2
$ cat counter.txt
2
$ cargo run -q
run number 3
$ cat counter.txt
3

Closing files

A file is closed when its value is dropped, typically at the end of a scope. With buffered writers, the data reaches the file only when the buffer is flushed.

main.rs
use std::fs::{self, File};
use std::io::{self, BufWriter, Write};

fn main() -> io::Result<()> {
    {
        let mut w = BufWriter::new(File::create("a.txt")?);
        writeln!(w, "buffered line")?;
        println!("inside the block: {:?}", fs::read_to_string("a.txt")?);
    }
    println!("after the block: {:?}", fs::read_to_string("a.txt")?);

    let mut w = BufWriter::new(File::create("b.txt")?);
    writeln!(w, "another line")?;
    let file = w.into_inner().map_err(|e| e.into_error())?;
    file.sync_all()?;
    drop(file);
    println!("b.txt: {:?}", fs::read_to_string("b.txt")?);

    Ok(())
}

We check the contents of a file while its writer is alive and after it is dropped.

{
    let mut w = BufWriter::new(File::create("a.txt")?);
    writeln!(w, "buffered line")?;
    println!("inside the block: {:?}", fs::read_to_string("a.txt")?);
}

Inside the block, the line is still in the buffer, so the file is empty. At the end of the block, the writer is dropped: the buffer is flushed and the file is closed.

let file = w.into_inner().map_err(|e| e.into_error())?;
file.sync_all()?;
drop(file);

The into_inner method flushes the buffer and returns the underlying file, reporting a failed flush as an error. The drop function closes the file before the end of the scope. Errors that occur when a file is closed are ignored; sync_all before closing reports them.

$ cargo run -q
inside the block: ""
after the block: "buffered line\n"
b.txt: "another line\n"

Source

The File type

The OpenOptions type

The Write trait

In this article we have written to files in Rust. See also the Rust file reading article.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Rust tutorials.