登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go csv.Reader 怎样处理每行字段数不同的文件

来源:17golang原创

时间:2026-11-15 21:30:07 101浏览 收藏

CSV 文件最容易让人困惑的地方,不是逗号怎么分隔,而是每条记录的字段数量未必一致。Go 的 encoding/csv 默认会让第一条记录决定后续字段数;如果后面的记录少一列或多一列,Read 就可能返回 csv.ErrFieldCount。

真正要做的选择只有一个:先判断文件是否有稳定的字段契约,再设置 Reader.FieldsPerRecord。正数表示固定列数,0 表示用第一条记录自动定标,负数表示允许每行字段数不同。官方文档:https://pkg.go.dev/encoding/csv#Reader

固定格式用正数保护数据质量;格式由首行决定时用 0;确实允许增减列时用负数,但缺列和多列的业务含义必须由你的代码明确处理。

FieldsPerRecord 的三个取值先分清

FieldsPerRecord 必须在第一次调用 Read 或 ReadAll 前设置。它不是“遇到错误后再切换”的开关,而是当前读取器的数据契约。

取值字段数规则适合的文件异常表现
大于 0每条记录都必须有指定数量的字段接口导出、固定模板、强约束报表数量不符时返回 ErrFieldCount
等于 0第一条记录确定数量,后续沿用没有单独元数据、但本批文件应保持一致后续数量变化时返回 ErrFieldCount
小于 0不检查每条记录的字段数量外部导出列会增减、需要逐行兼容的文件记录照常返回,业务层负责解释长度
CSV Reader 的三种 FieldsPerRecord 字段策略静态结构说明图
图1:说明图展示 csv.Reader、三种 FieldsPerRecord 契约和业务字段映射的静态关系,不是运行截图。

固定列数时,直接用正数保住数据契约

如果 CSV 的前三列分别是订单号、金额和状态,那么缺列或多列都不应该悄悄进入下游。此时把 FieldsPerRecord 设为 3,读取循环只需要把 ErrFieldCount 单独记录或转成带行号的业务错误。

package main

import (
    "encoding/csv"
    "errors"
    "fmt"
    "io"
    "os"
)

func readFixedColumns(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return fmt.Errorf("打开 CSV 失败: %w", err)
    }
    defer file.Close() // 文件句柄由当前函数负责释放

    reader := csv.NewReader(file)
    reader.FieldsPerRecord = 3 // 三列是这个导入任务的固定契约

    for row := 1; ; row++ {
        record, err := reader.Read()
        if errors.Is(err, io.EOF) {
            break // 没有下一条记录时才算正常结束
        }
        if errors.Is(err, csv.ErrFieldCount) {
            // record 仍可能包含已读到的字段,适合记录坏行位置
            return fmt.Errorf("第 %d 条记录字段数不是 3: %w", row, err)
        }
        if err != nil {
            // 引号、转义等 CSV 语法错误不能当成字段数问题处理
            return fmt.Errorf("第 %d 条记录解析失败: %w", row, err)
        }

        fmt.Printf("order=%s amount=%s status=%s\n", record[0], record[1], record[2])
    }
    return nil
}

这里有一个容易忽略的细节:当字段数不符时,Read 可能同时返回部分记录和 ErrFieldCount。如果固定列是数据质量底线,应该优先拒绝或隔离这一行,而不是因为拿到了部分切片就继续访问固定下标。

想让第一条记录决定列数,就保留默认的 0

FieldsPerRecord 的默认值是 0。第一次成功读到记录后,读取器会把该记录的字段数作为后续期望值。这个模式适合“文件没有外部 schema,但同一个文件内部应该整齐”的场景,例如首行是标题、后续每行都应保持同样列数。

package main

import (
    "encoding/csv"
    "fmt"
    "io"
    "strings"
)

func readWithFirstRowSchema(input string) error {
    reader := csv.NewReader(strings.NewReader(input))
    reader.FieldsPerRecord = 0 // 第一条记录成功后自动锁定字段数

    for {
        record, err := reader.Read()
        if err == io.EOF {
            return nil // ReadAll 不需要手动处理 EOF;逐行 Read 要明确结束条件
        }
        if err != nil {
            return fmt.Errorf("CSV 读取失败: %w", err)
        }
        fmt.Printf("字段数=%d, 数据=%v\n", len(record), record)
    }
}

自动模式并不等于“每行都能读”。如果第一行只有 3 个字段,后面某行有 4 个字段,仍然会得到 ErrFieldCount。因此它解决的是“schema 由文件首行提供”,不是“接受任意不规则 CSV”。如果首行可能是空行、注释或不稳定的说明行,也不要把它直接当作 schema,应先明确文件协议。

字段数量真的可变时,用 -1 接收,再由业务层归一化

