How to Read and Write CSV Files in Rust

Use the csv crate with Reader and Writer to parse and generate CSV files in Rust.

When text files need structure

You have a spreadsheet exported as sales_2024.csv. You need to load it, filter for high-value orders, and write the results to a new file. You grab the file, split lines by newline, split fields by comma, and immediately hit a wall. The customer name is "O'Brien, Mary". Your simple split logic sees a comma inside the quotes and thinks it's a field separator. The data is misaligned. The parser is useless.

You could write a regex to handle quotes, escapes, and different line endings. You could spend an afternoon debugging edge cases where a field contains a newline or a double quote. Or you can use the csv crate. It parses CSV correctly, handles all the edge cases, and streams data efficiently so you don't load the whole file into memory.

Don't write your own CSV parser. The edge cases will haunt you.

The csv crate handles the complexity

CSV stands for Comma-Separated Values, but the format is surprisingly complex. Fields can contain commas if they are wrapped in quotes. Quotes inside fields need to be escaped with double quotes. Line endings vary between operating systems. Some files use tabs or pipes as delimiters. The csv crate handles all this parsing logic so you don't have to write code that breaks on the first real-world dataset.

The crate treats CSV as a structured format, not just text. It validates the structure, respects quote rules, and yields records one by one. You get an iterator of Record objects. Each record contains the fields for a row. The parser buffers input to minimize system calls, which keeps performance high even for large files.

The crate handles the messy text so you can focus on the data.

Minimal read and write

Add csv = "1.3" to your Cargo.toml. The crate provides Reader for parsing and Writer for output. Both work with any type that implements Read or Write, so you can use files, strings, or network streams.

use csv::{Reader, Writer};
use std::fs::File;

/// Demonstrates basic CSV reading and writing.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Open the file for reading.
    let file = File::open("data.csv")?;
    // Create a reader that borrows the file.
    let mut rdr = Reader::from_reader(file);

    // Iterate over records, handling parse errors.
    for result in rdr.records() {
        let record = result?;
        println!("{:?}", record);
    }

    // Create a file for writing.
    let file = File::create("output.csv")?;
    // Wrap the file in a writer.
    let mut wtr = Writer::from_writer(file);

    // Write header and data rows.
    wtr.write_record(&["id", "name"])?;
    wtr.write_record(&["1", "Alice"])?;
    // Ensure data is written to disk.
    wtr.flush()?;

    Ok(())
}

The Reader takes ownership of the file or borrows it. In this example, Reader::from_reader takes ownership of the File. The records() method returns an iterator. Each item is a Result<Record, Error>. You must handle the error because a file can contain malformed data. The ? operator propagates the error up to main, which returns Result.

The Writer buffers output. write_record adds data to the buffer. The buffer stays in memory until you call flush or the writer drops. If you don't flush, you might lose data if the program crashes.

The iterator pattern forces you to handle errors. You can't ignore bad data.

How the reader and writer work

The Reader wraps a type that implements Read. It buffers input to avoid constant system calls. When you iterate with records(), the parser pulls data from the buffer, respects quote rules, and yields a Record. Each Record is a collection of fields. You can access fields by index or by name if headers are enabled.

The Writer wraps a type that implements Write. It buffers output. write_record accepts a slice of string slices. It escapes fields that contain commas, quotes, or newlines. The writer handles the quoting logic automatically.

Convention aside: ReaderBuilder is preferred over Reader::from_reader in production code. The builder makes configuration explicit. Reader::from_reader is a shortcut that assumes no headers and default settings. Using the builder signals intent and prevents surprises when the data format changes.

use csv::ReaderBuilder;
use std::fs::File;

/// Reads CSV with explicit configuration.
fn read_with_config() -> Result<(), Box<dyn std::error::Error>> {
    let file = File::open("data.csv")?;
    // Configure the reader to use the first row as headers.
    let mut rdr = ReaderBuilder::new()
        .has_headers(true)
        .from_reader(file);

    // Access fields by name using headers.
    for result in rdr.records() {
        let record = result?;
        // Get a field by name.
        let name = record.get("name");
        println!("{:?}", name);
    }

    Ok(())
}

The Record type provides methods like get, len, and indexing. get returns an Option<&str>, which is safe. Indexing returns a &str but panics if the index is out of bounds. Use get for robust code.

Flush the writer. Data in the buffer is data you haven't saved yet.

Mapping rows to structs

Real code rarely prints records. You usually want to map rows to structs. The csv crate integrates with serde. You define a struct, derive Deserialize, and use Reader::deserialize. The crate maps CSV fields to struct fields by name or position.

use csv::ReaderBuilder;
use serde::Deserialize;
use std::fs::File;

/// Represents a row in the sales data.
#[derive(Debug, Deserialize)]
struct Sale {
    id: u32,
    customer: String,
    amount: f64,
}

/// Reads sales data and filters high-value orders.
fn process_sales() -> Result<(), Box<dyn std::error::Error>> {
    let file = File::open("sales.csv")?;
    // Configure the reader to use headers.
    let mut rdr = ReaderBuilder::new().has_headers(true).from_reader(file);

    let mut total = 0.0;
    // Deserialize each row into a Sale struct.
    for result in rdr.deserialize() {
        let sale: Sale = result?;
        if sale.amount > 100.0 {
            total += sale.amount;
        }
    }

    println!("Total high-value sales: {:.2}", total);
    Ok(())
}

