Опирается на правила: R-SQLC-MIG-1, R-SQLC-MIG-2, R-SQLC-MIG-3, R-SQLC-MIG-4, R-SQLC-MIG-X1, R-SQLC-MIG-X2 из sqlc Style Guide → раздел 9. Миграции. Безопасность DDL-операций — по правилам PG-M-* из PostgreSQL: миграции.

Важно знать

  • Схема версионируется через файлы миграций в db/migrations/; никакого CREATE TABLE в коде приложения.
  • sqlc generate перечитывает схему из файлов миграций, не из живой БД — schema: "db/migrations" в sqlc.yaml.
  • golang-migrate и goose — оба допустимы; выбор фиксируется в начале проекта и не меняется.
  • Применение при старте через migrate.Up() — обязательно. Производственный деплой не требует ручного вмешательства.
  • Уже применённую миграцию не редактируют. Исправление — новой миграцией поверх.
  • CREATE INDEX CONCURRENTLY и lock_timeout — по правилам PG-M-*; миграция с долгим lock'ом блокирует трафик.
  • Паттерн expand-contract (добавить столбец → переключить код → удалить старый) применяется для обратно-несовместимых изменений.

Схема PostgreSQL — артефакт, который живёт отдельно от кода и отдельно от sqlc-сгенерированных запросов. Связь между ними — файлы в db/migrations/. Инструмент миграций читает их и приводит БД к нужной версии; sqlc читает их и генерирует типобезопасный Go-код. Это единый источник правды: одно дерево файлов описывает и схему, и типы.

Инструменты: golang-migrate и goose

R-SQLC-MIG-1 требует инструмент с версионированием файлов. Оба подходят:

golang-migrate — более строгая модель: каждый шаг состоит из пары <N>_<name>.up.sql и <N>_<name>.down.sql. Применение через CLI или через библиотеку в коде:

db/migrations/
├── 001_create_orders.up.sql
├── 001_create_orders.down.sql
├── 002_add_customer_index.up.sql
└── 002_add_customer_index.down.sql

goose — более гибкий: down-миграция необязательна, поддерживает Go-миграции для данных (seed), статусы хранятся в таблице goose_db_version:

db/migrations/
├── 20240101120000_create_orders.sql
├── 20240201090000_add_products.sql
└── 20240301150000_create_customers.sql

Файл goose содержит оба направления в одном:

-- +goose Up
CREATE TABLE orders (
    id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    customer_id UUID        NOT NULL,
    amount      NUMERIC(12,2) NOT NULL,
    status      TEXT        NOT NULL DEFAULT 'PENDING',
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- +goose Down
DROP TABLE orders;

Для нового сервиса golang-migrate предпочтительнее: строгое разделение up/down, нет зависимости от формата goose-аннотаций, хорошо работает в CI без дополнительной настройки.

sqlc.yaml: схема из файлов миграций

R-SQLC-MIG-2: в sqlc.yaml поле schema указывает на папку с миграциями. sqlc читает все *.sql-файлы в этой папке и восстанавливает финальное состояние схемы:

# sqlc.yaml
version: "2"
sql:
  - engine: "postgresql"
    queries: "db/queries"
    schema: "db/migrations"
    gen:
      go:
        package: "db"
        out: "db"
        emit_json_tags: true
        emit_pointers_for_null_fields: true
        overrides:
          - db_type: "uuid"
            go_type: "github.com/google/uuid.UUID"
          - db_type: "pg_catalog.numeric"
            go_type: "github.com/shopspring/decimal.Decimal"
          - db_type: "timestamptz"
            go_type: "time.Time"

После добавления новой миграции:

sqlc generate

Генератор перестроит db/models.go, db/querier.go и db/orders.sql.go, отражая изменения схемы. Если новая колонка nullable — появится поле *SomeType в параметрах запросов благодаря emit_pointers_for_null_fields: true.

Применение при старте: migrate.Up()

R-SQLC-MIG-4: миграция применяется при каждом запуске приложения через migrate.Up(). Это снимает вопрос «применена ли последняя миграция на этом окружении»:

// infrastructure/migrations.go
package infrastructure

import (
    "database/sql"
    "fmt"

    "github.com/golang-migrate/migrate/v4"
    "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
    _ "github.com/jackc/pgx/v5/stdlib"
)

func RunMigrations(dsn, migrationsPath string) error {
    db, err := sql.Open("pgx", dsn)
    if err != nil {
        return fmt.Errorf("open db for migrations: %w", err)
    }
    defer db.Close()

    driver, err := postgres.WithInstance(db, &postgres.Config{})
    if err != nil {
        return fmt.Errorf("create migration driver: %w", err)
    }

    m, err := migrate.NewWithDatabaseInstance(
        "file://"+migrationsPath,
        "postgres",
        driver,
    )
    if err != nil {
        return fmt.Errorf("init migrate: %w", err)
    }

    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        return fmt.Errorf("apply migrations: %w", err)
    }
    return nil
}

