Day 52 — migrations & SQL tooling
Day 52 — migrations & SQL tooling
Stage VI · ~3h
Goal: Manage schema with versioned migrations, understand sqlc vs ORM trade-offs, and apply migrations cleanly in app startup and tests.
Why this day exists
CREATE TABLE IF NOT EXISTS is fine for Day 51 toys. Production schemas evolve:
- Add columns, indexes, constraints
- Backfill data
- Coordinate multi-instance deploys
Without migrations you get “works on my machine” drift. Without a tooling philosophy you either drown in ORM magic or copy-paste SQL forever. Today freezes the mental model you will use through the capstone.
Theory 1 — Migration principles
- Forward versioned files —
0001_init.up.sql,0002_add_email.up.sql
- Expand/contract for zero-downtime when needed (add nullable → backfill → constrain)
- Never edit applied migrations in shared environments—add a new one
- Down migrations optional; many teams prefer forward-fix only
- Run migrations as a separate step or gated startup with locking
Minimal migration table
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
);Record each applied version. The version string is usually the filename prefix (0001_init).
Expand / contract in one paragraph
Ship a migration that adds a nullable column while old code ignores it; deploy new code that writes both old and new; backfill; then a later migration enforces NOT NULL. Dropping columns requires the reverse: stop reading → deploy → drop. This is how two binary versions coexist during a rolling deploy.
Theory 2 — Lightweight DIY migrator (lab-friendly)
type Migration struct {
Version string
SQL string
}
func Apply(ctx context.Context, db *sql.DB, migrations []Migration) error {
if err := ensureTable(ctx, db); err != nil {
return err
}
for _, m := range migrations {
ok, err := applied(ctx, db, m.Version)
if err != nil {
return err
}
if ok {
continue
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
_ = tx.Rollback()
return fmt.Errorf("migration %s: %w", m.Version, err)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
m.Version, time.Now().UTC().Format(time.RFC3339),
); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
func ensureTable(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TEXT NOT NULL
)`)
return err
}
func applied(ctx context.Context, db *sql.DB, version string) (bool, error) {
var n int
err := db.QueryRowContext(ctx,
`SELECT COUNT(1) FROM schema_migrations WHERE version = ?`, version,
).Scan(&n)
return n > 0, err
}Embed SQL with //go:embed migrations/*.sql for shippable binaries.
//go:embed migrations/*.sql
var migrationFS embed.FSProduction often uses golang-migrate, goose, atlas, or pressly/goose—pick one for real services; DIY teaches the model.
Theory 3 — sqlc vs ORM
| Approach | What you write | Pros | Cons |
|---|---|---|---|
Raw database/sql |
SQL strings | Full control, no codegen | Boilerplate Scan |
| sqlc | SQL → generated Go | Type-safe, explicit SQL, fast | Codegen step; SQL skill required |
| ORM (GORM, ent, …) | Struct tags / DSLs | Speed of CRUD | Hidden queries, magic, migrations coupling |
Expert recommendation for this volume
- Prefer explicit SQL (raw or sqlc-style).
- Use ORM only if you already know its failure modes.
- Capstone default:
database/sql+ migrations + thin repo; sqlc welcome.
sqlc mental model
-- name: GetItem :one
SELECT id, name, description FROM items WHERE id = ?;Generates GetItem(ctx, id) (Item, error) with correct types. You review SQL in PR diffs—good for teams.
ORM footguns (awareness)
- N+1 queries via lazy loading
- Auto-migrate in production by accident
- Unbounded
Findwithout limits
- Struct tag soup
- “Repository” that is really the ORM session leaking
Theory 4 — Migration + app lifecycle
deploy:
1. migrate job (or init container)
2. start N app replicas
local:
go run ./cmd/migrate
go run ./cmd/service
Avoid racing multiple app instances each trying complex non-transactional migrations without locks. SQLite DIY in a single process is fine for labs; multi-writer Postgres needs advisory locks or a dedicated migrate step.
// optional startup gate for single-instance tools
if os.Getenv("RUN_MIGRATIONS") == "1" {
if err := migrate.Apply(ctx, db, all); err != nil {
return err
}
}Theory 5 — Data migrations vs schema
- Schema: add column, index, constraint
- Data:
UPDATE ... SETbackfill
Sometimes split for safety (schema first, data in a controlled job). Always backup mindset on prod (even if lab skips). Never SELECT * assumptions after schema changes—name columns in app SQL.
Theory 6 — Ordering and naming
migrations/
0001_init.sql
0002_add_description.sql
0003_items_name_unique.sql
- Zero-pad so lexicographic order == apply order
- One logical change per file when possible
- Include index creation in the migration that needs it for correctness
Worked example — two-step migration set
migrations/0001_init.sql:
CREATE TABLE items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
created_at TEXT NOT NULL
);migrations/0002_add_description.sql:
ALTER TABLE items ADD COLUMN description TEXT NOT NULL DEFAULT '';Repo update: Scan description. Tests apply all migrations from empty DB.
func Load(fs embed.FS) ([]Migration, error) {
entries, err := fs.ReadDir("migrations")
if err != nil {
return nil, err
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].Name() < entries[j].Name()
})
var out []Migration
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
continue
}
b, err := fs.ReadFile("migrations/" + e.Name())
if err != nil {
return nil, err
}
ver := strings.TrimSuffix(e.Name(), ".sql")
out = append(out, Migration{Version: ver, SQL: string(b)})
}
return out, nil
}Lab 1 — Setup
mkdir -p ~/lab/90daysofx/01-go/day52/migrations
cd ~/lab/90daysofx/01-go/day52
go mod init example.com/day52Carry repo from Day 51 or reimplement a minimal items table + database/sql access.
Lab 2 — Implement Apply
- Embed or read migration files sorted by name.
- Apply to empty DB.
- Re-run Apply → no-op (idempotent).
- Test that
schema_migrationshas expected versions.
go test ./migrate/ -count=1 -raceLab 3 — Evolve schema
Add description migration; update Create/Get; tests pass on fresh DB.
Simulate “old code” vs “new code” discussion in README: why expand/contract matters when two versions run together. One short paragraph is enough.
Lab 4 — Tool choice writeup
In NOTES.md or README section (≤1 page):
- Would you use sqlc, raw SQL, or ORM for a 5-table service? Why?
- One risk of auto-migrate.
- One benefit of SQL in PRs.
No wrong answer if reasoned.
Lab 5 — Optional sqlc hello
If you want codegen experience:
# install sqlc; write sqlc.yaml; sqlc generateGenerate GetItem only. Not required for gate. If you skip, write one sentence why raw SQL is enough for this lab week.
Common gotchas
| Gotcha | Fix |
|---|---|
| Editing old migrations | Add new version |
| Non-deterministic file order | Zero-pad numeric prefixes |
| DDL outside transaction (some DBs) | Know engine limits |
| Auto-migrate prod casually | Review + backup |
| App assumes schema without migrating | Startup check / migrate job |
| Ignoring partial failure | Transaction per migration when possible |
| SQLite vs Postgres placeholders | ? vs $1; abstract or dual files |
| Forgetting indexes until prod hurts | Add with the query that needs them |
Checkpoint
- Versioned migrations apply idempotently
- Second migration evolves schema + code
- Tests start from empty DB + migrations
- Written sqlc vs ORM trade-off note
- Can explain expand/contract in one paragraph
go test -racegreen on migrate package
Commit
git add .
git commit -m "day52: versioned migrations and sql tooling notes"Write three personal gotchas before continuing.
Tomorrow
Day 53 — REST design: resource modeling, status codes, versioning, and a stdlib mux JSON API over your repository.