外部系统导出 CSV 时,经常会因为版本、租户配置或可选字段导致每行长度变化。如果这些变化是允许的,就把 FieldsPerRecord 设为负数。此时 Read 不再因为字段数量不同返回 ErrFieldCount,但它也不会替你决定缺失字段应该填空、跳过还是拒绝。

package main

import (
    "encoding/csv"
    "fmt"
    "io"
    "strings"
)

type orderRow struct {
    ID     string
    Amount string
    Status string
}

func normalize(record []string) (orderRow, error) {
    if len(record) == 0 {
        return orderRow{}, fmt.Errorf("记录没有字段")
    }

    row := orderRow{ID: record[0]} // 第一列是所有版本都保留的主键
    if len(record) > 1 {
        row.Amount = record[1] // 缺列时保留空值,由业务决定是否补默认值
    }
    if len(record) > 2 {
        row.Status = record[2]
    }
    if len(record) > 3 {
        return orderRow{}, fmt.Errorf("多出 %d 个未定义字段", len(record)-3)
    }
    return row, nil
}

func readVariableColumns(input string) error {
    reader := csv.NewReader(strings.NewReader(input))
    reader.FieldsPerRecord = -1 // 允许每条记录拥有不同字段数

    for {
        record, err := reader.Read()
        if err == io.EOF {
            break // 只有 EOF 才代表输入已经读完
        }
        if err != nil {
            return fmt.Errorf("CSV 语法错误: %w", err)
        }
        row, err := normalize(record) // 长度解释放在业务层,不放进 Reader
        if err != nil {
            return err
        }
        fmt.Printf("归一化订单: %+v\n", row)
    }
    return nil
}

负数模式只放宽了“字段数量”这一项检查。引号没有闭合、裸引号等 CSV 语法错误仍然会返回解析错误,所以不能把 FieldsPerRecord = -1 当作“任何脏数据都能读”的万能开关。

csv.Reader Read 返回记录、EOF、ErrFieldCount 和 ParseError 的静态关系说明图
图2:结构图把正常记录、文件结束、字段数不符和 CSV 语法错误分开,帮助确定读取循环的错误分支。

ErrFieldCount、ParseError 和 EOF 不要混在一起

处理不规则 CSV 时,最常见的误判是把所有非 nil 错误都当作“跳过这一行”,或者把字段数错误吞掉后继续按固定下标取值。建议按下面的语义分层:

  • io.EOF:输入已经读完,退出循环,不是坏数据。
  • csv.ErrFieldCount:字段数量违反了当前读取器契约;固定模式下通常应记录行号、隔离或拒绝。
  • *csv.ParseError:引号、转义等 CSV 语法无法解析,不能靠改字段数模式修复。
  • 其他错误:保留原始错误并向上返回,避免把文件读取失败伪装成业务数据问题。

如果你需要定位语法错误的行列,可以用类型断言或 errors.As 取出 *csv.ParseError。如果只关心“是否字段数量不符”,则用 errors.Is(err, csv.ErrFieldCount) 比较更稳妥。

最后按数据契约选模式

可以把选择压缩成四个问题:下游是否要求固定列数?第一条记录是否可靠?缺列是否有明确默认值?多出的列是兼容扩展还是输入错误?

你的情况建议额外动作
列数是接口契约,错一列就不能入库正数保留并上报 ErrFieldCount,不要继续按下标消费
首行代表本文件 schema,后续应一致0确认首行不是说明行、注释或脏数据
可选列或扩展列在不同记录间合法变化-1在业务层按长度补齐、截取或拒绝
格式本身也可能损坏任意模式均可继续单独处理 ParseError,不要只看字段长度

常见疑问

把 FieldsPerRecord 设成 -1 后,还会有 ErrFieldCount 吗?

字段数量检查会关闭,因此不会因为每行列数不同返回 ErrFieldCount。但 CSV 语法错误仍会返回,业务层也可能因为缺少主键或多出未定义列主动返回错误。

FieldsPerRecord=0 和 -1 应该怎么选?

如果文件内部应保持一致,选 0,让第一条记录成为约束;如果文件协议明确允许每行长度不同,选 -1,并把字段长度解释写进业务代码。

Read 返回 ErrFieldCount 时,record 能不能继续用?

它可能包含已经读取到的字段,但这不代表记录满足你的业务 schema。只有在你明确设计了“部分记录可观察、但不入库”的处理方式时才读取它来记录诊断信息,不能直接访问固定下标并当作完整记录。

使用 ReadAll 会改变字段数规则吗?

不会。ReadAll 仍然遵守 FieldsPerRecord,只是把剩余记录一次性收集起来,并在读到 EOF 时以 nil 错误返回。大文件或需要逐行隔离坏记录时,优先使用 Read。

一句话总结:FieldsPerRecord 决定的是 CSV 读取器对“字段数量”的态度,不负责替你定义缺列和多列的业务含义。先确定数据契约,再在正数、0 和负数之间选择,错误处理才不会变成隐性数据丢失。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>