Вызов из точки старта — до инициализации pgxpool.Pool и db.Queries:

// main.go или bootstrap
func main() {
    cfg := config.Load()

    if err := infrastructure.RunMigrations(cfg.DSN, "db/migrations"); err != nil {
        log.Fatalf("migrations: %v", err)
    }

    pool, err := infrastructure.NewPool(cfg)
    if err != nil {
        log.Fatalf("pool: %v", err)
    }
    defer pool.Close()

    // ... остальная инициализация
}

migrate.ErrNoChange — не ошибка: миграции уже применены. Всё остальное — fatalf, поскольку без консистентной схемы работать нельзя.

Пример: добавить столбец к orders

Допустим, в сервис заказов Sber нужно добавить payment_method. Добавляем новую миграцию:

-- db/migrations/003_add_payment_method_to_orders.up.sql
ALTER TABLE orders ADD COLUMN payment_method TEXT;
-- db/migrations/003_add_payment_method_to_orders.down.sql
ALTER TABLE orders DROP COLUMN payment_method;

Запускаем sqlc generate — в db/models.go появится:

type Order struct {
    ID            uuid.UUID
    CustomerID    uuid.UUID
    Amount        decimal.Decimal
    Status        string
    PaymentMethod *string   // nullable — через emit_pointers_for_null_fields
    CreatedAt     time.Time
}

Новое поле сразу доступно в запросах. Обновляем маппер в репозитории:

// adapters/out/persistence/order_mapper.go
func toDomain(row db.Order) *order.Order {
    o := &order.Order{
        ID:         row.ID,
        CustomerID: row.CustomerID,
        Amount:     row.Amount,
        Status:     order.Status(row.Status),
        CreatedAt:  row.CreatedAt,
    }
    if row.PaymentMethod != nil {
        o.PaymentMethod = order.PaymentMethod(*row.PaymentMethod)
    }
    return o
}

Expand-contract для обратно-несовместимых изменений

R-SQLC-MIG-3 отсылает к PG-M-*: переименовать или удалить столбец нельзя за один шаг — это прерывает трафик при rolling-деплое.

Паттерн для переименования statusorder_status в таблице orders:

Миграция 1 (expand): добавить новый столбец, триггер синхронизации:

-- 004_expand_order_status.up.sql
ALTER TABLE orders ADD COLUMN order_status TEXT;
UPDATE orders SET order_status = status;

CREATE OR REPLACE FUNCTION sync_order_status()
RETURNS TRIGGER AS $$
BEGIN
    NEW.order_status := NEW.status;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_sync_order_status
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_order_status();

Шаг кода: переключить запросы sqlc на order_status, задеплоить.

Миграция 2 (contract): убрать старый столбец и триггер:

-- 005_contract_drop_status.up.sql
DROP TRIGGER trg_sync_order_status ON orders;
DROP FUNCTION sync_order_status();
ALTER TABLE orders DROP COLUMN status;

Индексы: CONCURRENTLY и lock_timeout

Миграция с CREATE INDEX без CONCURRENTLY берёт ShareLock на таблицу и блокирует записи на время построения. На таблицах с трафиком это недопустимо.

R-SQLC-MIG-3, PG-M-*:

-- 006_idx_orders_customer_id.up.sql
SET lock_timeout = '2s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_orders_customer_id
    ON orders (customer_id);

