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:
[dependencies]csv = "1.4.0"serde = { version = "1.0.229", features = ["derive"] }A struct is a row
Section titled “A struct is a row”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,cityAda Lovelace,36,LondonGrace Hopper,45,New YorkThe 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.
The quoting you did not expect
Section titled “The quoting you did not expect”write_record takes raw strings instead, and the quoting rules are narrower than they look:
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""","withnewline", leading and trailing ,,tab hereA 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:
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.
Two traps around the file itself
Section titled “Two traps around the file itself”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:
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,what2024-01-15,signup2024-02-20,renewalThe 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.
- How to read a CSV file into structs — the reader side, where the header names decide the mapping
- How to rename JSON fields with serde —
renameandrename_allagainst an API you cannot change - Back to Working with JSON, TOML and CSV
The next page in this chapter reads YAML.