EctoRollbackGuard

Hex.pmCI

Know what you'll lose before you rollback.

EctoRollbackGuard analyzes Ecto migration source files to detect destructive rollback operations — table drops, column removals, irreversible migrations — before you execute them. Optionally enriches results with live row counts from PostgreSQL.

Why

You deploy. Something goes wrong. You need to rollback.

Ecto's change/0 migrations are reversible by design — but "reversible" doesn't mean "safe". When you roll back a create table(:users) migration, Ecto drops the table and every row in it. There's no confirmation, no warning, and no indication of how much data you're about to lose.

In a production incident, that's exactly the moment you're least equipped to manually review migration files. You need tooling that tells you what will happen before it happens.

EctoRollbackGuard answers one question: "What will I lose if I revert to version X?"

Real-world deployment integration

EctoRollbackGuard is designed to plug into your deployment pipeline at multiple points:

1. Before rollback — in your Release module

Log a full impact report with row counts before executing the rollback. This gives operators visibility in production logs:

🔴 20260318_create_email_signups — DROP TABLE email_signups (~1,847 rows)
🟢 20260315_add_email_index — DROP INDEX (safe)

2. At deploy time — in CI/CD

Use the mix task or programmatic API to analyze migrations as part of your rollback pipeline. Feed the results into Slack alerts, Buildkite annotations, or PagerDuty:

# In your rollback script
mix ecto_rollback_guard.preview --to $ROLLBACK_VERSION --format json

3. During development — in your terminal

Preview what a rollback would do before you run it:

mix ecto_rollback_guard.preview --to 20230101120000

Ecosystem position

excellent_migrations guards the forward path — "is this migration safe to deploy?"

EctoRollbackGuard guards the reverse path — "what will I lose if I revert?"

They're complementary. Use both.

Installation

Add to your mix.exs:

def deps do
  [
    {:ecto_rollback_guard, "~> 0.2"}
  ]
end

Usage

Mix task

$ mix ecto_rollback_guard.preview --to 20230101120000

Rollback Impact Preview
=======================

[destructive] 20260318_create_email_signups
  DROP TABLE email_signups (~1,847 rows)

[safe] 20260315_add_email_index
  DROP INDEX on users(email) (safe)

1 destructive operation(s). ~1,847 rows at risk.

Options:

In a Release module

defmodule MyApp.Release do
  def rollback(repo, version) do
    load_app()

    {:ok, _, _} =
      Ecto.Migrator.with_repo(repo, fn repo ->
        EctoRollbackGuard.log_preview(repo, version)
        Ecto.Migrator.run(repo, :down, to: version)
      end)
  end

  defp load_app do
    Application.load(:my_app)
  end
end

Programmatic API

# Detect from source text (pure function, no DB needed)
EctoRollbackGuard.detect(source)
#=> [{:drop_table, :users}, {:drop_column, :entities, :mobile_number}]

# Check if any operations are destructive
EctoRollbackGuard.destructive?(operations)
#=> true

# Enrich with approximate row counts
EctoRollbackGuard.enrich(operations, MyApp.Repo)
#=> [{:drop_table, :users, 1847}, {:drop_column, :entities, :mobile_number}]

# Full preview — discovers migrations, detects, enriches
{:ok, impacts} = EctoRollbackGuard.preview(MyApp.Repo, 20230101120000)

What it detects

In change/0 (reversed on rollback)

Migration code Detected rollback operation
create table(:users){:drop_table, :users}
create_if_not_exists table(:users){:drop_table, :users}
alter table(:users) do add :col{:drop_column, :users, :col}
alter table(:users) do remove :col (no type) {:non_reversible_remove, ...}
alter table(:users) do modify :col, :type (no :from) {:non_reversible_modify, ...}
alter table(:users) do modify :col, :bigint, from: :integer{:type_narrowing_risk, ...}
execute("up_sql", "DROP TABLE users"){:drop_table, "users"}
execute("single_arg_sql"){:non_reversible_execute}

In down/0 (executed directly)

Migration code Detected operation
drop table(:users){:drop_table, :users}
alter table(:users) do remove :col{:drop_column, :users, :col}
execute "DROP TABLE users"{:drop_table, "users"}
execute "some sql" (unrecognized) {:raw_sql}
def down, do: :ok{:irreversible}

Safe operations (indexes, renames, reversible removes) are detected but not flagged as destructive.

When both down/0 and change/0 exist, down/0 takes priority — matching Ecto's behavior.

How it works

EctoRollbackGuard parses migration source with Code.string_to_quoted/1 and pattern-matches on the Elixir AST. This is more reliable than regex:

Row count enrichment queries pg_class.reltuples for speed, with a bounded count(*) fallback for small tables. PostgreSQL only for v0.1.

Limitations

Contributing

# Clone and setup
git clone https://github.com/tommeier/ecto_rollback_guard.git
cd ecto_rollback_guard

# Install dependencies
mix deps.get

# Create test database (requires PostgreSQL running)
mix ecto.create

# Run the full suite
mix test              # 112 tests
mix credo --strict    # static analysis
mix format --check-formatted
mix dialyzer          # type checking (slow on first run)

PRs welcome. Please include tests for new detection patterns.

Releasing

  1. Update version in mix.exs
  2. Update CHANGELOG.md with a new ## vX.Y.Z section
  3. Update the version in the installation section of this README
  4. Commit: git commit -am "Bump version to X.Y.Z"
  5. Tag and push:
    git tag vX.Y.Z
    git push origin main --tags
  6. GitHub Actions will auto-create a GitHub release from the changelog
  7. Publish to Hex:
    mix hex.publish
    mix hex.publish docs

License

MIT — see LICENSE.