code-quality
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when migrating PostgreSQL tables that serve live traffic — index concurrently, @disable_ddl_transaction/@disable_migration_lock, lock_timeout, NOT NULL/foreign keys via NOT VALID, backfills, renames in rolling deploys. Not for migration basics (use ecto-essentials) or
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill ecto-migrations-safety --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
/ecto-migrations-safetyContext preview
The summary Claude sees to decide when to auto-load this skill.
Use when migrating PostgreSQL tables that serve live traffic — index concurrently, @disable_ddl_transaction/@disable_migration_lock, lock_timeout, NOT NULL/foreign keys via NOT VALID, backfills, renames in rolling deploys. Not for migration basics (use ecto-essentials) or
name: ecto-migrations-safety description: "Use when migrating PostgreSQL tables that serve live traffic — index concurrently, @disable_ddl_transaction/@disable_migration_lock, lock_timeout, NOT NULL/foreign keys via NOT VALID, backfills, renames in rolling deploys. Not for migration basics (use ecto-essentials) or releases (use deployment-gotchas)."
> Verified against Elixir 1.20, Ecto 3.14 (ecto_sql 3.14), PostgreSQL 16 — October 2026
1. **Index existing tables with `concurrently: true` plus both `@disable_ddl_transaction true` and `@disable_migration_lock true`**, one index per migration. With only the first, the migration hangs forever under the default migration lock. 2. **Set `lock_timeout` in every transactional migration that alters a busy table**: `after_begin/0` calling `repo().query!("SET LOCAL lock_timeout TO '5s'")`. Never `execute/1` there, because it breaks rollback of `change/0`. 3. **Add columns as nullable or with a constant default.** A volatile default (`gen_random_uuid()`, `clock_timestamp()`, `random()`) rewrites the whole table under an exclusive lock. 4. **`NOT NULL` takes three migrations**: `CHECK (col IS NOT NULL)` with `validate: false`, then `VALIDATE CONSTRAINT`, then `SET NOT NULL` through `execute/2`. Don't use `modify ... null: false` for this step: it also emits `ALTER COLUMN TYPE`, which rescans the table. 5. **Add foreign keys and check constraints to populated tables with `validate: false`**, then validate them in a later migration. 6. **Never rename or drop anything the running code reads.** Expand and contract across deploys. To rename only in Elixir, use `field :new_name, :string, source: :old_column`. 7. **Change a type in place only when PostgreSQL can skip the rewrite** (`varchar` → `text`). Any other change (`integer` → `bigint`) gets a new column, a backfill and a switch. 8. **Backfill in a separate data migration**: schemaless (`from p in "posts"`), batched, outside a DDL transaction. Never call app schemas or contexts from a migration. 9. **Make every migration reversible** (`execute/2`, `modify ... from:`, `remove/3` with a type) or write `up/0`/`down/0`. Keep migrations without a transaction to a single idempotent step, because they can fail half-applied. 10. **Run migrations before the new code takes traffic** (see the `deployment-gotchas` skill), and read the generated SQL with `mix ecto.migrate --log-migrations-sql` before you ship.
---
Most `ALTER TABLE` forms take an `ACCESS EXCLUSIVE` lock. On a busy table, the danger isn't how long the DDL takes. It's the time spent **waiting** for the lock: while an `ALTER` waits behind one long-running query or idle transaction, every later `SELECT` on that table queues behind the `ALTER`. A 2 ms change becomes a full outage. Two fixes cover most cases:
| Operation (PostgreSQL 12+) | Rewrite or scan? | Blocks reads and writes? | |---|---|---| | `add :col, :type` (nullable, no default) | no | briefly | | `add :col, :type, default: "constant"` (constant default, even with `null: false`) | no | briefly | | `add :col, :uuid, default: fragment("gen_random_uuid()")` | **rewrite** | for the whole rewrite | | `modify :title, :text, from: :string` (varchar → text) | no | briefly | | `integer` → `bigint`, or any other non-binary-compatible type | **rewrite** | for the whole rewrite | | `SET NOT NULL` | **full scan**, unless a validated `CHECK (col IS NOT NULL)` exists | for the scan | | `create constraint(..., validate: false)` / `references(..., validate: false)` | no | briefly | | `VALIDATE CONSTRAINT` | scan | no (`SHARE UPDATE EXCLUSIVE`) | | `create index` | scan | blocks writes for the build | | `create index(..., concurrently: true)` | scan | no | | `rename`, `remove` | no | briefly, **but breaks old code** |
The rewrite and scan rows were measured on a 2M-row table (relfilenode changes, `DEBUG` output). Lock levels come from the PostgreSQL `ALTER TABLE` docs.
defmodule MyApp.Repo.Migrations.AddPostsSlugIndex do
use Ecto.Migration
@disable_ddl_transaction true
@disable_migration_lock true
def change do
create index(:posts, [:slug], concurrently: true)
end
endRolling it back emits `DROP INDEX CONCURRENTLY`. Here is why both attributes are needed (each case tested):
| Attributes | Result | |---|---| | neither | `ERROR 25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block` | | only `@disable_ddl_transaction` | **hangs forever.** The default `migration_lock: :table_lock` keeps a transaction open on `schema_migrations` for the whole run, while the migration runs in a separate process. `CREATE INDEX CONCURRENTLY` waits for every older transaction to finish, including that one (`wait_event = virtualxid`). Ecto only prints an `IO.warn` | | both | works | | only `@disable_ddl_transaction`, plus repo `migration_lock: :pg_advisory_lock` | works, no warning |
· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when preparing releases or deployment config — mix phx.gen.release, runtime.exs vs compile-time config, release migrations,…
Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date…
Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes,…
Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop…
Use when a query is slow, N+1 or hard to compose — preload vs join-preload, dynamic/2, subquery/exists, Repo.aggregate,…