rust 132 lines · 3 tabs

Row-by-Row CSV Import Validation With Line-Numbered Errors in Rust

Shared by codesnips Aug 2026
3 tabs
use serde::Deserialize;
use std::fmt;

#[derive(Debug, Deserialize)]
pub struct UserRecord {
    pub name: String,
    pub email: String,
    pub age: u32,
}

impl UserRecord {
    pub fn validate(&self) -> Vec<String> {
        let mut errors = Vec::new();
        if self.name.trim().is_empty() {
            errors.push("name must not be empty".to_string());
        }
        if !is_valid_email(&self.email) {
            errors.push(format!("invalid email: {:?}", self.email));
        }
        if self.age < 18 || self.age > 120 {
            errors.push(format!("age {} out of range (18-120)", self.age));
        }
        errors
    }
}

fn is_valid_email(value: &str) -> bool {
    let mut parts = value.split('@');
    match (parts.next(), parts.next(), parts.next()) {
        (Some(local), Some(domain), None) => {
            !local.is_empty() && domain.contains('.') && !domain.starts_with('.')
        }
        _ => false,
    }
}

#[derive(Debug)]
pub struct RowError {
    pub line: u64,
    pub errors: Vec<String>,
}

impl fmt::Display for RowError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "line {}: {}", self.line, self.errors.join("; "))
    }
}
3 files · rust Explain with highlit

This snippet shows how to validate a CSV import in Rust while preserving the physical line number for every problem, so a user can jump straight to the offending row in their file. The core insight is that serde deserialization and business validation are two distinct failure modes, and both need to be attributed to the exact spreadsheet line the data came from.

In record.rs, the UserRecord struct derives Deserialize so the csv crate can map header-named columns onto typed fields. Validation lives in validate, which returns a Vec<String> of human-readable messages rather than short-circuiting on the first error — collecting all issues per row is friendlier for bulk imports than failing fast. Small helpers like is_valid_email keep the rules explicit and testable. The RowError type pairs a line number with its errors, and its Display impl formats a compact one-line summary.

The subtlety handled in importer.rs is line numbering. A csv::Reader yields records starting after the header, so a naive zero-based index is off by the header row and by one for human counting. The ReaderBuilder uses flexible(false) so rows with the wrong column count are rejected by the parser itself. The code reads through records() and uses the reader's own position().line() where available, falling back to a computed index + 2 (one for the header, one for 1-based counting). Each iteration distinguishes a parse-level Err from a successfully deserialized record that still fails validate, funneling both into a RowError with the correct line.

Accumulating into errors lets import return Result<Vec<UserRecord>, Vec<RowError>>: either the fully valid set or the complete list of problems. This all-or-nothing contract avoids partially importing dirty data.

In main.rs, the CLI opens the path from args, calls import, and on failure prints every RowError to stderr before exiting non-zero. Because each error already knows its line, the output is directly actionable. The trade-off is memory: buffering all records suits typical import sizes but a streaming, chunked approach would be preferable for very large files. This pattern is worth reaching for whenever imported data must be trustworthy and errors must be traceable back to source.


Related snips

Share this code

Here's the card — post it anywhere.

Row-by-Row CSV Import Validation With Line-Numbered Errors in Rust — share card
Link copied