起因
Go 写数据库代码两条路:
- 用 ORM(GORM):自动 query 但隐藏 SQL;复杂 join 难表达
- 手写 sql.DB:每条 query 自己写、scanner 自己 typed、容易写错
sqlc 是另一条路:写纯 SQL,工具生成完全类型化的 Go 函数。
两者优点都有:SQL 完全可控 + Go 调用类型安全。
解决方案
1. 装
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
# 或 brew install sqlc / Docker
2. 配 sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "db/query.sql"
schema: "db/schema.sql"
gen:
go:
package: "db"
out: "internal/db"
sql_package: "pgx/v5" # 或 "database/sql"
3. 写 schema.sql
-- db/schema.sql
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
nickname TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
body TEXT NOT NULL,
published_at TIMESTAMPTZ
);
4. 写 query.sql
-- db/query.sql
-- name: GetUser :one
SELECT * FROM users WHERE id = $1;
-- name: GetUserByEmail :one
SELECT * FROM users WHERE email = $1;
-- name: ListUsers :many
SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2;
-- name: CreateUser :one
INSERT INTO users (email, nickname)
VALUES ($1, $2)
RETURNING *;
-- name: UpdateUserNickname :exec
UPDATE users SET nickname = $2 WHERE id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
-- name: ListUserPosts :many
SELECT p.*, u.nickname AS author_nickname
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1
ORDER BY p.published_at DESC
LIMIT $2 OFFSET $3;
-- name: CountPostsByUser :one
SELECT count(*) FROM posts WHERE user_id = $1;
-- name: X :one|many|exec|execrows 告诉 sqlc 生成什么类型函数:
:one返回单行:many返回多行:exec不返回(INSERT/UPDATE/DELETE):execrows返回影响行数
5. 生成
sqlc generate
internal/db/ 下生成:
internal/db/
├── db.go # 接口 + Queries struct
├── models.go # 表 → Go struct
└── query.sql.go # 每个 query → Go 函数
models.go:
type User struct {
ID int64
Email string
Nickname string
CreatedAt time.Time
}
type Post struct {
ID int64
UserID int64
Title string
Body string
PublishedAt pgtype.Timestamptz
}
query.sql.go:
const getUser = `-- name: GetUser :one
SELECT id, email, nickname, created_at FROM users WHERE id = $1
`
func (q *Queries) GetUser(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRow(ctx, getUser, id)
var u User
err := row.Scan(&u.ID, &u.Email, &u.Nickname, &u.CreatedAt)
return u, err
}
类型完全推断自 schema。
6. 用
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"myapp/internal/db"
)
func main() {
ctx := context.Background()
pool, _ := pgxpool.New(ctx, "postgresql://localhost/myapp")
defer pool.Close()
queries := db.New(pool)
// 创建
user, err := queries.CreateUser(ctx, db.CreateUserParams{
Email: "[email protected]",
Nickname: "Alice",
})
// 查
u, _ := queries.GetUserByEmail(ctx, "[email protected]")
// 列表
posts, _ := queries.ListUserPosts(ctx, db.ListUserPostsParams{
UserID: u.ID,
Limit: 20,
Offset: 0,
})
for _, p := range posts {
fmt.Println(p.Title, p.AuthorNickname)
}
}
写错列名 / 类型 / 参数 → 编译报错。重构 schema 后 sqlc generate
所有不兼容的 query 调用都立刻被编译器抓出。
7. JOIN 出来的"虚拟表"自动生成 struct
-- name: ListUserPosts :many
SELECT p.*, u.nickname AS author_nickname
FROM posts p
JOIN users u ON u.id = p.user_id
WHERE p.user_id = $1;
生成:
type ListUserPostsRow struct {
ID int64
UserID int64
Title string
Body string
PublishedAt pgtype.Timestamptz
AuthorNickname string
}
func (q *Queries) ListUserPosts(...) ([]ListUserPostsRow, error) { ... }
新结构体自动产生,不需要自己定义 DTO。
8. transaction
tx, err := pool.Begin(ctx)
defer tx.Rollback(ctx)
qtx := queries.WithTx(tx)
user, err := qtx.CreateUser(ctx, ...)
_, err = qtx.CreateProfile(ctx, ...)
return tx.Commit(ctx)
WithTx(tx) 返回绑定到事务的 queries 实例。所有调用都在同事务。
9. 跑迁移
sqlc 不管迁移。配合 goose /
golang-migrate /
atlas:
goose -dir migrations postgres "postgresql://..." up
migrations/0001_init.sql:
-- +goose Up
CREATE TABLE users (...);
-- +goose Down
DROP TABLE users;
sqlc 的 schema.sql 通常是迁移结果的"快照"(开发时方便)。
生产以 migration 序列为准。
10. 动态 query
sqlc 主要服务于"静态 SQL"。动态 WHERE / ORDER BY 灵活性差。
解决:
- 简单分页 / 排序:参数化
LIMIT $1 OFFSET $2 - 可选 filter:
WHERE (@email::text IS NULL OR email = @email) - 真正动态:用 squirrel / goqu 等 query builder,sqlc 处理静态部分
与 GORM / ent 对比
| GORM | ent | sqlc | |
|---|---|---|---|
| 哲学 | ORM 全自动 | schema-first ORM | 写 SQL,生成 type-safe 代码 |
| 学习曲线 | 低 | 中 | 极低(你已经会 SQL) |
| 性能 | 中(reflection) | 高 | 高(直接 SQL) |
| 复杂 JOIN | 难表达 | 好 | 自然写 SQL |
| 类型安全 | 弱 | 强 | 强 |
| migration | 内置 | 内置 | 需配合工具 |
我的取向:业务大量 SQL → sqlc;CRUD 简单 → GORM 也行;schema 变化
频繁 + 业务复杂 → ent。
效果
我们 5 万行 Go 代码用 sqlc 替代手写 sql.DB.Query:
- DB 相关 bug 减少 80%(编译期捕获)
- 重构 schema 时编译器告诉所有要改的地方
- 团队新人 onboarding:会 SQL 就能写,不需要学 ORM 语法
- 性能比 GORM 快 ~30%(无 reflection / 直接 prepared statement)
踩过的坑
-
nullable 列变 pgtype:column
NULL在 Go 是pgtype.Text/
pgtype.Int4等,不是 string / int。
String.Valid字段判 null。
SET sqlc.go.emit_pointers_for_null_types = true改用*string等。 -
改 schema 后忘 sqlc generate:编译失败但不知道为啥。
把sqlc generate加进go generate+ Makefile / justfile。 -
复杂 query plan 看不见:写了个 SQL 跑很慢,没人在 ORM 层
优化。EXPLAIN ANALYZE是基本功 —— ORM 怎么也帮不上忙。 -
time.Time vs pgtype.Timestamptz:默认 timestamptz 列生成
pgtype.Timestamptz。Time字段拿值,Valid判 null。
设emit_exact_table_names = true让生成名跟列对应。 -
JOIN 列名冲突:两表都有
id列 → 生成 struct 字段冲突。
SQL 里手动 alias:SELECT p.id AS post_id, u.id AS user_id ...。
登录后参与评论。