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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
In this article we have written to files in Rust. See also the Rust file reading article.
Author
List all Rust tutorials.