database/sql Null 类型扫描到业务结构体的转换
来源:17golang原创
时间:2026-10-10 19:28:16 481浏览 收藏
如果数据库列允许 NULL,把查询结果直接扫描到普通的 string、int64 或 time.Time 字段,常见结果是 Scan 报错,或者业务代码把“没有值”和“值为零”混在一起。稳定的做法是:先在数据库访问边界使用 sql.NullString、sql.NullInt64 等可空类型,读取它们的 Valid 字段,再转换成业务结构体需要的指针、可选值或明确默认值。
- 数据库驱动把 SQL
NULL传给Scanner时,对应的是nil;普通值类型不能表达这个状态。 sql.Null*用“值字段 +Valid”保留空值语义;支持 Go 1.22 及以上时也可以使用sql.Null[T]。- 推荐把 Null 类型限制在持久化 DTO 中,再显式映射到业务结构体,避免数据库适配细节渗透到业务规则。
先还原 NULL 扫描失败的现场
database/sql.Scanner 接收的源值包含整数、浮点数、布尔值、字节切片、字符串、时间以及表示 SQL NULL 的 nil。当目标是普通的 string 或 int64 时,它们没有“缺失”这个额外状态,因此 Scan 无法在不丢失信息的前提下完成转换。
type User struct {
ID int64
Nickname string
}
var user User
err := db.QueryRowContext(ctx, `SELECT id, nickname FROM users WHERE id = ?`, id).Scan(&user.ID, &user.Nickname)
if err != nil {
// nickname 为 NULL 时,普通 string 无法承载缺失状态,错误会在 Scan 处返回。
return fmt.Errorf("读取用户失败: %w", err)
}
这里的问题不是 SQL 语句一定错误,而是查询列的可空性与 Go 目标字段的表达能力不匹配。更重要的是,直接把 NULL 改成空字符串也会丢失信息:空字符串可能是用户主动保存的值,而 NULL 可能代表用户从未填写。

在扫描边界使用 sql.Null 类型
database/sql 提供了一组实现 Scanner 和 driver.Valuer 的类型。以字符串为例,sql.NullString.String 保存实际值,sql.NullString.Valid 表示该值是否来自非 NULL 数据。数字类型的规则相同,例如 sql.NullInt64.Int64 和 sql.NullInt64.Valid。
type userRow struct {
ID int64
Nickname sql.NullString
Age sql.NullInt64
}
var row userRow
err := db.QueryRowContext(ctx, `
SELECT id, nickname, age
FROM users
WHERE id = ?
`, id).Scan(&row.ID, &row.Nickname, &row.Age)
if err != nil {
// 查询失败和 NULL 本身是两件事;先处理查询错误,再读取 Valid。
return fmt.Errorf("扫描用户行失败: %w", err)
}
if row.Nickname.Valid {
log.Printf("昵称=%q", row.Nickname.String)
}
if !row.Age.Valid {
// Age 为 NULL,不要把它误报成 0 岁。
log.Println("用户没有填写年龄")
}
这个边界有两个好处:扫描阶段不会把 SQL 空值强行塞进普通字段,业务代码也能明确区分“字段没有值”和“字段值恰好为零”。如果使用 Rows 批量读取,仍然要在每一轮先检查 Scan,循环结束后再检查 rows.Err()。
把数据库 DTO 映射成业务结构体
持久化结构体可以接受 sql.Null*,但不建议让所有业务函数都知道 NullString.String 和 Valid。更清楚的分层方式是:查询得到一个数据库 DTO,随后在一个转换函数中把可空字段映射成业务层的指针或自定义可选类型。
type User struct {
ID int64
Nickname *string
Age *int64
}
func (r userRow) toUser() User {
user := User{ID: r.ID}
if r.Nickname.Valid {
// 只有 Valid 为 true 才把数据库值暴露给业务层。
nickname := r.Nickname.String
user.Nickname = &nickname
}
if r.Age.Valid {
// 指针让业务层继续区分 NULL 与数值 0。
age := r.Age.Int64
user.Age = &age
}
return user
}
映射之后,业务代码可以通过 user.Nickname == nil 判断缺失,也可以在接口层统一把 nil 序列化为 JSON null。如果业务定义明确规定缺失字段必须显示为默认文案,也应在业务层做这个决定,而不是在 Scan 时静默替换。

