Table of contents
Open Table of contents
TL;DR
sqlc 是一个编译器:输入 schema.sql(DDL)+ query.sql(带注解的 DML),输出类型安全的 Go 代码。不是 ORM,不是 query builder,是 SQL-first 的代码生成。
为什么用 sqlc,不用 ORM / query builder
| 维度 | ORM (GORM) | Query Builder (squirrel) | sqlc |
|---|---|---|---|
| SQL 控制力 | 低,框架生成 SQL | 中,Go 代码拼 SQL | 完全控制,你写原始 SQL |
| 类型安全 | 运行时才知道字段对不对 | 运行时 | 编译时,字段不存在直接报错 |
| 学习曲线 | 学框架 DSL | 学 builder API | 学 SQL 本身 |
| 性能可预测性 | 低,N+1、隐式 JOIN | 中 | 高,SQL 即所见即所得 |
| schema 变更感知 | 无,运行时 panic | 无 | 编译时报错 |
不用 sqlc 会怎样:你手写 rows.Scan(&a, &b, &c) 的参数数量、类型、顺序全靠人肉保证。加一列要改 N 处代码,漏了编译不报错,运行时才炸。
核心概念:三个文件的分工
schema.sql → DDL:CREATE TABLE / INDEX / ENUM(数据库结构)
query.sql → DML + 注解:SELECT / INSERT / UPDATE / DELETE(业务查询)
sqlc.yaml → 配置:引擎、输入输出路径、代码生成选项
sqlc 读入前两者,按第三者的配置,生成 .go 文件。生成的代码永远不要手动编辑,再次 sqlc generate 会覆盖。
sqlc.yaml 配置
最小可用配置:
version: "2"
sql:
- engine: "postgresql"
queries: "query.sql" # 可以是目录:"queries/"
schema: "schema.sql" # 可以是目录:"migrations/"
gen:
go:
package: "db" # 生成代码的 Go package 名
out: "internal/db" # 输出目录
sql_package: "pgx/v5" # 底层驱动:pgx/v5 | pgx/v4 | database/sql
生产环境常用选项:
version: "2"
sql:
- engine: "postgresql"
queries: "queries/"
schema: "migrations/"
gen:
go:
package: "db"
out: "internal/db"
sql_package: "pgx/v5"
emit_json_tags: true # struct 加 json tag
emit_empty_slices: true # :many 返回空 slice 而非 nil
emit_interface: true # 生成 Querier interface(方便 mock)
query_parameter_limit: 3 # 超过 N 个参数自动生成 Params struct
overrides:
- db_type: "timestamptz"
go_type: "time.Time"
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
关键配置说明:
| 配置项 | 作用 | 建议 |
|---|---|---|
sql_package | 底层 SQL 驱动 | 新项目用 pgx/v5,性能好、功能全 |
emit_interface | 生成 Querier interface | 开启,方便单元测试 mock |
emit_empty_slices | :many 返回 []T{} 而非 nil | 开启,避免 JSON 序列化出 null |
query_parameter_limit | 参数超过 N 个自动打包成 struct | 设 1-3,减少函数签名长度 |
overrides | 数据库类型 → Go 类型映射 | uuid、timestamptz、jsonb 必配 |
Schema 怎么写
Schema 就是标准的 PostgreSQL DDL。sqlc 用它来理解你的表结构和类型。
-- schema.sql
-- 枚举类型:sqlc 会生成对应的 Go string 类型
CREATE TYPE user_role AS ENUM ('admin', 'member', 'guest');
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role user_role NOT NULL DEFAULT 'member',
avatar_url TEXT, -- 可空字段 → Go 生成 *string 或 sql.NullString
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
author_id UUID NOT NULL REFERENCES users(id),
title TEXT NOT NULL,
content TEXT NOT NULL,
published BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_posts_author ON posts(author_id);
CREATE INDEX idx_posts_created ON posts(created_at DESC);
schema 的核心原则:
- 就是标准 SQL DDL — 不需要任何 sqlc 特有语法,你写的就是能直接在 psql 里执行的 SQL
- 可空性决定 Go 类型 —
NOT NULL的列生成值类型(string),可空列生成指针(*string)或pgtype类型 - 枚举自动映射 —
CREATE TYPE ... AS ENUM会生成 Go 的 string 常量 - 可以用迁移文件 —
schema: "migrations/"指向目录,sqlc 会按文件名排序解析所有.sql文件
Schema 来源:迁移工具兼容
sqlc 不管理迁移,只读 schema。你可以用任何迁移工具(golang-migrate、goose、atlas),把迁移目录指给 sqlc:
schema: "migrations/" # sqlc 会解析所有 .sql,提取 DDL
sqlc 能识别 goose / golang-migrate 的 -- +goose Up / -- +migrate Up 标记,只解析 Up 部分。
Query 怎么写
这是 sqlc 的核心。每个查询用注解告诉 sqlc:函数名叫什么、返回什么形状。
注解语法
-- name: <FunctionName> :<return_type>
返回类型一览
| 注解 | 含义 | 生成的 Go 返回值 | 典型用途 |
|---|---|---|---|
:one | 返回一行 | (Model, error) | SELECT ... WHERE id = $1 |
:many | 返回多行 | ([]Model, error) | SELECT ... WHERE ... |
:exec | 不返回数据 | error | DELETE, UPDATE(不需要返回值) |
:execrows | 返回影响行数 | (int64, error) | 需要知道改了几行 |
:execresult | 返回 sql.Result | (sql.Result, error) | 需要 LastInsertId 等 |
:copyfrom | COPY FROM 批量插入 | 特殊批量接口 | 大量数据导入(pgx only) |
:batchone | 批量查询,每组返回一行 | 批量接口 | pgx batch(pgx only) |
:batchmany | 批量查询,每组返回多行 | 批量接口 | pgx batch(pgx only) |
:batchexec | 批量执行 | 批量接口 | 批量删除/更新(pgx only) |
完整 Query 示例
-- queries/users.sql
-- name: GetUserByID :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, name, role)
VALUES ($1, $2, $3)
RETURNING *;
-- name: UpdateUserName :exec
UPDATE users
SET name = $2, updated_at = now()
WHERE id = $1;
-- name: DeleteUser :exec
DELETE FROM users
WHERE id = $1;
-- name: CountUsers :one
SELECT count(*) FROM users;
参数传递方式
位置参数($1, $2, $3)— 简单查询直接用:
-- name: CreateUser :one
INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *;
-- 生成:func (q *Queries) CreateUser(ctx context.Context, email string, name string) (User, error)
命名参数(sqlc.arg() 或 @)— 参数多或语义不明确时用:
-- name: UpdateUserProfile :exec
UPDATE users SET
name = @name,
avatar_url = @avatar_url,
updated_at = now()
WHERE id = @id;
-- 生成 UpdateUserProfileParams struct,字段名 = 你命名的名字
sqlc.arg('name') 和 @name 等价,@ 更简洁(但 MySQL 不支持 @ 语法)。
可空参数(sqlc.narg())— 强制参数为 nullable:
-- name: UpdateUserOptional :one
UPDATE users SET
name = coalesce(sqlc.narg('name'), name), -- 传 nil 则保持原值
avatar_url = coalesce(sqlc.narg('avatar_url'), avatar_url)
WHERE id = @id
RETURNING *;
-- 生成的 Params 中 Name 是 *string,AvatarUrl 是 *string
JOIN 查询与 sqlc.embed
普通 JOIN 返回平铺的 Row struct:
-- name: GetPostWithAuthor :many
SELECT p.*, u.name as author_name, u.email as author_email
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.published = true
ORDER BY p.created_at DESC;
-- 生成 GetPostWithAuthorRow struct,包含 Post 的所有字段 + AuthorName + AuthorEmail
用 sqlc.embed 复用已有 model struct:
-- name: GetPostWithAuthorEmbed :many
SELECT sqlc.embed(posts), sqlc.embed(users)
FROM posts
JOIN users ON users.id = posts.author_id
WHERE posts.published = true;
// 生成的返回类型
type GetPostWithAuthorEmbedRow struct {
Post Post // 复用已生成的 Post struct
User User // 复用已生成的 User struct
}
sqlc.embed 的价值:避免 JOIN 查询各自生成大量重复字段的 Row struct,直接嵌入已有 model。
IN 查询与 sqlc.slice
pgx 原生支持传 slice,直接写 ANY:
-- name: GetUsersByIDs :many
SELECT * FROM users
WHERE id = ANY($1::uuid[]);
如果用 database/sql 驱动(不支持 slice 参数),需要 sqlc.slice:
-- name: GetUsersByIDs :many
SELECT * FROM users
WHERE id IN (sqlc.slice('ids'));
-- sqlc 在运行时展开为正确数量的 $1, $2, $3...
批量插入::copyfrom
大量数据插入时,COPY FROM 比逐条 INSERT 快一个数量级:
-- name: BulkCreatePosts :copyfrom
INSERT INTO posts (author_id, title, content, published)
VALUES ($1, $2, $3, $4);
// 生成的接口
func (q *Queries) BulkCreatePosts(ctx context.Context, arg []BulkCreatePostsParams) (int64, error)
生成的代码结构
运行 sqlc generate 后,输出目录包含:
internal/db/
├── db.go # New() 构造函数、DBTX interface、Queries struct
├── models.go # 表 → Go struct 映射
├── users.sql.go # users 相关查询的 Go 方法
├── posts.sql.go # posts 相关查询的 Go 方法
└── querier.go # Querier interface(如果开启 emit_interface)
使用方式:
package main
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"yourproject/internal/db"
)
func main() {
ctx := context.Background()
pool, _ := pgxpool.New(ctx, "postgres://localhost:5432/mydb")
defer pool.Close()
queries := db.New(pool)
// 类型安全的调用
user, err := queries.CreateUser(ctx, db.CreateUserParams{
Email: "alice@example.com",
Name: "Alice",
Role: db.UserRoleMember,
})
users, err := queries.ListUsers(ctx, db.ListUsersParams{
Limit: 20,
Offset: 0,
})
}
事务支持
sqlc 生成的 New() 接受 DBTX interface,*pgxpool.Pool 和 pgx.Tx 都实现了它:
func transferFunds(ctx context.Context, pool *pgxpool.Pool, from, to uuid.UUID, amount int64) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
qtx := db.New(tx) // 用事务创建 Queries
// qtx 的所有操作都在同一个事务里
qtx.DeductBalance(ctx, ...)
qtx.AddBalance(ctx, ...)
return tx.Commit(ctx)
}
文件组织最佳实践
db/
├── migrations/ # DDL 迁移文件(goose / golang-migrate)
│ ├── 001_users.sql
│ ├── 002_posts.sql
│ └── 003_add_index.sql
├── queries/ # 按领域拆分 query 文件
│ ├── users.sql
│ ├── posts.sql
│ └── comments.sql
├── sqlc.yaml
└── internal/db/ # 生成的代码(不要手动编辑)
├── db.go
├── models.go
├── users.sql.go
├── posts.sql.go
└── querier.go
拆分原则:
- Schema:用迁移工具管理,每个迁移文件一个变更
- Query:按领域实体拆分(users.sql、posts.sql),不要一个巨大的 query.sql
sqlc.yaml中用目录而非单文件:queries: "queries/",schema: "migrations/"
Pitfalls
1. SELECT * 的隐患
SELECT * 在 sqlc 里是安全的(编译时检查列),但加列后所有用 SELECT * 的查询都会重新生成 struct。如果你只需要部分字段,显式列出列名可以避免不必要的代码变更:
-- 明确列出需要的字段,减少变更影响面
-- name: ListUserNames :many
SELECT id, name FROM users ORDER BY name;
2. 可空列的类型陷阱
可空列默认生成 pgtype.Text(pgx)或 sql.NullString(database/sql),使用起来很啰嗦。用 overrides 或 emit_pointers_for_null_types 转成指针:
gen:
go:
emit_pointers_for_null_types: true # 可空列 → *string 而非 pgtype.Text
或者在 schema 设计阶段尽量 NOT NULL DEFAULT '',从根源避免。
3. sqlc.slice 不能用于预编译语句
sqlc.slice 在运行时动态生成 placeholder,所以不兼容 pgx 的 prepared statement 模式。如果用 pgx,优先用 ANY($1::type[]) 语法。
4. 迁移文件顺序错误导致编译失败
sqlc 按文件名排序解析 schema。如果 002_posts.sql 引用了 users 表但 001_users.sql 不存在,sqlc 会报错。迁移文件命名必须保证依赖顺序。
5. 忘记重新 generate
改了 schema 或 query 后忘记跑 sqlc generate,Go 代码和 SQL 不同步。解决方案:
- CI 里加
sqlc diff检查:生成代码是否最新 - 或用
sqlc vet做静态分析
# CI 中检查生成代码是否最新
sqlc diff
# 如果有差异,exit code 非零,CI 失败
6. 批量操作的驱动限制
:copyfrom、:batchone、:batchmany、:batchexec 只支持 pgx 驱动。如果用 database/sql,这些注解会报错。
与 gRPC/protobuf 工作流的类比
sqlc 和 protobuf 的设计哲学一脉相承:
| 维度 | protobuf | sqlc |
|---|---|---|
| 输入 | .proto 文件 | .sql 文件 |
| 编译器 | protoc / buf | sqlc |
| 输出 | 类型安全的 Go 代码 | 类型安全的 Go 代码 |
| 核心思想 | 接口契约即源代码 | SQL 即源代码 |
| 不要编辑 | *.pb.go | *.sql.go |
两者都是 schema-first / contract-first 的代码生成方案——用声明式语言定义契约,编译器保证类型安全。