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

Go sql.NullString 为什么 JSON 输出不是 null

来源:17golang原创

时间:2026-10-26 19:40:29 237浏览 收藏

在接口联调时,数据库里的昵称明明是 NULL,Go 返回的 JSON 却变成了 {"String":"","Valid":false},这不是数据库驱动把空值改坏了,而是两个层次的“空”没有被混在一起。sql.NullString 负责承载 SQL NULL,encoding/json 负责编码 Go 结构体;默认情况下,后者不会把一个结构体自动变成 JSON null。

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

要点速览
  • Scan 遇到数据库 NULL 时,会把 sql.NullString.Valid 设为 false,而不是让 sql.NullString 变成 nil。
  • NullString 的 String 和 Valid 是导出字段,encoding/json 会按结构体字段输出,所以结果是对象。
  • 要输出真正的 JSON null,在接口 DTO 层使用 *string,或给包装类型实现 MarshalJSON。

先看 NULL 是怎样进入 NullString 的

sql.NullString 不是“可为空的字符串指针”,而是一个带状态位的值类型。官方定义包含 String string 和 Valid bool:当数据库列有字符串时,Valid 为 true;当列值是 SQL NULL 时,Valid 为 false,此时不能只看 String 判断业务含义。

var nickname sql.NullString
err := row.Scan(&nickname)
if err != nil {
    // Scan 失败时保留原始错误,避免把数据库错误误判为空值。
    return fmt.Errorf("scan nickname: %w", err)
}

if nickname.Valid {
    // Valid 为 true 时,String 才代表数据库返回的字符串。
    fmt.Println("昵称:", nickname.String)
} else {
    // Valid 为 false 表示 SQL NULL,不等同于空字符串。
    fmt.Println("昵称未设置")
}

所以,数据库读取层需要同时保留“值”和“是否存在”两个信息。把它直接放进 API 响应结构体,相当于把这两个内部字段也暴露给了 JSON 层。

数据库 NULL、Rows.Scan、sql.NullString 的 String 和 Valid 字段以及 JSON 编码边界的静态关系说明图
图1:结构说明图,展示数据库 NULL 到 sql.NullString 字段的静态映射,不是运行截图。

为什么 JSON 输出是对象而不是 null

问题出在 encoding/json 的职责边界:它看到的是一个普通结构体,而不是一个实现了“空值序列化”协议的特殊对象。sql.NullString 没有为 JSON 实现 MarshalJSON,因此两个导出字段都会参与编码。

type User struct {
    Nickname sql.NullString `json:"nickname"`
}

func encodeUser() ([]byte, error) {
    // Valid=false 只表达 SQL NULL 的状态,不会改变 Go 类型本身。
    user := User{Nickname: sql.NullString{String: "", Valid: false}}
    data, err := json.Marshal(user)
    if err != nil {
        // 返回编码错误,调用方可以决定是否终止响应。
        return nil, fmt.Errorf("marshal user: %w", err)
    }
    return data, nil
}

// 结果形态:{"nickname":{"String":"","Valid":false}}

这也是为什么给字段加上 omitempty 往往不能解决问题:字段的实际类型仍然是结构体,JSON 编码器不会根据其中的 Valid 字段猜测你的 API 语义。SQL NULL、Go 结构体和 JSON null 是三个不同层次的表示。

encoding/json 将 String 和 Valid 编码为 JSON 对象并与 *string、MarshalJSON 输出 JSON null 的静态关系说明图
图2:表示边界说明图,对比结构体对象、*string 与 JSON null 的静态关系,不是运行结果截图。

在接口边界转换成真正的 null

更适合长期维护的做法,是让数据库层继续使用 sql.NullString,在 API DTO 层明确决定对外表示。最简单的是转换成指针:非 NULL 时给出字符串指针,NULL 时保留 nil。

type UserResponse struct {
    Nickname *string `json:"nickname"`
}

func toUserResponse(user User) UserResponse {
    if !user.Nickname.Valid {
        // nil 会被 encoding/json 编码为 JSON null。
        return UserResponse{Nickname: nil}
    }

    value := user.Nickname.String
    // 取局部值的地址,避免把数据库承载类型直接泄漏到响应模型。
    return UserResponse{Nickname: &value}
}

// NULL 结果:{"nickname":null}
// 有值结果:{"nickname":"Ada"}

指针方案把 API 的语义写进了类型:nil 是没有值,非 nil 是有值。若项目已经大量使用 sql.NullString,也可以只为响应层定义一个包装类型:

type JSONNullString struct {
    sql.NullString
}

func (n JSONNullString) MarshalJSON() ([]byte, error) {
    if !n.Valid {
        // 明确返回 JSON 字面量 null,而不是带字段的对象。
        return []byte("null"), nil
    }
    // 有值时仍按 JSON 字符串规则处理引号和转义。
    return json.Marshal(n.String)
}

自定义类型适合需要统一复用的响应模型,但它也把序列化策略绑定到了这个类型上。不要为了“看起来像 null”直接把 SQL NULL 转成空字符串,否则客户端无法区分“用户没有填写”和“用户填写了空文本”。

数据库层与接口层怎样分工

场景推荐表示原因
扫描数据库列sql.NullString同时保留字符串值与 NULL 状态
接口允许 null*string 或自定义 MarshalJSON把对外 JSON 语义写清楚
接口约定空字符串显式转换为 string确认客户端确实不需要区分 NULL
兼容旧客户端保留旧字段,新增明确的可空字段避免突然改变响应形状

结论可以压缩成一句话:sql.NullString.Valid=false 表示数据库层的 NULL 状态,并不等于 Go 值本身是 nil;默认 JSON 编码看到的是结构体,所以输出对象。只有在响应边界显式使用 *string 或实现 MarshalJSON,才能稳定输出 JSON null。

常见问题

把 sql.NullString.String 直接返回可以吗?

可以,但会把 NULL 和空字符串都变成同一个空文本。只有业务明确接受这种信息丢失时才这样做。

给 NullString 字段加 json:",omitempty" 能输出 null 吗?

不能把它当作可靠方案。字段仍是结构体,encoding/json 不会读取 Valid 来替你改变编码类型;需要 null 时应在 DTO 层转换。

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