Миграции, sqlc, squirrel: типизированный SQL без ORM
Урок-конспект про то, как версионировать схему Postgres и писать к ней
SQL так, чтобы Go-код оставался типизированным, читаемым и без магии
ORM. Считаем, что 08-persistence-and-sql
уже пройден: знаешь database/sql, pgx, как открывается пул, как
закрывается контекст.
Главные тезисы:
- SQL пишем руками, не доверяем ORM-у.
- Схему версионируем через
gooseилиgolang-migrate. - Из SQL генерим типизированный Go-код через
sqlc. ORM не нужен. - Динамические запросы строим через
squirrel, не черезfmt.Sprintf.
1. Зачем миграции и почему не “ORM-автомиграция”
Миграция, версионированный SQL-скрипт, который меняет схему. Запускается один раз, имеет уникальный номер, имеет обратную команду. В репозитории живут вместе с кодом, в продакшн прокатываются вместе с релизом.
Альтернатива из мира Java/JS, “ORM-автомиграция” (spring.jpa.hibernate.ddl-auto=update,
prisma migrate dev), в Go считается дурным тоном. Причины:
- ORM не знает про индексы, partial indexes, partitioning, generated columns. Любая нетривиальная схема выпадает.
- В продакшне нельзя “пусть schema подгонит под структуру”. Нужен явный план, явный порядок, явный rollback.
- ORM-миграции непрозрачны: непонятно, какой именно SQL пойдёт в БД.
В Go идиома, разработчик пишет SQL, ORM не используется. См. обсуждение почему не gorm в секции 6.
2. goose: миграции для прагматиков
goose ставится одной командой:
go install github.com/pressly/goose/v3/cmd/goose@latest
Создаём миграцию:
goose -dir db/migrations create create_users sql
# пишет файл: db/migrations/20260505123456_create_users.sql
Содержимое:
-- +goose Up
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- +goose Down
DROP TABLE users;
Применяем:
goose -dir db/migrations postgres "$DSN" up
goose -dir db/migrations postgres "$DSN" status
# Applied At Migration
# 2026-05-05 12:34:56 20260505123456_create_users.sql
Откат на одну версию назад, down. Откат полностью, reset.
Перепрогон последней, redo. Имя файла из timestamp плюс slug, поэтому
конфликтов между PR-ами обычно нет.
Goose умеет ещё и embed через goose.SetBaseFS, чтобы миграции
прокатывались самим бинарём при старте:
//go:embed db/migrations/*.sql
var migrations embed.FS
func migrate(ctx context.Context, db *sql.DB) error {
goose.SetBaseFS(migrations)
if err := goose.SetDialect("postgres"); err != nil {
return err
}
return goose.UpContext(ctx, db, "db/migrations")
}
Это удобно для маленьких сервисов: один бинарь, миграции внутри. Но
для команд из 3+ человек безопаснее держать отдельный goose up шаг
в CI или deploy-pipeline, иначе один из инстансов параллельно запустит
миграцию, поломает.
3. golang-migrate: минималистичная альтернатива
golang-migrate/migrate
делает то же, иначе. Файлы парами, NNN_name.up.sql и NNN_name.down.sql,
номера последовательные (1, 2, 3 …), а не timestamp:
db/migrations/
├── 000001_create_users.up.sql
├── 000001_create_users.down.sql
├── 000002_add_email_verified.up.sql
└── 000002_add_email_verified.down.sql
CLI:
brew install golang-migrate
migrate -path db/migrations -database "$DSN" up
migrate -path db/migrations -database "$DSN" down 1
migrate -path db/migrations -database "$DSN" version
| goose | golang-migrate | |
|---|---|---|
| Имя файла | <ts>_<name>.sql | <NNN>_<name>.up.sql плюс <NNN>_<name>.down.sql |
| Один или два файла | один на миграцию (Up/Down) | два на миграцию |
| Embed в бинарь | goose.SetBaseFS | iofs.New(...) |
| CLI | один бинарь | один бинарь |
| Поддержка драйверов | postgres, sqlite, mysql, mssql, clickhouse | те же плюс ещё |
| Нумерация | timestamp | sequential |
| Стиль | “rails-like” | минималистичный |
Что выбирать в pet-проекте: голова болит одинаково. Я обычно беру
goose за один файл и timestamp-нумерацию (меньше merge-конфликтов).
В команде на 5+ людей golang-migrate чище в плане единого формата.
4. sqlc: SQL внутри, Go снаружи
sqlc это codegen: ты пишешь SQL в файле,
sqlc разбирает запросы, проверяет их против схемы, генерирует
Go-функции.
go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest
Минимальный конфиг sqlc.yaml:
version: '2'
sql:
- engine: postgresql
queries: db/query.sql
schema: db/migrations
gen:
go:
package: 'dbgen'
out: 'internal/dbgen'
sql_package: 'pgx/v5'
db/query.sql:
-- name: GetUserByID :one
SELECT id, email, created_at FROM users WHERE id = $1;
-- name: SaveUser :one
INSERT INTO users (email)
VALUES ($1)
RETURNING id, email, created_at;
-- name: ListUsersByEmail :many
SELECT id, email, created_at
FROM users
WHERE email LIKE $1
ORDER BY id;
-- name: UpdateUserEmail :exec
UPDATE users SET email = $2 WHERE id = $1;
-- name: DeleteUser :exec
DELETE FROM users WHERE id = $1;
После sqlc generate появляется пакет internal/dbgen с типами:
// internal/dbgen/users.sql.go (сгенерирован)
type User struct {
ID int64
Email string
CreatedAt time.Time
}
type Queries struct { db DBTX }
func New(db DBTX) *Queries { /* ... */ }
func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) { /* ... */ }
func (q *Queries) SaveUser(ctx context.Context, email string) (User, error) { /* ... */ }
func (q *Queries) ListUsersByEmail(ctx context.Context, email string) ([]User, error) { /* ... */ }
func (q *Queries) UpdateUserEmail(ctx context.Context, arg UpdateUserEmailParams) error { /* ... */ }
func (q *Queries) DeleteUser(ctx context.Context, id int64) error { /* ... */ }
Что важно:
- Аннотации
:one,:many,:exec,:execrows,:execresultсообщают sqlc, какой результат ждать. - Параметры запроса (
$1,$2) автоматически становятся параметрами Go-функции. Если 3+ параметра, sqlc создаётXxxParamsструктуру. RETURNINGвINSERTнужен, чтобы получить id и created_at одной командой (Postgres-фишка). БезRETURNINGпришлось бы делать два запроса.- Схема sqlc читает из миграций (
schema: db/migrations), поэтому при обновлении миграции запросы перепроверяются на типы.
Использование в коде:
// internal/user/pg.go
type pgRepo struct{ q *dbgen.Queries }
func NewPGRepo(pool *pgxpool.Pool) *pgRepo {
return &pgRepo{q: dbgen.New(pool)}
}
func (r *pgRepo) Save(ctx context.Context, u *user.User) error {
rec, err := r.q.SaveUser(ctx, u.Email)
if err != nil {
return fmt.Errorf("save user: %w", err)
}
u.ID = rec.ID
u.CreatedAt = rec.CreatedAt
return nil
}
Идиома: бизнес-логика живёт в internal/user, sqlc-сгенерированный
пакет в internal/dbgen. Доменный тип user.User в репо превращается
в dbgen.User адаптером. Это чуть-чуть mapping-кода, но это лучше,
чем втащить в домен типы, привязанные к схеме БД.
премиум-урок
Продолжение в премиуме: 1 200 ₽ в месяц или 10 000 ₽ в год. Вместе с ним серверные проверки задач и школьные токены AI-коуча.
Оплата пока не подключена: премиум и курсы открываются промокодом.