--- name: ecto-migration-helper description: Create, manage, and safely run Ecto database migrations with proper rollback handling and best practices. Use when working with database schema changes, adding columns, or modifying constraints. allowed-tools: Bash, Read, Edit, Write --- # Ecto Migration Helper This skill helps create and manage Ecto migrations safely with proper patterns and rollback support. ## When to Use - Creating new migrations - Modifying existing tables - Adding/removing indexes - Changing constraints - Data migrations - Rolling back migrations ## Creating Migrations ### Generate Empty Migration ```bash mix ecto.gen.migration add_email_to_users ``` Creates: `priv/repo/migrations/TIMESTAMP_add_email_to_users.exs` ### Migration Naming Conventions - `create_table_name` - Creating new table - `add_field_to_table` - Adding column - `remove_field_from_table` - Removing column - `add_index_to_table_on_field` - Adding index - `modify_field_in_table` - Changing column type - `add_constraint_to_table` - Adding constraint ## Common Migration Patterns ### Adding a Column ```elixir defmodule MyApp.Repo.Migrations.AddEmailToUsers do use Ecto.Migration def change do alter table(:users) do add :email, :string end end end ``` ### Adding Column with Default ```elixir def change do alter table(:users) do add :active, :boolean, default: true, null: false end end ``` ### Adding Column with Index ```elixir def change do alter table(:users) do add :email, :string end create unique_index(:users, [:email]) end ``` ### Adding Foreign Key ```elixir def change do alter table(:posts) do add :user_id, references(:users, on_delete: :delete_all), null: false end create index(:posts, [:user_id]) end ``` ### Removing a Column ```elixir def change do alter table(:users) do remove :old_field end end ``` **WARNING**: Removing columns is irreversible with `change`. Use `up`/`down`: ```elixir def up do alter table(:users) do remove :old_field end end def down do alter table(:users) do add :old_field, :string end end ``` ### Modifying Column Type ```elixir def change do alter table(:products) do modify :price, :decimal, precision: 10, scale: 2 end end ``` ### Renaming Column ```elixir def change do rename table(:users), :username, to: :name end ``` ### Adding Composite Index ```elixir def change do create index(:posts, [:user_id, :published_at]) end ``` ### Adding Unique Constraint ```elixir def change do create unique_index(:users, [:email]) create unique_index(:users, [:organization_id, :email]) # Composite unique end ``` ### Adding Check Constraint ```elixir def change do create constraint(:products, :price_must_be_positive, check: "price > 0") end ``` ## Safe Migration Patterns ### Making Columns NOT NULL **WRONG** (will fail if existing NULLs): ```elixir def change do alter table(:users) do modify :email, :string, null: false # FAILS! end end ``` **RIGHT** (two-step approach): ```elixir # Migration 1: Add default, fill NULLs def change do # Set default for new rows alter table(:users) do modify :email, :string, default: "unknown@example.com" end # Fill existing NULLs execute( "UPDATE users SET email = 'unknown@example.com' WHERE email IS NULL", "" # No rollback needed ) end # Migration 2: Add NOT NULL constraint def change do alter table(:users) do modify :email, :string, null: false end end ``` ### Removing Columns Safely **Step 1**: Deploy code that doesn't use the column **Step 2**: Run migration to remove column (after deployment) ```elixir # Deploy this migration AFTER code no longer references the field def up do alter table(:users) do remove :old_field end end def down do alter table(:users) do add :old_field, :string # Specify type for rollback end end ``` ### Large Data Migrations Use batching to avoid locking: ```elixir def up do execute """ UPDATE users SET status = 'active' WHERE status IS NULL AND id IN (SELECT id FROM users WHERE status IS NULL LIMIT 1000) """ # Repeat in batches or use recursive function end ``` ## Data Migrations ### Backfilling Data ```elixir defmodule MyApp.Repo.Migrations.BackfillUserDefaults do use Ecto.Migration import Ecto.Query alias MyApp.Repo alias MyApp.Accounts.User def up do # Use application code in migrations carefully User |> where([u], is_nil(u.status)) |> Repo.update_all(set: [status: "active"]) end def down do # Usually no rollback for data migrations :ok end end ``` ### Complex Data Migration (Separate Module) ```elixir defmodule MyApp.Repo.Migrations.MigrateUserData do use Ecto.Migration def up do MyApp.ReleaseTasks.migrate_user_data() end def down do :ok end end # In lib/my_app/release_tasks.ex defmodule MyApp.ReleaseTasks do def migrate_user_data do # Complex logic here end end ``` ## Running Migrations ### Development ```bash # Run all pending migrations mix ecto.migrate # Run to specific version mix ecto.migrate --to 20250101120000 # Rollback last migration mix ecto.rollback # Rollback last 3 migrations mix ecto.rollback --step 3 # Rollback to specific version mix ecto.rollback --to 20250101120000 ``` ### Test Environment ```bash # Create test database MIX_ENV=test mix ecto.create # Run migrations in test MIX_ENV=test mix ecto.migrate # Reset test database (drop, create, migrate) MIX_ENV=test mix ecto.reset ``` ### Production ```bash # Run on production (typically via release task) bin/my_app eval "MyApp.ReleaseTasks.migrate()" # Or if mix is available MIX_ENV=prod mix ecto.migrate ``` ## Migration Status ```bash # Check migration status mix ecto.migrations # Output shows: # Status Migration ID Migration Name # -------------------------------------------------- # up 20250101120000 create_users # up 20250101130000 add_email_to_users # down 20250101140000 add_profile_to_users ``` ## Reversible vs Non-Reversible ### Reversible (use `change`) - Adding columns - Creating tables - Adding indexes - Adding references ### Non-Reversible (use `up`/`down`) - Removing columns (data loss) - execute() with SQL - Data transformations - Dropping tables ## Best Practices ### 1. One Logical Change Per Migration ```bash # Good: Focused migration mix ecto.gen.migration add_email_to_users # Bad: Multiple unrelated changes mix ecto.gen.migration update_users_and_posts_and_comments ``` ### 2. Always Add Indexes for Foreign Keys ```elixir add :user_id, references(:users) create index(:posts, [:user_id]) # Always add this! ``` ### 3. Specify on_delete for Foreign Keys ```elixir # Be explicit about cascade behavior add :user_id, references(:users, on_delete: :delete_all) # Cascade add :user_id, references(:users, on_delete: :nilify_all) # Set NULL add :user_id, references(:users, on_delete: :restrict) # Prevent delete add :user_id, references(:users, on_delete: :nothing) # No action ``` ### 4. Use Precision for Decimals ```elixir # Good add :price, :decimal, precision: 10, scale: 2 # Bad (database decides precision) add :price, :decimal ``` ### 5. Make Constraints Explicit ```elixir # Email should be unique and not null add :email, :string, null: false create unique_index(:users, [:email]) ``` ### 6. Test Rollbacks Locally ```bash # After creating migration mix ecto.migrate mix ecto.rollback mix ecto.migrate ``` ## Troubleshooting ### Migration Fails **Column already exists:** ```bash # Check current schema mix ecto.migrations # Drop and recreate if in development mix ecto.drop && mix ecto.create && mix ecto.migrate ``` **Can't rollback:** - Check if migration uses `change` vs `up`/`down` - Review the migration for non-reversible operations - May need to write custom `down` function **Lock timeout:** ```elixir # Add timeout to migration @disable_ddl_transaction true # For operations that can't run in transaction @disable_migration_lock true # For long-running migrations def change do # Migration code end ``` ### Data Migration Issues **Timeout on large tables:** - Use batching - Consider running outside of migration (Rails-style rake task) - Use `@disable_ddl_transaction true` **References to application code:** - Be careful with schema changes - Application code might change, migration won't - Consider using raw SQL for data migrations ## Advanced Patterns ### Concurrent Index Creation (PostgreSQL) ```elixir @disable_ddl_transaction true def change do create index(:posts, [:user_id], concurrently: true) end ``` ### Conditional Migrations ```elixir def change do if function_exported?(MyApp.Repo, :__adapter__, 0) do # Migration code end end ``` ### Timestamps Helper ```elixir create table(:users) do add :name, :string timestamps() # Adds inserted_at and updated_at end ```