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

Go database/sql Null[T] 怎么扫描可空字段

来源:17golang原创

时间:2026-09-27 17:15:55 156浏览 收藏

我第一次把用户资料从数据库读进 Go 结构体时,最容易忽略的不是 SQL,而是“没有值”和“值为零”并不是一回事。昵称为 SQL NULL,年龄为 0,空字符串,这三种状态在业务上可能完全不同。直接把可空列扫描到 string 或 int64,通常会在边界数据上暴露问题。

结论先说:Go 1.22 起可以用 sql.Null[T] 作为 Scan 目标。它用 V 保存值,用 Valid 表示当前列是否不是 SQL NULL;扫描时传指针,读取时先判断 Valid,写回时让它的 Value 保留空值语义。

官方文档:https://pkg.go.dev/database/sql

要点速览
  • sql.Null[T] 在 Go 1.22 加入,结构核心是 V 和 Valid。
  • Scan 的目标要传 &nullable,不能传值副本。
  • Valid=false 表示 SQL NULL,不代表 V 一定是业务默认值。

先把 NULL 当成需要保护的业务资产

可空字段的资产是“存在性信息”。例如会员表的 nickname 为 NULL 可能代表用户从未设置昵称,空字符串则可能代表用户主动清空;把二者都写成 "",后面的展示、补全和审计就失去了判断依据。

我会先做一个小映射,再写扫描代码:

数据库结果Go 目标读取规则
NULLsql.Null[string]Valid=false
非 NULL 文本sql.Null[string]Valid=true 后读取 V
可空整数sql.Null[int64]只让数值转换落在驱动支持范围内
可空时间sql.Null[time.Time]确认驱动返回的时间类型

这里的风险不是“程序马上崩溃”,而是默认值穿过权限、计费或展示逻辑后变成一条看似合理的错误数据。只有把 NULL 视为输入边界,后面的控制才有意义。

用 *sql.Null[T] 扫描可空字段

最小写法是在 Go 1.22 或更高版本中声明泛型 Null,并把它的地址交给 Scan。下面的代码以单行查询为例,重点是指针、错误处理和读取顺序。

package main

import (
    "database/sql"
    "fmt"
)

type User struct {
    ID       int64
    Nickname sql.Null[string]
    Age      sql.Null[int64]
}

func loadUser(db *sql.DB, id int64) (User, error) {
    var user User
    // Scan 需要可写的目标;传地址才能让 Null[T].Scan 更新 V 和 Valid。
    err := db.QueryRow("SELECT id, nickname, age FROM users WHERE id = ?", id).
        Scan(&user.ID, &user.Nickname, &user.Age)
    if err != nil {
        // ErrNoRows、驱动转换错误都要交给上层决定如何处理。
        return User{}, err
    }
    return user, nil
}

func displayNickname(user User) string {
    // Valid=false 时代表数据库是 NULL,不把 V 当成用户输入。
    if !user.Nickname.Valid {
        return "(未设置)"
    }
    return user.Nickname.V
}

func _() {
    _ = fmt.Sprint
}

Null[T] 实现了 sql.Scanner,所以 Rows.Scan 会把数据库驱动传来的值交给它处理。列为 NULL 时,Valid 变成 false;列有值时,V 才是可交给业务层的结果。这里不要写成 Scan(user.Nickname),那只是一个不可写的值副本。

用 Valid 切断零值误判和输入污染

可空类型的安全控制点只有一个:业务分支必须先看 Valid。不能因为 V 的零值“看起来能用”,就跳过存在性判断。

func ageLabel(age sql.Null[int64]) string {
    // NULL 不等于 0;先分流才能避免把未知年龄显示成零岁。
    if !age.Valid {
        return "年龄未填写"
    }
    if age.V 

还要留意泛型参数的边界。官方文档要求 T 选择驱动值支持的类型;不能把它当成任意结构体的通用反序列化器。如果驱动返回的值无法无损转换,Scan 应该返回错误,而不是猜一个默认值。

Go database/sql Null[T] 从数据库 NULL 或实际值进入 V 与 Valid 的分流结构说明图
图1:结构说明图,展示 SQL NULL 与非 NULL 进入 Null[T] 后由 Valid 分流,非截图或运行证据。

批量读取与写回时保留同一条边界

批量查询不能只检查每次 Scan。每行处理完后还要检查 rows.Err,否则网络或驱动在迭代末尾报告的错误可能被忽略。对大结果集,我通常把可空字段保持在数据访问层,离开这一层后再转换成明确的展示模型。

func listUsers(db *sql.DB) ([]User, error) {
    rows, err := db.Query("SELECT id, nickname, age FROM users ORDER BY id")
    if err != nil {
        return nil, err
    }
    defer rows.Close() // 及时归还连接,避免异常分支占住连接池。

    users := make([]User, 0)
    for rows.Next() {
        var user User
        // 每次循环都把地址传给 Scan,并立即处理本行转换错误。
        if err := rows.Scan(&user.ID, &user.Nickname, &user.Age); err != nil {
            return nil, err
        }
        users = append(users, user)
    }
    if err := rows.Err(); err != nil {
        // Next 返回 false 既可能是结束,也可能是迭代期间出错。
        return nil, err
    }
    return users, nil
}

func keepNullableValue(value sql.Null[string]) any {
    // Value 会把 Valid=false 还原为 SQL NULL,适合写回参数。
    return value
}

写回时,Null[T].Value()实现了 driver.Valuer,可以把无效值保留为 SQL NULL。若业务需求是“空字符串代表清空”,就显式构造一个 sql.Null[string]{V: "", Valid: true},不要依靠结构体零值碰运气。

检查项通过条件失败处理
版本Go 1.22+旧版本使用专用 Null 类型或自定义 Scanner
目标*sql.Null[T]修正为指针,避免扫描到副本
状态先判断 Valid不要用 V 的零值猜测 NULL
批量结果检查 Scan 与 rows.Err记录驱动或连接错误并回滚本行处理
Go Null[T] 在批量 Rows.Scan 和 Value 写回之间保持 NULL 语义的边界结构图
图2:结构说明图,展示批量扫描、Valid 判断与写回 Value 的边界关系,非截图或运行证据。

常见问题

sql.Null[T] 支持任意 Go 结构体吗?

不支持。T 应选择数据库驱动能提供和转换的值类型;复杂结构体需要单独实现 Scanner 与 Valuer。

Valid=false 时 V 还能读取吗?

可以读取语言层面的字段,但它不代表数据库真实存储的值。业务判断应把 Valid=false 当成 NULL 分支。

Go 1.21 项目能直接使用 Null[T] 吗?

不能直接使用标准库中的该类型。可以继续使用 sql.NullString、sql.NullInt64 等类型,或把自定义可空类型实现为 Scanner/Valuer,等升级到 Go 1.22 后再迁移。

对我来说,sql.Null[T]真正省下的是重复定义可空包装类型的成本,而不是替业务做决定。把 Valid 当作不可跳过的边界检查,再用错误处理和 rows.Err 收口,扫描可空字段就不会因为一个零值而悄悄改变含义。

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