The deserialize method returns an iterator of Result<Sale, Error>. The error includes the row number and the parse failure. This makes debugging easier. The struct fields must match the CSV headers. If a header is missing or misspelled, deserialization fails.

Convention aside: has_headers(true) is a common safety net. Most CSV files have headers. Enabling headers allows field access by name and makes deserialization robust against column order changes.

Struct mapping turns text into types. The compiler checks your schema for you.

Handling messy real-world data

Real-world CSV files often have inconsistent column counts or trailing whitespace. The default parser is strict. If a row has fewer fields than the header, it returns an error. You can relax this with ReaderBuilder.

Set flexible(true) to allow variable column counts. Rows with fewer fields will have missing fields set to empty strings. Rows with more fields will have extra fields ignored. This prevents the parser from stopping on a single malformed row.

Set trim(Whitespace) to remove spaces around fields. CSV exports from Excel often add spaces. Trimming prevents silent data corruption from formatting errors.

use csv::ReaderBuilder;
use std::fs::File;

/// Reads messy CSV with relaxed rules.
fn read_messy_csv() -> Result<(), Box<dyn std::error::Error>> {
    let file = File::open("messy.csv")?;
    // Configure the reader for messy data.
    let mut rdr = ReaderBuilder::new()
        .has_headers(true)
        .flexible(true)
        .trim(csv::Trim::All)
        .from_reader(file);

    for result in rdr.records() {
        let record = result?;
        println!("{:?}", record);
    }

    Ok(())
}

Relaxing the parser helps you load data, but you still need to validate the results in your logic. flexible(true) doesn't fix bad data. It just lets the parser continue. Check for missing fields or unexpected values after parsing.

Relax the parser for messy data, but validate the results in your logic.

Performance and byte records

The csv crate is designed for speed. It uses SIMD internally for parsing. You can work with ByteRecord instead of StringRecord to avoid UTF-8 validation overhead. ByteRecord treats data as raw bytes. This is useful when you don't need string semantics or when performance is critical.

The trade-off is you lose String methods and have to handle encoding manually. ByteRecord fields are &[u8]. You can convert them to strings later if needed. The crate provides ByteRecord methods for comparison and indexing.

use csv::ReaderBuilder;
use std::fs::File;

/// Reads CSV as byte records for performance.
fn read_bytes() -> Result<(), Box<dyn std::error::Error>> {
    let file = File::open("data.csv")?;
    // Use byte records to skip UTF-8 validation.
    let mut rdr = ReaderBuilder::new()
        .has_headers(false)
        .from_reader(file);

    for result in rdr.byte_records() {
        let record = result?;
        // Access fields as byte slices.
        let first_field = record.get(0);
        println!("{:?}", first_field);
    }

    Ok(())
}

Use ByteRecord when profiling shows UTF-8 validation is the bottleneck. For most applications, StringRecord is fast enough and easier to work with. The crate validates UTF-8 by default, which is safer.

Use ByteRecord when profiling shows UTF-8 validation is the bottleneck.

Common pitfalls and compiler errors

The Reader borrows the underlying file. You can't move the file while the reader exists. If you try to use the file after creating the reader, the compiler rejects you with E0382 (use of moved value) or a borrow conflict. The reader holds a mutable borrow of the file internally.

If you pass a Vec<String> to write_record, the compiler rejects you with E0308 (mismatched types). The writer expects a slice of string slices. You need to convert or borrow. Use .as_slice() on a vector of string slices, or map owned strings to references.

use csv::Writer;
use std::fs::File;

/// Demonstrates type conversion for writing.
fn write_with_conversion() -> Result<(), Box<dyn std::error::Error>> {
    let file = File::create("output.csv")?;
    let mut wtr = Writer::from_writer(file);

    // Owned data needs conversion.
    let row = vec!["1".to_string(), "Alice".to_string()];
    // Convert to slice of references.
    let refs: Vec<&str> = row.iter().map(|s| s.as_str()).collect();
    wtr.write_record(&refs)?;

    wtr.flush()?;
    Ok(())
}

The compiler error E0277 (trait bound not satisfied) can appear if you try to deserialize into a type that doesn't implement Deserialize. Ensure your struct derives Deserialize and all fields support deserialization.

Check the borrow checker. It protects you from using a file after the reader consumes it.

Decision matrix

Use csv::Reader when you need to parse CSV files with correct handling of quotes, escapes, and delimiters. Use csv::Writer when you need to generate CSV output that other tools can read without corruption. Use ReaderBuilder when you need to configure the parser for headers, custom delimiters, or flexible quote rules. Use serde deserialization when you want to map CSV rows directly to Rust structs with type validation. Reach for manual string splitting only when you are processing a controlled internal format and performance profiling proves the crate is a bottleneck.

Pick the tool that matches your data shape. The crate scales from simple rows to complex schemas.

Where to go next