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

Go database/sql NullTime 如何区分数据库 NULL 与零值并安全返回 JSON

来源:17golang原创

时间:2026-08-28 00:43:19 336浏览 收藏

订单表里的 shipped_at 允许为空时,直接把它扫描到 time.Time,很快就会遇到“未发货”和“时间是零值”混在一起的问题。Go 的 database/sql.NullTime 正好把这两个状态拆开:Valid=false 表示数据库值是 NULLValid=true 时才读取 Time

判断是否缺失看 Valid,不要拿 Time.IsZero() 代替;对外返回 JSON 时,再把无效值转换为 null

要点速览

  • NullTimeTime 保存时间,用 Valid 保存 NULL 状态。
  • 扫描后先判断 Valid,有效时间才参与业务排序和格式化。
  • JSON 输出应明确区分 null 与 RFC3339 时间字符串。
  • 写回数据库时,Valid=false 才代表把字段写成 NULL。

先把 NULL 和零时间分成两条路径

time.Time{} 是 Go 的零值,它仍然是一个 Go 值;数据库 NULL 则表示没有值。两者都可能在日志里看起来像“没有时间”,但业务含义并不相同:一条订单可能尚未发货,另一条记录也可能真的存了一个历史上的零时间。

sql.NullTime 的最小结构可以理解为两个槽位:

type NullTime struct {
    Time  time.Time
    Valid bool
}

扫描完成后,Valid 是第一判断条件。只在它为 true 时访问 Time,这样业务代码不会因为格式化零时间而制造一条看似合法的日期。

从数据库扫描到接口字段

下面用一个订单查询展示完整数据路径。假设表字段允许为空:

SELECT id, shipped_at
FROM orders
WHERE id = ?;

查询层先把结果接到 sql.NullTime,再转换为接口使用的字段。MarshalJSON 负责把有效时间输出为字符串,把无效时间输出为 JSON 的 null

package order

import (
    "database/sql"
    "encoding/json"
    "time"
)

type NullableTime struct {
    sql.NullTime
}

func (n NullableTime) MarshalJSON() ([]byte, error) {
    if !n.Valid {
        return []byte("null"), nil
    }
    return json.Marshal(n.Time.Format(time.RFC3339))
}

type OrderView struct {
    ID        int          `json:"id"`
    ShippedAt NullableTime `json:"shipped_at"`
}

func LoadOrder(row *sql.Row) (OrderView, error) {
    var view OrderView
    if err := row.Scan(&view.ID, &view.ShippedAt.NullTime); err != nil {
        return OrderView{}, err
    }
    return view, nil
}
Scan 将 orders.shipped_at 送入 Valid 和 Time,再由 MarshalJSON 输出 null 或 RFC3339 时间

这条链路的关键不是自定义类型本身,而是状态顺序:Scan 先填充 ValidTimeMarshalJSON 再根据 Valid 选择输出分支。有效记录才会调用时间格式化。

JSON 的两个输出结果要保持稳定

数据库值为 NULL

此时 Valid=false,接口返回:

{"id":1001,"shipped_at":null}

客户端可以据此显示“未发货”或保留空状态。不要把它格式化成 0001-01-01T00:00:00Z,那会让调用方误以为系统记录过这个时刻。

数据库值不为 NULL

此时 Valid=true,接口返回 RFC3339 字符串,例如:

{"id":1002,"shipped_at":"2026-08-28T09:30:00+08:00"}

格式由接口契约决定。只要写入和读取都遵守同一时区约定,前端就不需要猜测这个时间来自哪里。

写回数据库时不要只传 Time

NullTime 还实现了 driver.Valuer。写回时应把整个值交给数据库层:

var shipped sql.NullTime
if shouldClearShipment {
    shipped.Valid = false
} else {
    shipped.Time = shippedAt
    shipped.Valid = true
}

_, err := db.ExecContext(ctx,
    "UPDATE orders SET shipped_at = ? WHERE id = ?",
    shipped, orderID,
)
Valid=false 经过 Value 写回 NULL,Valid=true 经过 Value 写回 Time

Valid=false 时,Value 返回数据库 NULL;当 Valid=true 时,才返回内部的 Time。因此“清空发货时间”和“把发货时间改成零时间”是两种不同操作,调用方必须先决定业务语义。

一段小测试能守住这条边界

func TestNullableTimeJSON(t *testing.T) {
    empty, err := json.Marshal(NullableTime{NullTime: sql.NullTime{Valid: false}})
    if err != nil || string(empty) != "null" {
        t.Fatalf("empty time = %s, err = %v", empty, err)
    }

    got := time.Date(2026, 8, 28, 9, 30, 0, 0, time.FixedZone("CST", 8*60*60))
    full, err := json.Marshal(NullableTime{NullTime: sql.NullTime{Time: got, Valid: true}})
    if err != nil || string(full) != `"2026-08-28T09:30:00+08:00"` {
        t.Fatalf("full time = %s, err = %v", full, err)
    }
}

测试同时覆盖缺失分支和有效分支。若后续把时间格式改成毫秒精度,先更新接口约定,再同步修改断言,避免数据库迁移和 API 行为各自变化。

常见问题:NULL 时间怎么处理

为什么不能只检查 Time.IsZero?

因为 NULL 和一个有效但恰好等于零时间的值都可能让 IsZero 返回相同结果。真正的 NULL 状态由 Valid 记录。

可以直接把 NullTime 暴露给 JSON 吗?

不建议把数据库层类型直接当成 API 契约。用 NullableTime 或专门的响应字段明确输出格式,更容易控制 null 和时间字符串的兼容性。

查询结果为空和字段为 NULL 是一回事吗?

不是。没有查询行通常由 sql.ErrNoRows 表示;查询到了行但字段为 NULL,才是 NullTime.Valid=false

落地前的速查清单

  • 字段允许 NULL 时,扫描目标使用 sql.NullTime
  • 业务判断先看 Valid,不要先看 Time.IsZero()
  • JSON 用 null 表示缺失,用固定格式表示有效时间。
  • 更新语句传入完整的 NullTime,让 Value 保留 NULL 语义。
  • 用测试锁定两条输出分支,并单独覆盖 sql.ErrNoRows
声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>