JavaRush /Courses /Go SELF /CSV row validation and error reporting

CSV row validation and error reporting

Go SELF
Level 47, Lesson 2
Available

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
id
int
required, must parse as an integer
row 4: column id: invalid int "abc"
title
string
required, not empty after TrimSpace
row 3: column title: required
done
bool
required, accept true/false
row 5: column done: must be true or 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.

1
Task
Go SELF, level 47, lesson 2
Locked
Column lookup
Column lookup
1
Task
Go SELF, level 47, lesson 2
Locked
Row Error
Row Error
1
Task
Go SELF, level 47, lesson 2
Locked
Task validation
Task validation
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION