1. Why validation is needed if the CSV has already “been read”
When you first successfully parse a CSV via csv.Reader and get a []string for each record, it feels like: “That’s it, the import is ready, time to celebrate.” That is a very human reaction. The problem is that csv.Reader guarantees only one thing: the CSV format is correct. It does not promise that id is actually a number, that done looks like a boolean, or that title is not empty.
Let’s define the boundary of responsibility. The CSV parser is responsible for syntax: quotes are closed, the delimiter is recognized, a record is assembled. But the meaning of the data is your application’s contract. And that is exactly where validation appears. Validation is not “nitpicking”; it is a way to keep garbage out of your domain model, so you do not have to later hunt down why a task with id = "banana" suddenly cannot be deleted.
A very important point: validation almost always must be able to explain the error in a way that a human can fix the file. That is why we need an error report with a row number and clear text: where exactly the problem is and what exactly is wrong.
Row number in CSV: record vs physical line
The term “row number” in CSV is a bit tricky. We are used to thinking that a file line is what is between "\n" and "\n". But in CSV, a newline can appear inside quotes, and then one CSV record “takes up” two (or more) physical lines in the file. So the “physical line number in the file” and the “CSV record number” may not match.
From a practical standpoint, for an error report we more often need the record number in the table: “the second data row after the header.” That is: a person opens the CSV in Excel/Google Sheets, sees row 2 or 17, goes there, and fixes it. This is the most convenient UX: the user thinks in terms of a table, not file bytes.
So we will agree on this: in the report, we treat the CSV row number as the record number in the file (record number), including the header. That means the header is row 1, and the first data row has number 2. Technically, that means: we read the header, then read records and increment the rowNo counter starting from 2.
2. Model and CSV contract for import
To keep validation grounded, we need a simple data model. Let our training app be a task manager. A task will have an identifier, a title, and a completion status. We will import tasks from CSV and want each row to be able to become a valid Task.
Minimal Task model
package main
type Task struct {
ID int
Title string
Done bool
}
Note: we immediately want to end up with an int and a bool inside the model. CSV stores everything as strings, but our program should not live in a string-only world forever — otherwise it will not be an app, it will be a “string store with hope for the best.”
Which columns we expect and how we validate them
When you import a CSV, you are essentially accepting an external contract. External contracts almost always break. Sometimes accidentally, sometimes intentionally, sometimes because someone “shifted the columns a bit and saved.” So we need to agree on the format ahead of time and enforce it strictly.
Let’s imagine the task CSV looks like this:
id,title,done
1,Buy milk,false
2,Read Go book,true
That is, we expect the columns id, title, done. And each column has its own rules.
| Column | Model type | Minimal rule | Example error |
|---|---|---|---|
|
|
required, must parse as an integer | |
|
|
required, not empty after TrimSpace | |
|
|
required, accept true/false | |
The meaning of validation here is: if a row cannot become a valid Task, we must either stop the import (strict policy) or skip the row and report the error (lenient policy). We will not complicate policy here: in this lecture we focus on how to produce a high-quality error with context.
4. Validation skeleton: getField and RowError
When beginners write a CSV import, the code often looks like one huge function that contains reading, column mapping, parsing, validation, model construction, and printing errors. It is not because people are lazy — it is simply easier to “get to a result.”
But that kind of code has an unpleasant effect: when the first error appears, you do not understand where it came from or what exactly is broken. A good import style is small functions: separately “get a field by name,” separately “parse an int,” separately “check required,” separately “assemble a Task.”
Safely get a column value by name: getField
Let’s start with a small helper: safely get a column value by name. We will use a map idx, where the key is the column name and the value is the index in record.
package main
import "fmt"
func getField(record []string, idx map[string]int, col string) (string, error) {
i, ok := idx[col]
if !ok {
return "", fmt.Errorf("missing column %s", col)
}
if i < 0 || i >= len(record) {
return "", fmt.Errorf("column %s: index out of range", col)
}
return record[i], nil
}
Here we protected ourselves from two classes of problems at once: the column is missing in the header, and “not enough fields in the record.” The second one is sometimes caught via FieldsPerRecord, but an extra check does not hurt — especially if the input data likes surprises.
Error with context: RowError
A message like invalid int is useless. It says “something is wrong,” but does not say where. A message like row 7, column id, value "abc": invalid int can already be handed to a human: they will open the CSV and fix it.
In Go, it is convenient to make your own error type. This is very much in the Go spirit: errors are values; you can model them, store fields, and improve UX.
Let’s make RowError that stores the row number, column name, the “bad” value, and the root cause (as a nested error).
package main
import "fmt"
type RowError struct {
Row int
Col string
Value string
Err error
}
func (e RowError) Error() string {
return fmt.Sprintf("row %d: column %s: %s (%q)", e.Row, e.Col, e.Err, e.Value)
}
Notice a small detail: we print the value using %q so we can see spaces, tabs, and other “invisible joys.” If title contains " ", then with %s you will see “emptiness” and think the value is missing. With %q it becomes obvious that it is spaces.
5. Field validation: required, int, bool
Now we need validation functions that return either a value of the desired type or a RowError.
required for strings
First, let’s make required for strings. The logic is simple: trim whitespace and check that it is not empty.
package main
import (
"errors"
"strings"
)
var errRequired = errors.New("required")
func requiredString(row int, col string, raw string) (string, error) {
v := strings.TrimSpace(raw)
if v == "" {
return "", RowError{Row: row, Col: col, Value: raw, Err: errRequired}
}
return v, nil
}
We use errRequired as the base cause. This is convenient: the cause is short, and the context is added by RowError. Later, if you want to display errors differently (for example, show “required field” separately), you will have a structured foundation.
Parsing int
package main
import (
"fmt"
"strconv"
"strings"
)
func parseInt(row int, col string, raw string) (int, error) {
v := strings.TrimSpace(raw)
n, err := strconv.Atoi(v)
if err != nil {
return 0, RowError{Row: row, Col: col, Value: raw, Err: fmt.Errorf("invalid int")}
}
return n, nil
}
Here I deliberately do not wrap the err from Atoi as %w so the example stays compact and does not go into details of diagnosing internal causes. But if you build a production import, wrapping the root cause is a good idea: then logs have something to latch onto.
Parsing bool
A common temptation is to accept anything (0/1, "yes"/"no", "TRUE"/"FALSE"), because “it’s more convenient for people.” That is not always bad, but for a contract it matters to agree on rules. Here we accept true/false in any case:
package main
import (
"fmt"
"strings"
)
func parseBool(row int, col string, raw string) (bool, error) {
v := strings.ToLower(strings.TrimSpace(raw))
if v == "true" {
return true, nil
}
if v == "false" {
return false, nil
}
return false, RowError{Row: row, Col: col, Value: raw, Err: fmt.Errorf("must be true or false")}
}
6. Building a Task from a record
Now we have a set of small building blocks. All that is left is to build the wall: a function that takes record, rowNo, and idx, and returns either a Task or an error that already includes the row number.
package main
import "fmt"
func parseTaskRecord(record []string, idx map[string]int, rowNo int) (Task, error) {
rawID, err := getField(record, idx, "id")
if err != nil {
return Task{}, fmt.Errorf("row %d: %w", rowNo, err)
}
rawTitle, err := getField(record, idx, "title")
if err != nil {
return Task{}, fmt.Errorf("row %d: %w", rowNo, err)
}
rawDone, err := getField(record, idx, "done")
if err != nil {
return Task{}, fmt.Errorf("row %d: %w", rowNo, err)
}
id, err := parseInt(rowNo, "id", rawID)
if err != nil {
return Task{}, err
}
title, err := requiredString(rowNo, "title", rawTitle)
if err != nil {
return Task{}, err
}
done, err := parseBool(rowNo, "done", rawDone)
if err != nil {
return Task{}, err
}
return Task{ID: id, Title: title, Done: done}, nil
}
7. Import with a per-row error report
Now the most practical part: how to design the import so it does not “die silently,” but can collect a list of issues and show them to the user.
We will write a function that reads from io.Reader, returns tasks, and also returns a list of validation errors. The err error will mean “the import cannot continue” (for example, the CSV is broken or an I/O error occurred), and problems means “rows were read, but some did not satisfy the contract.”
package main
import (
"encoding/csv"
"fmt"
"io"
)
func importTasksCSV(r io.Reader) ([]Task, []error, error) {
cr := csv.NewReader(r)
header, err := cr.Read()
if err != nil {
return nil, nil, fmt.Errorf("read header: %w", err)
}
// Example: the column index is hard-coded by hand.
// In real code, idx is usually built based on header.
_ = header
idx := map[string]int{"id": 0, "title": 1, "done": 2}
rowNo := 2
var tasks []Task
var problems []error
for {
rec, err := cr.Read()
if err == io.EOF {
return tasks, problems, nil
}
if err != nil {
return nil, nil, fmt.Errorf("read csv: %w", err)
}
t, err := parseTaskRecord(rec, idx, rowNo)
if err != nil {
problems = append(problems, err)
} else {
tasks = append(tasks, t)
}
rowNo++
}
}
Note the loop style: we read records until io.EOF, and handle errors separately. This approach usually makes code straightforward: “read the stream to the end, and on output neatly split errors into ‘fatal’ and ‘data errors’.”
What the error output looks like
When we have []error, the simplest thing is to print them line by line. If RowError.Error() already contains everything we need, the output will be human-friendly.
package main
import "fmt"
func printProblems(problems []error) {
for _, err := range problems {
fmt.Println("import:", err)
}
}
For example, if row 3 has an empty title, you will get:
import: row 3: column title: required (" ")
And this is where %q works magic: the person sees that the value is not missing, it consists of spaces. This is a typical “invisible” data error, and without correct output you can stare at the CSV for a long time as if it were a riddle.
8. Import flow: where errors are born
To avoid getting lost, it helps to keep a simple flow in mind. It helps distinguish “the file is broken” from “the data is bad.”
flowchart TD
A[Read header] --> B[Build column index]
B --> C[Read record]
C -->|io.EOF| Z[Finish]
C -->|parse error| E[Stop: fatal error]
C --> D[Validate + parse fields]
D -->|ok| T[Append Task]
D -->|validation error| P[Append problem with row number]
T --> C
P --> C
The point of this diagram is simple: errors have different “weights.” A read/parse error is a crash; the import cannot understand what is happening next. A validation error is a problem of a specific row, but the file format as a whole is fine.
9. Common mistakes when validating CSV rows
Mistake #1: confusing CSV parsing errors with validation errors.
When csv.Reader returns an error, it usually means the CSV is broken as a format: quotes were not closed, weird characters, unexpected structure. Validation is when the CSV was read successfully, but the data does not satisfy your contract. If you mix these levels, you will get either an “overly strict import” (crashes on the first bad row) or an “overly silent import” (swallows critical format errors).
Mistake #2: error messages without context.
Phrases like invalid id or bad value sound smart, but they do not help fix the file. A person needs at least: the row (record) number, the column, and the problematic value. That is why RowError with fields is not a luxury, but basic import hygiene.
Mistake #3: using a row number “however it comes out,” without agreeing on its meaning.
If you report “row 10,” but the user opens the CSV and sees the problem on “row 11,” trust in the import drops instantly. You must explicitly decide whether you count the header or not and stick to it. And if you allow newlines inside quotes, it is better to rely on the CSV record number rather than the physical file line number.
Mistake #4: not normalizing strings before checks.
Checking title == "" without strings.TrimSpace is a classic trap. In real CSVs, people leave spaces, tabs, sometimes even non-breaking spaces. Without normalization, you will accept an “empty” title as valid and then wonder why the task list looks like a set of blank rows.
Mistake #5: turning validation into one huge function.
A big import function seems convenient at first, but later it is hard to understand at which step the problem happened. Small functions like requiredString, parseInt, parseBool, getField make the code longer in line count, but shorter in the number of thoughts you need to hold in your head.
GO TO FULL VERSION