gohugoio/hugo · error
cannot unmarshal CSV into %T: expected at least a header row
Error message
cannot unmarshal CSV into %T: expected at least a header row and one data row
What it means
In Decoder.unmarshalCSV with TargetType "map", Hugo converts CSV rows into []map[string]string keyed by the header row. That requires at least two records: one header row plus one data row. Fewer than two parsed records makes the map construction meaningless, so Hugo fails fast instead of returning an empty or ambiguous result.
Source
Thrown at parser/metadecoders/decoder.go:351
}
func (d Decoder) unmarshalCSV(data []byte, v any) error {
r := csv.NewReader(bytes.NewReader(data))
r.Comma = d.Delimiter
r.Comment = d.Comment
r.LazyQuotes = d.LazyQuotes
records, err := r.ReadAll()
if err != nil {
return err
}
switch vv := v.(type) {
case *any:
switch d.TargetType {
case "map":
if len(records) < 2 {
return fmt.Errorf("cannot unmarshal CSV into %T: expected at least a header row and one data row", v)
}
seen := make(map[string]bool, len(records[0]))
for _, fieldName := range records[0] {
if seen[fieldName] {
return fmt.Errorf("cannot unmarshal CSV into %T: header row contains duplicate field names", v)
}
seen[fieldName] = true
}
sm := make([]map[string]string, len(records)-1)
for i, record := range records[1:] {
m := make(map[string]string, len(records[0]))
for j, col := range record {
m[records[0][j]] = col
}
sm[i] = m
}View on GitHub ↗ (pinned to 8a468df065)
Solutions
- Ensure the CSV source has a header row plus at least one data row, or guard the template to skip unmarshalling when the resource content is empty/header-only.
- If the file uses a non-comma separator, pass the matching option: transform.Unmarshal (dict "delimiter" ";" "targetType" "map").
- Use targetType "slice" (the default) if you must accept header-only files and handle the raw records yourself.
Example fix
// before
{{ $rows := $csv | transform.Unmarshal (dict "targetType" "map") }}
// after
{{ $rows := slice }}
{{ if gt (len $csv.Content) 0 }}
{{ $rows = $csv | transform.Unmarshal (dict "delimiter" ";" "targetType" "map") }}
{{ end }} Defensive patterns
Strategy: validation
Validate before calling
// require header + at least one data row before decoding CSV into structs/maps
records, err := csv.NewReader(bytes.NewReader(data)).ReadAll()
if err != nil {
return err
}
if len(records) < 2 {
return fmt.Errorf("CSV %s needs a header row and at least one data row", filename)
} Try / catch
if err := decoder.UnmarshalTo(data, metadecoders.CSV, &rows); err != nil {
if strings.Contains(err.Error(), "expected at least a header row") {
// empty or header-only file — treat as no data, not corruption
}
return err
} Prevention
- Never ship empty or header-only CSV data files; validate row count in CI
- If a dataset can legitimately be empty, decode into [][]string instead of a keyed type, or skip the file when it has fewer than 2 rows
- Watch for trailing exports that produce header-only CSVs from upstream tools
When it happens
Trigger: transform.Unmarshal on a CSV resource with (dict "targetType" "map") when csv.ReadAll yields 0 or 1 records — an empty file, a header-only file, or a file whose delimiter option doesn't match the actual separator so everything collapses into one record.
Common situations: CSV exports that legitimately have zero data rows (empty query results) fed to a template expecting map output; wrong "delimiter" option (e.g. semicolon-separated file read with the default comma); files containing only a trailing newline; remote CSV endpoints returning an empty body.
Related errors
- failed to decode options: %w
- invalid character: %q
- failed to unmarshal XML: %w
- XML root element '%s' has no value
- cannot unmarshal CSV into %T: header row contains duplicate
AI-assisted analysis of gohugoio/hugo@8a468df065 (2026-07-31).
Data as JSON: /data/errors/aeaf222881b021f1.json.
Report an issue: GitHub ↗.