SET lock_timeout ограничивает время ожидания блокировки — если за 2 секунды таблицу не удалось захватить (идут тяжёлые транзакции), миграция падает с ошибкой, а не зависает. CONCURRENTLY строит индекс без удержания lock'а на записи.

Ограничение: golang-migrate не поддерживает CREATE INDEX CONCURRENTLY в транзакции. Нужно отключить транзакцию для этого шага:

// Через golang-migrate можно использовать WithInstance с DisableLock
// или вынести CONCURRENTLY-шаги в отдельный механизм (goose --no-tx)

Goose решает это проще — аннотация -- +goose NO TRANSACTION:

-- +goose Up
-- +goose NO TRANSACTION
SET lock_timeout = '2s';
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_products_category_id
    ON products (category_id);

-- +goose Down
DROP INDEX CONCURRENTLY IF EXISTS idx_products_category_id;

Изоляция в тестах

R-SQLC-TEST-2: каждый интеграционный тест репозитория запускает миграции на чистой БД. Через testcontainers-go:

// adapters/out/persistence/postgres_order_repository_test.go
var testPool *pgxpool.Pool

func TestMain(m *testing.M) {
    ctx := context.Background()

    container, err := postgres.Run(ctx, "postgres:16-alpine",
        postgres.WithDatabase("testdb"),
        postgres.WithUsername("test"),
        postgres.WithPassword("test"),
        testcontainers.WithWaitStrategy(
            wait.ForListeningPort("5432/tcp").WithStartupTimeout(30*time.Second),
        ),
    )
    if err != nil {
        log.Fatalf("start postgres: %v", err)
    }
    defer container.Terminate(ctx)

    dsn, _ := container.ConnectionString(ctx, "sslmode=disable")

    if err := infrastructure.RunMigrations(dsn, "../../../db/migrations"); err != nil {
        log.Fatalf("migrations: %v", err)
    }

    testPool, _ = pgxpool.New(ctx, dsn)
    defer testPool.Close()

    os.Exit(m.Run())
}

func TestPostgresOrderRepository_Save(t *testing.T) {
    ctx := context.Background()
    tx, _ := testPool.Begin(ctx)
    t.Cleanup(func() { tx.Rollback(ctx) })

    repo := NewPostgresOrderRepository(testPool).WithTx(tx)

    o := &order.Order{
        ID:         uuid.New(),
        CustomerID: uuid.New(),
        Amount:     decimal.NewFromFloat(1500.00),
        Status:     order.StatusPending,
        CreatedAt:  time.Now(),
    }
    err := repo.Save(ctx, o)
    require.NoError(t, err)

    found, err := repo.FindByID(ctx, o.ID)
    require.NoError(t, err)
    require.Equal(t, o.ID, found.ID)
}

Миграции применяются один раз в TestMain, контейнер переиспользуется всеми тестами. Изоляция данных — через tx.Rollback в t.Cleanup.

Что запрещено

АнтипаттернПравилоЧто взамен
pool.Exec("CREATE TABLE ...") в application-кодеR-SQLC-MIG-1Файлы в db/migrations/, инструмент миграций
schema: "" или указание на живую БД в sqlc.yamlR-SQLC-MIG-2schema: "db/migrations" — sqlc читает файлы
Редактирование уже применённой миграцииR-SQLC-MIG-X1Новая миграция поверх
AutoMigrate-паттерн из Go-структурR-SQLC-MIG-X2Версионированные .sql-файлы
CREATE INDEX без CONCURRENTLY на таблице с трафикомPG-M-*CREATE INDEX CONCURRENTLY + lock_timeout
Применение миграций вручную без CI-шага или migrate.Up()R-SQLC-MIG-4Автоматический запуск при старте или в CI

Куда дальше

  • go/repository-pattern.md — как репозиторий использует сгенерированные из миграций типы
  • go/transactions.md — pgx.Tx, WithTx, границы транзакции на Handler
  • PostgreSQL: миграции — правила PG-M-*: expand-contract, CONCURRENTLY, lock_timeout, проверка длинных транзакций