---
title: "Миграции вместе с sqlc"
description: "Миграции sqlc: golang-migrate и goose, пары up/down, таблица schema_migrations, миграции при старте приложения и синхронизация схемы с запросами."
course: "sqlc: типобезопасный код из SQL-запросов"
url: "https://golangmaster.ru/courses/sqlc/lessons/16-migracii/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# Миграции вместе с sqlc

*Курс «sqlc: типобезопасный код из SQL-запросов», урок 16 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/sqlc/lessons/16-migracii/*

Тема урока — миграции sqlc: момент, на котором генератор наконец встречается с базой, живущей во времени. Пятнадцать уроков схема трекера стояла на месте: файлы в db/migrations существовали, sqlc читал их и сверял по ним каждый запрос, а вопрос «как эти файлы попадают в настоящую базу?» вежливо откладывался. Пора снять отложенное. Схема, живущая только в живой базе, не переживает ни нового разработчика, ни новый стенд: она воспроизводится перебиванием у коллеги, рассказами «а у меня эта колонка есть» и ручными ALTER в консоли. Схема в коде — договор, что единственная правда о структуре таблиц лежит в git и применяется программой, а не руками. Урок отвечает на пять вопросов. Первый: зачем вообще миграции и почему «схема в коде» — это процесс, а не лозунг. Второй: как устроен golang-migrate — пары файлов up/down, версии, CLI и API. Третий: что пишет в базу таблица schema_migrations и почему повторный up — штатное «ничего не делать». Четвёртый: как ведёт миграции goose migrate — аннотации в одном файле — и что он покупает против пар. Пятый: как замыкается синхронизация схемы и запросов — sqlc читает папку миграций и отказывается генерировать код под несуществующую колонку. По дороге — честный разговор про миграции при старте приложения и про то, нужны ли down-миграции на проде. Конфиг, указывающий на папку миграций, мы завели во втором уроке; панель фильтров, ради которой схема вообще меняется, — в пятнадцатом. План остальных тем — на странице [курса sqlc](/courses/sqlc/).

К шестнадцатому уроку трекер — взрослый проект: запросы генерируются с плоскими сигнатурами, панель фильтров обслуживается статическими методами, страницы листаются без вранья. Но у взрослости есть непроговорённое дно: откуда взялась сама схема. В каноне курса таблица todos с колонками id, title, done, priority, created_at, deleted_at просто была — и по ней sqlc выводил типы, сигнатуры и поля моделей. Между тем каждая из этих колонок когда-то появилась: таблицу создали, приоритет добавили, мягкое удаление прикрутили. Всё это — изменения схемы, и вопрос только в том, где они записаны: в истории чата, в голове старожила или в git.

Пятнадцатый урок закончил мыслью, что фильтры появляются вместе с колонками, которые фильтруешь. Это значит: изменение схемы — рядовая часть фичи, а не исключительная операция. Если оно рядовое, у него должен быть рядовой процесс: файл, ревью, применение, проверка — как у любого кода. Миграции и есть этот процесс: изменения схемы, записанные файлами в репозиторий и применяемые инструментом, а не руками. Урок пересобирает канон задним числом: покажет миграции, из которых собрана нынешняя схема трекера, инструменты, которые их катают, и точку, в которой папка миграций и генератор становятся одной командой.

> **Как проверять себя в этом уроке.** Честно о формате, как и во всём курсе: миграциям нужна живая база, поэтому уроки не запускаются в песочнице — runner-ов здесь нет. Понимание проверяют quiz, predict, fill, order и флешкарты: они гоняют то же, что вы повторите командами migrate и sqlc generate у себя. Формат имён, поведение schema_migrations и dirty-состояния сверены с README и FAQ golang-migrate, документацией goose и официальными доками sqlc: не пересказ, а проверенные факты.

### Миграции sqlc: зачем схеме переезжать в код

Миграция — это шаг изменения схемы, записанный в код: файл с SQL, у которого есть номер версии и направление. Вперёд — up: применить шаг, создать таблицу, добавить колонку. Назад — down: отменить шаг, вернуть схему к предыдущей версии. Пара up/down — минимальная единица: даже когда откат вам никогда не понадобится, его существование заставляет задуматься, как шаг развернуть, — бесплатная проверка дизайна. Сумма всех up-мигранций в порядке версий — это и есть схема: не схема где-то в живой базе, а схема в коде, которую можно прочитать, отревьюить и воспроизвести с нуля одной командой.

Зачем это. Первое — воспроизводимость: новый разработчик поднимает базу командой миграций, стенд разворачивается из пустоты, и исчезает жанр «а какой ALTER надо было сделать в марте». Второе — эволюция: продукт меняется, колонки появляются и уходят вместе с фичами, история миграций — это история продукта, записанная на SQL. Третье — ревью: изменение схемы едет в PR рядом с кодом, который её использует, и reviewer видит оба мира сразу. Четвёртое — одинаковые окружения: локальная база, CI, staging и прод ведутся одним и тем же набором файлов, и расхождение между ними перестаёт быть анекдотом.

sqlc в этой картине — придирчивый читатель: он не выполняет миграции, но проверяет по ним каждый запрос. Поэтому порядок в папке миграций превращается из стилистики в контракт: как только файлы соврали о схеме, генерация начинает врать о типах. Конфиг, который указывает sqlc на папку миграций — schema: "db/migrations" — мы записали во втором уроке; теперь наполним папку так, чтобы её хотелось читать.

### Миграция номер один: пара up/down

```go
-- db/migrations/000001_init.up.sql: таблица-прародительница трекера
CREATE TABLE todos (
    id BIGSERIAL PRIMARY KEY,                      -- int64 в Go
    title TEXT NOT NULL,                           -- string
    done BOOLEAN NOT NULL DEFAULT false,           -- bool
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()  -- время ставит база
);

-- db/migrations/000001_init.down.sql: шаг назад из пустоты
DROP TABLE IF EXISTS todos;
```

Пара, с которой начинается канон: из этого файла выросла нынешняя схема — второй шаг добавил priority, третий deleted_at, и сумма трёх up-файлов равна схеме, по которой генерировался код пятнадцати уроков. Down зеркален буквально: DROP TABLE возвращает базу к точке «таблицы не было». Пометки с Go-типами — перевод для глаз, правила соответствия разобраны в четвёртом уроке; now() — вызов в базе, не в приложении: время создания записи — забота хранилища. Обратите внимание на IF EXISTS в down-файле: откат обязан быть безопасен даже в моменте, когда таблицы уже нет.

### golang-migrate: пары файлов up/down

Самый популярный инструмент чистого SQL — github.com/golang-migrate/migrate. Его модель проста до прозрачности: одна миграция — пара файлов с одинаковым номером версии и общим названием. Имя собирается по шаблону «версия_название.up.sql» для шага вперёд и «версия_название.down.sql» для шага назад: 000001_init.up.sql и 000001_init.down.sql. Номер — целое число: по умолчанию инструмент ставит unix-время создания файла, но флаг -seq у команды create включает последовательную нумерацию с ведущими нулями — 000001, 000002, 000003; проект, который читают люди, лучше живёт с последовательным форматом.

Здесь живёт подводный камень, о котором прямо предупреждают доки sqlc: golang-migrate сортирует файлы численно, а sqlc читает папку лексикографически. Без ведущих нулей файл 10_bar.up.sql ляжет раньше 9_foo.up.sql — генератор увидит схему не в том порядке, в котором её видит база. Лечение одно: нулевое дополнение номеров. 009_foo и 010_bar дружат в обоих мирах, 9_foo и 10_bar — нет.

CLI лаконичен: источник и база задаются флагами -source (для файловой папки её короткое имя -path) и -database, дальше — глаголы: up — применить всё или N шагов, down — откатить N, version — показать текущую версию, goto V — доехать до конкретной, force V — переписать версию без выполнения файлов. create — единственная команда, работающая с файлами, а не с базой: она печатает заготовку пары up/down с очередным номером.

Библиотечная сторона — тот же механизм в Go: migrate.New(sourceURL, databaseURL) возвращает объект, у которого вызывают Up(), Steps(n), Migrate(version) — «приведи базу к версии V» — и Down(). Пустые импорты регистрируют драйвер базы и источник файлов; именно поэтому одна программа умеет вести миграции и из папки на диске, и из файловой системы, вшитой в бинарник.

### CLI: четыре команды на каждый день

```go
# заготовить пару: 000003_soft_delete.up.sql и 000003_soft_delete.down.sql
migrate create -ext sql -dir db/migrations -seq soft_delete

# применить всё неприменённое к локальной базе
migrate -path db/migrations -database "postgres://localhost:5432/tracker?sslmode=disable" up

# узнать версию и откатить ровно один шаг
migrate -path db/migrations -database "postgres://localhost:5432/tracker?sslmode=disable" version
migrate -path db/migrations -database "postgres://localhost:5432/tracker?sslmode=disable" down 1
```

Один источник, одна база и набор глаголов. create работает только с файлами: печатает пару заготовок с очередным последовательным номером — содержимое пишете вы, восстановите его по пропускам в fill-задании ниже. up без аргумента доезжает до последней версии; down N — это N шагов назад по счётчику, а не «до версии N»: у возврата к конкретной версии есть отдельный глагол goto. Строка подключения — та же, что у приложения; sslmode=disable годится только локально. Повторный up на накатанной базе ничего не изменит — так работает книга учёта, о ней сразу ниже.

### schema_migrations: книга учёта версий

Инструмент помнит версию базы в самой базе: рядом с вашими таблицами появляется служебная schema_migrations — таблица с двумя колонками, version и dirty. Механика честная: перед применением шага migrate записывает его номер и ставит dirty в true — «я мигрирую»; после успеха сбрасывает флаг, оставляя версию применённого шага. Повторный migrate up на полностью накатанной базе сравнит версию из таблицы с последней в папке, не найдёт недостающих и не сделает ничего: повторный up — штатный no-op, а не ошибка. Сбой посреди шага — самый важный сценарий: dirty останется true, и любая следующая попытка ответит «Dirty database version N. Fix and force version» — конвейер миграций не поедет, пока человек не разберётся.

Разобраться — значит понять, что именно сломалось: частично применённый ALTER придётся доводить или убирать руками, потому что откат за вас никто не придумал. Когда схема приведена в порядок, версию переписывают командой force: она выставляет номер без выполнения файлов, и конвейер снова едет. Таблицу можно переименовать параметром подключения (x-migrations-table у postgres-драйвера), если имя по умолчанию не устраивает.

И про соревнование: два процесса migrate на одной базе не применят один шаг дважды — postgres-драйвер берёт advisory-лок базы (функция pg_advisory_lock), и второй процесс ждёт. Лок защищает от двойного применения, но не от долгого ожидания: процесс, вставший в очередь за длинной миграцией, просто стоит. Это знание понадобится, когда мы будем обсуждать миграции при старте сервиса.

### Проверка: повторный up

**Вопрос:** База доведена до последней версии: все up-файлы применены, schema_migrations показывает номер последней миграции и dirty = false. Команда запускает migrate up ещё раз. Что произойдёт?

- Все миграции применятся заново: инструмент не помнит прошлых накатов
- ✅ Ничего не изменится: версия в schema_migrations совпадает с последней в папке, применять нечего
- База откатится на шаг вниз и снова поднимется: так инструмент проверяет целостность
- Таблица schema_migrations пересоздастся с нуля, после чего миграции применятся

Книга учёта именно для этого и существует: мигрант сравнивает version из schema_migrations с последней версией папки, разницы не находит и завершается без изменений. Применённые шаги не выполняются дважды — их результат уже в схеме и записан в таблице учёта. Откаты не случаются: down — отдельная команда, которую вызывают руками. И таблицу никто не пересоздаёт: это единственная память инструмента, стирать её — терять версию базы.

### goose migrate: аннотации в одном файле

Второй полюс — goose из проекта pressly. Его философию видно по формату: одна миграция — один файл, внутри которого два раздела, размеченные комментариями-аннотациями. Раздел вперёд открывает -- +goose Up — по правилам goose она ровно одна на файл и обязательна; раздел отката открывает -- +goose Down — она опциональна, но если есть, обязана стоять после Up. Всё, что следует за аннотацией до конца файла, goose относит к соответствующему направлению; SQL вне аннотаций просто пропускается — и это не только удобство, но и ловушка: заголовок уехал выше комментария, часть шага молча не применяется. Учёт версий ведётся в собственной таблице goose_db_version, где у каждого шага отмечено, применён ли он.

Команды зеркалят golang-migrate с теми же идеями: goose up — применить всё, goose down — откатить шаг, goose down-to 0 — разобрать базу до пустоты, goose status — показать, что применено. Полезное отклонение — up-by-one: по одному шагу за раз, любимый режим отладки миграций. Для процедур и функций, где точка с запятой встречается внутри тела, goose просит размечать оператор парой -- +goose StatementBegin и -- +goose StatementEnd, а сложные шаги можно вывести из транзакции аннотацией -- +goose NO TRANSACTION.

Для Go-кода у goose три двери: CLI, библиотека и embed — goose.SetBaseFS с файловой системой go:embed кладёт миграции в бинарник, и сервис несёт схему в себе. По запросу «goose migrate» вас приведут ровно сюда: команда у инструмента называется просто goose up, но смысл фразы — ведение миграций goose'ом — совпадает с темой урока.

### Тот же шаг в формате goose: один файл

```go
-- db/migrations-goose/000003_soft_delete.sql: тот же шаг, формат goose

-- +goose Up
ALTER TABLE todos ADD COLUMN deleted_at TIMESTAMPTZ; -- nullable: задача жива
CREATE INDEX idx_todos_open_priority ON todos (priority)
    WHERE deleted_at IS NULL;                        -- индекс открытых для фильтров

-- +goose Down
DROP INDEX IF EXISTS idx_todos_open_priority;
ALTER TABLE todos DROP COLUMN IF EXISTS deleted_at;

# goose ведёт ту же базу: команды вместо пар файлов
goose -dir db/migrations-goose postgres "postgres://localhost:5432/tracker?sslmode=disable" up
goose -dir db/migrations-goose postgres "postgres://localhost:5432/tracker?sslmode=disable" status
```

Весь шаг в одном файле: два раздела, два направления, порядок фиксирован — Up раньше Down. Обратите внимание на три вещи. Первая: внутри одного раздела несколько операторов — колонка и индекс уезжают одним шагом, потому что логически это одна фича: мягкое удаление с быстрым списком открытых задач. Вторая: аннотации — комментарии для goose, а sqlc читает их как разметку и берёт из файла только SQL. Третья: форматы в одной папке не смешивают — либо пары up/down, либо файлы с аннотациями; sqlc поддерживает оба, но инструмент на проекте один. Если команда выбрала goose, пара из предыдущего примера сжимается в один файл, а содержимое раскладывается по разделам.

### Миграции при старте приложения: цена удобства

Третий способ — не инструмент, а решение: вызывать миграции из самого приложения при старте. Идея заманчива: деплой из одного шага, локальный запуск сам поднимает базу, тестовые окружения обслуживают себя сами. Библиотечный API обоих инструментов для этого и сделан: несколько строк до подключения к пулу — и база догнала код.

Теперь честно про цену. Риск первый — стартовая гонка: при раскатке обновления поднимается сразу несколько подов, и каждый пытается мигрировать. Advisory-лок сериализует попытки — один применяет, остальные ждут, — но время старта каждого пода становится временем чужой миграции, а readiness-проверки могут прибить под раньше, чем он дождётся очереди. Риск второй — отказоупорность: миграция падает — процесс не выходит из старта, инцидент схемы становится инцидентом сервиса, и чинить dirty-версию придётся руками в тот момент, когда весь деплой стоит. Риск третий — длительные шаги: реорганизация большой таблицы на десятки минут превращает обычный деплой в окно простоя, и обнаружится это в худший момент, потому что до продакшен-объёмов миграцию никто не примерил. Риск четвёртый — права: приложению нужны права на DDL в рантайме, а это расширение поверхности атаки и соблазн «заодно» поменять схему из кода.

Вывод урока: миграции при старте — честный выбор для маленького сервиса с одним инстансом и для локальной разработки. В командном продакшене миграции должны быть отдельным шагом деплоя — джобой или стадией пайплайна, с логами, таймаутами и правом падать до того, как пострадал трафик. Не запрет, а дисциплина: инструмент тот же, место другое.

### API: база догоняет код при старте

```go
// cmd/tracker/main.go: поднять схему до последней версии перед стартом
import (
        "errors"

        "github.com/golang-migrate/migrate/v4"
        _ "github.com/golang-migrate/migrate/v4/database/postgres" // драйвер базы
        _ "github.com/golang-migrate/migrate/v4/source/file"       // источник file://
)

// migrateUp применяет все недостающие миграции; вызывается до подключения пула
func migrateUp(dsn string) error {
        m, err := migrate.New("file://db/migrations", dsn)
        if err != nil {
                return err // не нашлись папка миграций или зарегистрированный драйвер
        }
        if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
                return err // сбой шага: база в dirty, старт прерываем честно
        }
        return nil // ErrNoChange — не ошибка: применять нечего
}
```

Три части API. migrate.New принимает два URL: источник миграций — file:// для папки на диске, а для схемы, вшитой в бинарник, есть источник iofs — и строку подключения к базе. Пустые импорты регистрируют драйвер и источник: без них New вернёт загадочную ошибку, пока не вспомнишь про механизм регистрации. m.Up() применяет всё недостающее; на накатанной базе он вернёт ErrNoChange — штатный повтор, а не сбой, и его отфильтровывают. Ошибку честно возвращают наверх: подниматься на базе неизвестной версии хуже, чем не подняться. Доехать до конкретной версии — Migrate(version); продвинуться по шагам — Steps(n). В продакшене тот же код лучше вызывать не из приложения, а из отдельной джобы: инструмент тот же, место другое.

### Проверка: три пода стартуют разом

**Вопрос:** Сервис мигрирует базу при старте (m.Up() в main.go). Обновление раскатывается на три пода, и все три стартуют одновременно. Что произойдёт при условии, что миграции ещё не применены?

- Ничего страшного: Go-рантайм сам сериализует стартовые горутины разных подов
- Все три пода применят каждую миграцию по разу, и схема утроится
- ✅ Advisory-лок сериализует попытки: один под применит шаги, остальные дождутся; но если шаг упадёт посреди, база останется в dirty, а стартующие поды — на перепутье
- База откажет в третьем подключении: лимит соединений не даст трем подам стартовать

Двойного применения не случится: postgres-драйвер берёт advisory-лок, один под мигрирует, остальные ждут очереди. Но честная картина дороже: время старта каждого пода удлиняется на чужую миграцию, readiness-проверки могут убить ждущего, а сбой посреди шага оставляет dirty — сервис в это время не выходит из старта, и чинить версию придётся руками при стоящем деплое. Отсюда вывод урока: для командного прода миграции — отдельный шаг деплоя, а не побочный эффект старта. Гонки горутин тут ни при чём: поды — разные процессы на разных машинах.

### Сравнение: пары, аннотации, старт приложения

| Критерий | golang-migrate | goose | Миграции при старте |
| --- | --- | --- | --- |
| Файлы | пара up/down на шаг | один файл с аннотациями | формат любого из двух |
| Запуск | CLI или API | CLI, API, embed-провайдер | сам процесс приложения |
| Учёт версий | schema_migrations: version + dirty | goose_db_version | таблица выбранного инструмента |
| Откат | down N по счётчику | down и down-to по версиям | обычно не предусмотрен |
| Защита от гонок | advisory-лок postgres-драйвера | запуск из одного места | лок сериализует, старт ждёт |
| Главный риск | dirty-версия требует force | SQL вне аннотаций пропускается молча | сбой миграции останавливает сервис |
| Когда выбирать | чистый SQL, CI, отдельная джоба | один файл, embed в бинарник | маленький сервис, один инстанс |

### Синхронизация: sqlc проверяет запросы по папке миграций

Вот и стык, ради которого урок живёт в курсе sqlc. Конфиг второго урока указывал schema: "db/migrations" — не файл, а папку. Теперь это перестаёт быть просто удобством: sqlc читает все файлы папки в лексикографическом порядке, собирает итоговую схему и сверяет с ней каждый запрос. Доки sqlc фиксируют рамку: sqlc не выполняет миграции — он их понимает. Он различает up и down и при разборе файлов down-миграции игнорирует; из коробки распознаются форматы golang-migrate, goose, sql-migrate и tern, а также atlas и dbmate. Поэтому ни инструменту миграций, ни генератору не нужно ничего объяснять: up-файл — правда для обоих, down — правда только для инструмента.

Выигрыш — контракт синхронизации: запрос не сгенерируется под несуществующую колонку. Пишите SELECT с колонкой, которой нет ни в одном up-файле, — генерация падает с сообщением о колонке, которой нет в схеме, и вы узнаёте об этом на ноутбуке, а не после выката. Второй урок показывал падение генерации на опечатках в конфиге; здесь тот же механизм работает на более глубоком слое — расхождение схемы и запросов ловится до компиляции. В ревью появляется правило одного PR: миграция, запросы и перегенерированный код едут вместе — reviewer видит колонку рядом с фильтром, который её читает. Фильтр из пятнадцатого урока и миграция с колонкой priority — образцовая пара.

И три привычки гигиены. Первая — ведущие нули в номерах: лексикографический порядок sqlc и численный порядок golang-migrate обязаны совпадать. Вторая — не редактировать применённые миграции: файл, накатанный на чужие базы, теперь история, ошибки исправляются новым шагом. Третья — down-файлы в тонусе: sqlc их игнорирует, но ваш инструмент — нет.

### Один PR, две папки, один источник правды

```go
# sqlc.yaml: схема — не файл, а папка миграций (конфиг второго урока)
version: "2"
sql:
  - engine: "postgresql"
    queries: "db/queries"
    schema: "db/migrations"
    gen:
      go:
        package: "db"
        out: "db/sqlc"
        sql_package: "pgx/v5"

-- db/migrations/000002_add_priority.up.sql: колонка для фильтров панели
ALTER TABLE todos ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;

-- db/queries/todos.sql: запрос панели опирается на колонку
-- name: SearchTodosByPriority :many
SELECT * FROM todos
WHERE priority = $1 AND deleted_at IS NULL
ORDER BY created_at DESC, id DESC;

// сгенерировано: поле появляется в Todo вместе с колонкой
Priority int32 // NOT NULL DEFAULT 0 -> плоский int32
```

Один PR проходит цепочку целиком: миграция дописывает колонку в папку, sqlc собирает из папки схему, запрос панели проверяется против неё, генерация выдаёт поле Priority int32 — плоский тип от NOT NULL-колонки, как и положено. Ни один шаг не требует запущенного сервера: и схема, и запросы — файлы, сверка — часть sqlc generate. Down-файл пары (000002_add_priority.down.sql) в сборке схемы не участвует — sqlc его игнорирует, поэтому в примере его нет. Соблюсти стоит другое: ведущие нули в номере — 000002, а не 2, — чтобы лексикографический порядок генератора совпал с численным порядком инструмента миграций.

### Предскажите исход генерации

```go
-- db/migrations/000002_add_priority.down.sql: файл есть, но он down
ALTER TABLE todos DROP COLUMN IF EXISTS priority;

-- up-файла с ADD COLUMN priority ещё нет: коллега не закоммитил

-- db/queries/todos.sql уже написан под фильтр панели:
-- name: SearchTodosByPriority :many
SELECT * FROM todos WHERE priority = $1;
```

**Что выведет программа?**

- Сгенерируется: Priority int32 в Params — колонка из down-файла тоже считается
- Сгенерируется с pgtype.Int4: раз колонка живёт в down-файле, параметр nullable
- ✅ Генерация падает: колонки priority в схеме нет — down-файлы sqlc игнорирует
- Генерация падает: sqlc требует, чтобы down-файл был пустым

Down-миграции при разборе пропускаются: их SQL для sqlc не существует, и «колонка в down-файле» — это колонка, которой нет. Папка без up-файла описывает трекер без priority, запрос ссылается на отсутствующую колонку — генератор падает, указав файл и строку. Это и есть контракт синхронизации: расхождение ловится на ноутбуке, а не в проде. Спасение простое — закоммитить up-файл, и цепочка сходится. Сценарий намеренно повторяет правило одного PR: миграция и запрос едут вместе, иначе конвейер — и этот predict — краснеет.

### Down-миграции: нужны ли — мнение урока

Down-миграции — предмет вечного спора, и у урока есть мнение, изложенное честно. Начнём с того, чего требуют инструменты: golang-migrate рекомендует включать оба файла всегда — даже когда down пуст, пара должна существовать, чтобы решение «шаг необратим» было явным; goose считает Down необязательной — без неё шаг вперёд возможен, а назад — нет. Оба подхода законны, но молчаливое отсутствие отката хуже явного пустого: по крайней мере, никто не понадеется на несуществующий скрипт.

Мнение: down-миграции — инструмент разработки, а не план спасения продакшена. В разработке они незаменимы: down-to 0 и снова up — чистая база за секунды, отладка миграций, прогоны тестов на свежих схемах, эксперименты с формой колонок держатся на откате. В проде честность другая. Down-файлы почти никогда не тестируются на реальных данных — файл, который не гоняли на объёмах, не план, а надежда. DROP COLUMN стирает данные без возврата, и «откат» на деле означает потерю. А данные, которые шаг пересчитал, вообще некуда откатывать. Аварийный путь продакшена — не назад, а вперёд: новая forward-миграция, чинящая схему и данные; откат кода деплоем; на случай катастрофы — восстановление из бэкапа. Это не отказ от down-файлов: писать их дёшево, и они полезны как тест дизайна шага. Это отказ от иллюзии, что они спасут живой прод.

Того же духа дисциплина разворачивания: колонка добавляется с DEFAULT — старый код живёт, пока новый доезжает; безобидные расширяемые шаги едут первыми, разрушающие (переименования, слияния таблиц) дробятся на серию. Схема меняется часто и понемногу — так, чтобы ни один шаг не требовал остановки мира.

### Предскажите состояние книги учёта

```go
-- база доведена до версии 3 (000003_soft_delete применён):
-- schema_migrations: version = 3, dirty = false

# откатываем ровно один шаг:
# migrate -path db/migrations -database $DSN down 1
```

**Что выведет программа?**

- version = 3, dirty = false — down не трогает таблицу учёта
- ✅ version = 2, dirty = false — откатился один шаг: с третьей версии на вторую
- version = 1, dirty = false — down 1 возвращает базу к самой первой версии
- version = 0, dirty = true — down помечает базу грязной

down N — это N шагов по счётчику, а не «до версии N»: откатился ровно один up-файл — 000003_soft_delete, схема потеряла deleted_at и индекс открытых, книга учёта показала version = 2 и чистый флаг. До первой версии отсюда ещё два шага; полная зачистка у golang-migrate живёт в down -all, у goose — в down-to 0. dirty остался false: сбоя не было, откат — штатная операция. И помните: реальные данные ушли вместе с колонкой — на этот вечер рассчитаны бэкапы, а не down-файлы.

### Проверка: авария на проде

**Вопрос:** Миграция с ошибкой уехала на прод: колонка добавилась, но часть записей получила неверные значения. Down-файл у шага есть. Какой план возврата урок называет честным?

- Прогнать down-миграцию до версии N: файл есть, значит путь надёжен
- ✅ Написать и применить новую forward-миграцию, чинящую схему и данные; бэкап — страховка на катастрофу, код откатывается деплоем
- Откатить деплой через git revert: база вернётся в прошлое вместе с кодом
- Пересоздать базу из папки миграций: потеря данных приемлема, зато схема чистая

Down-файл на проде — не план, а надежда: он не гонялся на объёмах, DROP COLUMN стирает данные без возврата, а испорченные значения он не умеет восстанавливать вовсе. Честный путь — вперёд: forward-миграция, которая приводит схему и данные в правильное состояние и сама проходит через ревью и тесты; код откатывается деплоем предыдущей версии, а на катастрофу рассчитан бэкап. git revert базы не касается, пересоздание — это потеря продакшен-данных. Отсюда и мнение урока: down-файлы пишем (дешёвая страховка и тест дизайна), но прод идёт только вперёд.

### CI: миграции и генерация в конвейере

Миграции размечают границу между «работает у меня» и «работает везде», поэтому их естественное место — конвейер. Картина простая: в CI поднимается эфемерная база — пустая, чистая, одноразовая; на ней прогоняются все up-миграции подряд; тесты работают на живой схеме; база гасится вместе с окружением. Такой прогон стоит копейки и проверяет главное: сумма миграций из папки действительно разворачивается с нуля — не «в базе что-то накатано руками», а именно пустота до полного состояния. Жанр «накатил руками на тестовой базе, потом удивлялся на staging» перестаёт существовать: окружения рождаются из папки миграций и умирают с ней.

Второй гейт конвейера — генерация: свежий sqlc generate против папки миграций и проверка, что рабочая копия не изменилась. Расхождение означает, что кто-то закоммитил запрос без перегенерации или схему без запросов. Инструменты для этого — sqlc diff и шаг в GitHub Actions — тема двадцать второго урока; здесь важно направление: миграции и генерация проверяются одним конвейером, и зелёный PR означает согласованность обоих миров. Интеграционные тесты на живой базе — материал двадцать первого урока.

Связка с ревью завершает картину: правило одного PR выполняется автоматически, если конвейер краснеет при частичной подаче — миграция без запросов, запросы без миграции, код без перегенерации. Папка миграций из архива проекта превращается в активно проверяемый артефакт: то, что проверяется каждый день, не может соврать.

### Восстановите пару миграций мягкого удаления

Три пропуска: ключевые слова SQL в up- и down-файлах третьей миграции трекера.

```go
-- db/migrations/000003_soft_delete.up.sql: шаг вперёд
ALTER TABLE todos
    ADD ___ TIMESTAMPTZ;              -- nullable: задача пока жива

CREATE INDEX idx_todos_open_priority ON todos (priority)
    WHERE ___ IS NULL;                -- индекс открытых для фильтров

-- db/migrations/000003_soft_delete.down.sql: шаг назад
DROP INDEX IF EXISTS idx_todos_open_priority;
ALTER TABLE todos
    ___ COLUMN IF EXISTS deleted_at;  -- откат без сюрпризов
```

Подсказка: Колонка добавляется ADD COLUMN и откатывается DROP COLUMN; частичный индекс смотрит только живые строки — те, у которых маркер удаления пуст.

Правильные ответы в пропусках: `COLUMN`, `deleted_at`, `DROP`

### Соберите путь одной колонки до продакшена

Девять строк: фича «приоритет» проезжает от заготовки пары файлов до деплоя кода. Шаги, расширяющие схему, едут раньше кода, который их читает.

Правильный порядок строк:

1. `migrate create -ext sql -dir db/migrations -seq add_priority — заготовлена пара файлов`
2. `в up-файл пишем ALTER TABLE todos ADD COLUMN priority INTEGER NOT NULL DEFAULT 0`
3. `в down-файл пишем ALTER TABLE todos DROP COLUMN IF EXISTS priority`
4. `дописываем запрос в db/queries/todos.sql: WHERE priority = $1`
5. `sqlc generate — в Todo-структуре появляется поле Priority int32`
6. `go test ./... — код компилируется, тесты зелёные`
7. `migrate up на стейдже — репетиция на клонированных данных`
8. `migrate up на проде — колонка с DEFAULT, старый код её ещё не читает`
9. `деплой приложения с новым запросом — колонка и код едут вместе`

### Карточки: миграции вместе с sqlc

- **Миграции: зачем и что это** — Изменения схемы, записанные файлами в репозиторий: пара up/down на шаг, сумма up-файлов в порядке версий — это и есть схема. Схема в коде даёт воспроизводимость, историю, ревью рядом с кодом фичи и одинаковые окружения; схему в живой базе руками не трогают.
- **golang-migrate** — Пары файлов «версия_название.up.sql» и «.down.sql»; -seq — последовательные номера с ведущими нулями. CLI: create, up, down N, version, goto V, force V. API: migrate.New(источник, база), m.Up(), m.Steps(n), m.Migrate(version); повторный up — штатный no-op.
- **schema_migrations и dirty** — Служебная таблица с колонками version и dirty: перед шагом dirty=true, после успеха — версия и чистый флаг. Сбой посреди шага оставляет dirty: конвейер стоит, пока не починена схема и не вызван force V. Postgres-драйвер берёт advisory-лок — двойного применения не бывает, но очередь ждут.
- **goose** — Один файл, внутри -- +goose Up (обязательна, ровно одна) и -- +goose Down (опциональна, после Up); SQL вне аннотаций пропускается. Таблица goose_db_version; команды goose up / down / down-to 0 / status / up-by-one; StatementBegin и StatementEnd для процедур, NO TRANSACTION; embed через SetBaseFS.
- **Синхронизация с sqlc** — schema: db/migrations — sqlc читает папку лексикографически (нули в номерах обязательны), down-файлы игнорирует, форматы golang-migrate, goose, sql-migrate, tern понимает. Контракт: запрос не сгенерируется под несуществующую колонку; миграция и запросы едут одним PR.
- **Down-миграции: мнение** — Инструмент разработки, не план спасения прода: down-to 0 плюс up — свежая база в dev; прод идёт вперёд — forward-миграция чинит схему и данные, код откатывается деплоем, катастрофа закрывается бэкапом. Писать down дёшево и полезно как тест дизайна шага, надеяться на них на живых данных — нет.

### Что дальше

Миграции встали на место: схема трекера — это сумма up-файлов в db/migrations; golang-migrate ведёт её парами и не едет дальше грязной версии; goose migrate собирает шаг в один файл с аннотациями; sqlc сверяет каждый запрос с папкой и отказывается генерировать код под несуществующую колонку. Правило одного PR связало миры: колонка появляется вместе с фильтром, который её читает, — пятнадцатый урок впервые провернул этот узел, теперь он закручен до конца.

Дальше — про масштаб: Семнадцатый урок «Несколько схем и организация» разведёт домены по пакетам queries, когда одна папка перестаёт вмещать проект. А работа этого урока переедет в конвейер: двадцать второй урок покажет sqlc diff и проверку генерации в CI — зелёный PR там означает, что миграции, запросы и сгенерированный код согласованы. Конфиг с путями — во [втором уроке](/courses/sqlc/lessons/02-nastrojka/); панель фильтров, ради которой меняется схема, — в [пятнадцатом](/courses/sqlc/lessons/15-dinamicheskie/); план остальных тем — на странице [курса sqlc](/courses/sqlc/).

## Коротко: что запомнить

- Миграции — изменения схемы файлами в git: пара up/down на шаг, сумма up-файлов в порядке версий и есть схема. Схема в коде даёт воспроизводимость, историю и ревью рядом с кодом фичи.
- golang-migrate: пары файлов с численными версиями, CLI (create, up, down N, version, goto, force) и API (migrate.New, Up, Steps, Migrate). Нули в номерах обязательны: sqlc читает папку лексикографически, инструмент — численно.
- schema_migrations помнит version и dirty: сбой посреди шага блокирует конвейер до force V; повторный up — штатный no-op; postgres-драйвер защищает базу advisory-локом.
- goose migrate: один файл с аннотациями -- +goose Up (обязательна) и -- +goose Down (опциональна), таблица goose_db_version, команды up/down/down-to/status и embed в бинарник через SetBaseFS.
- Миграции при старте — удобство с ценой: стартовая гонка подов, dirty-инцидент во время деплоя, окно простоя, DDL-права в рантайме; для командного прода — отдельный шаг деплоя.
- Синхронизация: sqlc не выполняет миграции, но читает папку и игнорирует down; запрос не сгенерируется под несуществующую колонку — расхождение ловится на ноутбуке, миграция и запросы едут одним PR.
- Down-миграции — инструмент разработки: dev откатывает смело, прод идёт вперёд forward-миграцией и бэкапами; в CI эфемерная база проверяет сумму миграций с нуля, генерация — соседний гейт конвейера.

## Домашнее задание

Проведите трекер через полный миграционный цикл. Первое: заведите папку db/migrations и соберите стартовую пару 000001_init — CREATE TABLE с четырьмя колонками в up-файле и DROP TABLE в down; примените к локальной базе, найдите schema_migrations в списке таблиц и распечатайте version и dirty. Второе: допишите 000002_add_priority и 000003_soft_delete, накатите всё одним up, повторите up ещё раз и убедитесь, что второй вызов ничего не изменил. Третье: воспроизведите dirty-состояние — напишите up-файл с намеренной ошибкой (ссылка на несуществующую таблицу), примените, прочитайте сообщение о грязной версии, почините файл и вызовите force; зафиксируйте письменно, почему файл чинится до force. Четвёртое: перепишите три миграции в формате goose — один файл с аннотациями на шаг — и прогоните goose status и goose down-to 0, затем up заново; сверьте итоговую схему с суммой пар golang-migrate: она обязана совпасть. Пятое: сломайте синхронизацию намеренно — напишите запрос с колонкой due_date, не добавляя миграцию, и прочитайте падение sqlc generate; затем добавьте 000004_add_due_date парой, перегенерируйте и найдите новое поле в Todo-структуре. Шестое: проверьте лексикографическую ловушку — создайте файлы 9_test и 10_test без нулей и посмотрите, в каком порядке генератор читает папку; почините нулями и объясните в README папки миграций, почему нумерация именно такая. Седьмое: соберите в main.go функцию migrateUp из урока с фильтрацией ErrNoChange и запустите сервис дважды — второй старт обязан пройти без ошибок; затем вынесите вызов в отдельную джобу и опишите, что изменилось в поведении при сбое. Восьмое: напишите down-файлы к четырём миграциям и прогоните по одному шагу назад с проверкой schema_migrations после каждого; сформулируйте одним абзацем план аварии вашего проекта — forward-миграция, бэкап или и то и другое.

## Вопросы и ответы по уроку

### sqlc сам выполняет миграции?

Нет: sqlc миграции не выполняет — он их понимает. Генератор читает папку, различает up и down (down игнорирует при разборе), распознаёт форматы golang-migrate, goose, sql-migrate, tern, atlas и dbmate. Применением ведают инструменты миграций или ваш код через их API; sqlc лишь гарантирует, что каждый запрос сверялся с той схемой, которая получится после всех up-файлов.

### golang-migrate или goose — что выбрать?

Смотрите на два критерия. Формат: пары up/down строго разделяют направления и удобны для больших шагов; один файл с аннотациями компактнее и читается сверху вниз. Окружение: если миграции нужны внутри бинарника — у goose embed из коробки через SetBaseFS, у golang-migrate есть источник iofs для файловой системы из go:embed. Инструменты взаимозаменяемы, sqlc поддерживает оба; главное — выбрать один на проект и не смешивать форматы в папке.

### Можно ли запускать миграции при старте сервиса?

Для одного инстанса и локальной разработки — да: несколько строк с m.Up() до подключения пула. Для командного продакшена — отдельный шаг деплоя: при раскатке на несколько подов каждый стартует с миграцией, время старта растягивается на чужие шаги, а сбой превращает инцидент схемы в недоступность сервиса и dirty-версию, чинимую руками посреди релиза. Джоба с логами, таймаутами и правом падать до трафика — это дисциплина, а не запрет.
