Skip to content
Select theme

How to write a CSV file from structs

Short answer: Open a csv::Writer on a path and call serialize() on structs that derive Serialize. The first record writes the header from the field names, and any field containing a comma, a quote or a newline comes out quoted, with the quotes inside it doubled.

This continues the json-demo project from the reading page; its Cargo.toml already carries the two crates used here:

Cargo.toml
[dependencies]
csv = "1.4.0"
serde = { version = "1.0.229", features = ["derive"] }
src/main.rs
use csv::Writer;
use serde::Serialize;
use std::fs;
#[derive(Serialize)]
struct Person {
name: String,
age: u8,
city: String,
}
fn main() {
let mut writer = Writer::from_path("people.csv").unwrap();
for person in [
Person {
name: "Ada Lovelace".to_string(),
age: 36,
city: "London".to_string(),
},
Person {
name: "Grace Hopper".to_string(),
age: 45,
city: "New York".to_string(),
},
] {
writer.serialize(person).unwrap();
}
writer.flush().unwrap();
print!("{}", fs::read_to_string("people.csv").unwrap());
}
name,age,city
Ada Lovelace,36,London
Grace Hopper,45,New York

The header row is written once, from the field names, so #[serde(rename = "...")] on a field renames its column — the same attribute the JSON page uses against an API you cannot change. Numbers stay bare: age is a u8, so it lands as 36, not "36". Records end with a single \n. RFC 4180 asks for \r\n, and this writer emits LF unless you build it with a different terminator.

write_record takes raw strings instead, and the quoting rules are narrower than they look:

src/main.rs
use csv::Writer;
fn main() {
let mut writer = Writer::from_path("quoted.csv").unwrap();
writer
.write_record(&[
"plain",
"with,comma",
"with \"quote\"",
"with\nnewline",
" leading and trailing ",
"",
"tab\there",
])
.unwrap();
writer.flush().unwrap();
print!("{}", std::fs::read_to_string("quoted.csv").unwrap());
}
plain,"with,comma","with ""quote""","with
newline", leading and trailing ,,tab here

A field is quoted when it holds the delimiter, a quote or a newline, and a quote inside a quoted field is doubled: with "quote" becomes "with ""quote""". What does not trigger quoting: leading or trailing spaces, an empty field, and a tab. The newline rule is the one that bites, because a quoted field can span several lines of the file — anything downstream that assumes one line per record breaks the moment a value contains a newline.

When the consumer wants quotes everywhere, build the writer with a style:

src/main.rs
use csv::{QuoteStyle, WriterBuilder};
use std::fs;
fn main() {
for (label, style) in [
("necessary", QuoteStyle::Necessary),
("always", QuoteStyle::Always),
("non-numeric", QuoteStyle::NonNumeric),
] {
let path = format!("{label}.csv");
let mut writer = WriterBuilder::new().quote_style(style).from_path(&path).unwrap();
writer
.write_record(&["Ada", "36", "36.5", "", "0x10", "with,comma"])
.unwrap();
writer.flush().unwrap();
println!("{label}:");
print!("{}", fs::read_to_string(&path).unwrap());
}
}
necessary:
Ada,36,36.5,,0x10,"with,comma"
always:
"Ada","36","36.5","","0x10","with,comma"
non-numeric:
"Ada",36,36.5,"","0x10","with,comma"

NonNumeric quotes every field that does not parse as a number, so 36 and 36.5 stay bare while 0x10 and the empty field are quoted. Always puts quotes around everything.

Writer::from_path truncates the file if it exists, so the old contents are gone before the first record lands. To append, open the file yourself and hand the handle to from_writer:

src/main.rs
use csv::Writer;
use std::fs::{self, OpenOptions};
fn main() {
fs::write("log.csv", "when,what\n2024-01-15,signup\n").unwrap();
let file = OpenOptions::new().append(true).open("log.csv").unwrap();
let mut writer = Writer::from_writer(file);
writer.write_record(&["2024-02-20", "renewal"]).unwrap();
writer.flush().unwrap();
print!("{}", fs::read_to_string("log.csv").unwrap());
}
when,what
2024-01-15,signup
2024-02-20,renewal

The writer is buffered, so write_record returns before anything reaches the disk; the file catches up on flush(), on into_inner(), or when the writer is dropped. A drop cannot report an error, so a write that fails there disappears silently — call flush() and check it when the file matters. One more rule the writer enforces: every record must have the same number of fields, or write_record hands back an UnequalLengths error.

The next page in this chapter reads YAML.