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

  1. 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.
  2. If the file uses a non-comma separator, pass the matching option: transform.Unmarshal (dict "delimiter" ";" "targetType" "map").
  3. 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

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


AI-assisted analysis of gohugoio/hugo@8a468df065 (2026-07-31). Data as JSON: /data/errors/aeaf222881b021f1.json. Report an issue: GitHub ↗.