按工具链选择泛型 Null[T]
如果项目使用 Go 1.22 或更高版本,可以用 sql.Null[T] 表达可空值。它把值放在 V 字段中,把是否有效放在 Valid 字段中,减少为不同基础类型分别记忆字段名的成本。
type userRowV2 struct {
ID int64
Nickname sql.Null[string]
Age sql.Null[int64]
}
var row userRowV2
if err := db.QueryRowContext(ctx, `
SELECT id, nickname, age FROM users WHERE id = ?
`, id).Scan(&row.ID, &row.Nickname, &row.Age); err != nil {
// 泛型 Null 仍然需要在 Scan 阶段检查错误。
return fmt.Errorf("读取用户失败: %w", err)
}
if row.Nickname.Valid {
// V 是值字段;Valid 为 false 时不要读取 V 作为业务结论。
fmt.Println(row.Nickname.V)
}
sql.Null[T] 的 T 不是任意业务对象,而应当是驱动值可以支持的类型。旧工具链、需要兼容旧版本的公共库,或者团队已经统一使用 NullString 等类型时,继续使用具体类型也完全合理;选择的关键是保持项目工具链和数据边界的一致。
批量查询和写回时保持同一套语义
批量查询时,Null 类型的使用方式不会因为结果集变多而改变。每行先扫描到 DTO,再转换到业务对象;循环结束后检查 rows.Err(),这样可以把扫描错误和迭代错误区分开。
rows, err := db.QueryContext(ctx, `SELECT id, nickname FROM users WHERE state = ?`, "active")
if err != nil {
// QueryContext 尚未返回结果集时,直接处理建立阶段错误。
return err
}
defer rows.Close()
for rows.Next() {
var row userRow
if err := rows.Scan(&row.ID, &row.Nickname); err != nil {
// 当前行扫描失败时立即停止,避免把不完整数据交给业务层。
return fmt.Errorf("扫描用户列表失败: %w", err)
}
consumeUser(row.toUser())
}
if err := rows.Err(); err != nil {
// Next 返回 false 不一定是正常结束,迭代阶段错误在这里收口。
return fmt.Errorf("读取用户列表失败: %w", err)
}
return nil
写回数据库时,sql.NullString 等类型实现了 driver.Valuer。当 Valid 为 false 时,传给驱动的值会保持为空值语义;当 Valid 为 true 时,才写入内部值。
row := userRow{
ID: 1,
Nickname: sql.NullString{String: "阿岚", Valid: true},
}
_, err := db.ExecContext(ctx, `UPDATE users SET nickname = ? WHERE id = ?`, row.Nickname, row.ID)
if err != nil {
// Value 转换或执行阶段失败都要向上层返回,不能假设写回成功。
return fmt.Errorf("更新用户昵称失败: %w", err)
}
常见问题
为什么不直接把 NULL 当成空字符串?
因为空字符串是一个真实字符串值,而 NULL 表示没有值。两者在搜索、更新、接口展示和统计时可能有不同含义,应在业务规则明确之后再决定是否合并。
Valid 为 false 时,String 或 V 一定是空的吗?
不要依赖无效状态下的值字段。业务判断应当先看 Valid,只有为 true 时才读取 String、Int64 或 V。
业务结构体也可以直接使用 sql.NullString 吗?
可以,但会让业务层依赖数据库访问包。小型程序可以接受;如果项目需要清晰分层、多个存储实现或稳定的接口模型,使用 DTO 到业务结构体的显式映射更容易维护。
Rows.Scan 报错后还要检查 rows.Err 吗?
当前行的 Scan 错误应当立即处理;如果循环是因为 Next 返回 false 结束,仍然要检查 rows.Err(),确认不是迭代阶段错误。
总结
处理 database/sql 的 NULL,核心不是寻找一个默认值,而是先保留“有没有值”这个事实。使用 sql.Null* 或 sql.Null[T] 接住扫描结果,在 DTO 到业务结构体的转换处决定指针、默认值或其它领域表达,再用同一套规则处理批量读取和写回。这样既能避免 Scan 类型错误,也不会把 NULL 悄悄伪装成零值。
-
502 收藏
-
502 收藏
-
501 收藏
-
501 收藏
-
501 收藏
-
246 收藏
-
476 收藏
-
479 收藏
-
136 收藏
-
247 收藏
-
140 收藏
-
251 收藏
-
347 收藏
-
430 收藏
-
464 收藏
-
494 收藏
-
108 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习