$npx -y skills add marckohlbrugge/37signals-skills --skill rails-migrationsWrite and review Rails database migrations safely, including reversible changes, lock-aware operations, and rollout sequencing. Use when adding or changing schema, indexes, constraints, or backfills.
| 1 | # Rails Migrations |
| 2 | |
| 3 | Use for schema changes and migration safety. |
| 4 | |
| 5 | ## Rules |
| 6 | |
| 7 | - Make migrations reversible whenever possible (`reversible` blocks with explicit up/down SQL for data moves). |
| 8 | - Use raw SQL for data manipulation inside migrations; avoid referencing app models that drift over time. |
| 9 | - Avoid long table locks on large tables; split risky work into multiple deploy-safe steps. |
| 10 | - Separate schema changes from heavy data backfills. |
| 11 | - Prefer database operations that remain safe when rerun (`table_exists?` / `column_exists?` guards where migrations may run against varied states). |
| 12 | - Small inline backfills are fine in one migration (add column → `execute "UPDATE ..."` → tighten constraint); anything long-running moves out of the migration path entirely. |
| 13 | |
| 14 | ## Script Backfills (not everything is db:migrate) |
| 15 | |
| 16 | Long-running or risky data backfills live in `script/migrations/*.rb`, run manually (e.g. via Kamal), not in the deploy migration window: |
| 17 | |
| 18 | - Document preconditions and run instructions in the header comment. |
| 19 | - Preflight queries print scope before mutating. |
| 20 | - Idempotent: skip rows already processed (`next if Entry.exists?(...)`) so reruns are safe. |
| 21 | - Batched (`find_each` / `in_batches`), never one giant write. |
| 22 | |
| 23 | ## Safe Patterns |
| 24 | |
| 25 | - Add nullable column -> backfill -> enforce `NOT NULL`. |
| 26 | - Add index concurrently when supported/needed. |
| 27 | - Dedupe data (raw SQL delete of older duplicates) in the same migration immediately before adding a unique index. |
| 28 | - Data-only migrations are legitimate: `find_each` + `update!` up, `update_all` down. |
| 29 | - Batched backfills instead of one giant write. |
| 30 | |
| 31 | ## Constraints: a deliberate choice, not a default |
| 32 | |
| 33 | - Prefer DB-level uniqueness indexes over AR `validates uniqueness` (the validation races; the index doesn't). |
| 34 | - Foreign keys are a tradeoff: Fizzy deliberately removed all FKs (`foreign_key: false` on references) for DDL speed and shard-friendliness, keeping integrity in Rails. Either posture is fine — but make it consistent and documented, not accidental. |
| 35 | - Enforce odd invariants with cheap schema tricks where CHECK constraints are awkward: e.g. singleton tables via a `singleton_guard` column defaulting to 0 with a unique index. |
| 36 | - Atomic per-tenant counters: `account.increment!(:cards_count)` for sequence numbers + unique `[account_id, number]` index; a locked sequence row (`first_or_create!` under lock, `increment!`) for global ID sequences. |
| 37 | |
| 38 | ## Multi-Tenant Index Strategy |
| 39 | |
| 40 | - When tenanting an app, replace global indexes with `[account_id, ...]` composites in a dedicated migration phase; drop now-redundant single-column indexes and comment why. |
| 41 | - Scoped uniqueness lives at the DB level: `add_index :tags, [:account_id, :title], unique: true`. |
| 42 | |
| 43 | ## Multi-Database / Multi-Adapter Apps |
| 44 | |
| 45 | - Solid Queue/Cache/Cable each get their own database with separate `migrations_paths` and schema files. |
| 46 | - Supporting SQLite + MySQL from one codebase: early-return adapter guards in migrations (`return if connection.adapter_name == "SQLite"`), adapter-specific DDL (FTS5 vs sharded fulltext), and dual schema dumps (`schema.rb` / `schema_sqlite.rb` via per-config `schema_dump`). |
| 47 | - SQLite in production: set `default_transaction_mode: immediate` to reduce `SQLITE_BUSY` under concurrent writers. |
| 48 | - Disable `dump_schema_after_migration` in production. |
| 49 | |
| 50 | ## Staged Rollout Playbooks |
| 51 | |
| 52 | - **Column replacement** |
| 53 | - Deploy 1: add new nullable column. |
| 54 | - Deploy 2: dual-write / backfill. |
| 55 | - Deploy 3: read from new column. |
| 56 | - Deploy 4: enforce constraints, then drop old column later. |
| 57 | |
| 58 | - **Constraint hardening** |
| 59 | - Add data cleanup/backfill first. |
| 60 | - Add index/constraint only after data is compliant. |
| 61 | - Flip application behavior to rely on constraint once live. |
| 62 | |
| 63 | - **Destructive changes** |
| 64 | - First deprecate reads/writes in app code. |
| 65 | - Remove usage in a separate deploy. |
| 66 | - Drop columns/tables only after confirmation window. |
| 67 | |
| 68 | ## Red Flags |
| 69 | |
| 70 | - Irreversible migrations without explicit reason. |
| 71 | - Combining schema rewrite + heavy data migration in one step. |
| 72 | - Large backfills inside transaction-heavy default migrations (move to `script/migrations/`). |
| 73 | - Dropping columns/tables without staged deprecation. |
| 74 | - Referencing app models in migrations (model behavior drifts; use SQL or inline minimal AR classes). |
| 75 | - Adding a unique index without first deduping existing data. |
| 76 | - `validates uniqueness` with no backing unique index. |