Summary
When running rails db:migrate VERSION=X, if X points to a migration that is earlier than the current schema version, Rails silently runs down on every migration between X and the current version. This is documented behavior, but it is a common footgun in production. The command reads as “migrate to version X,” not “roll back everything after X.”
I would like to propose adding a safety layer when db:migrate VERSION=X would run one or more down migrations, and gather feedback on which of three possible designs would be the best fit for Rails.
Motivation
In a recent production deploy, the intent was to apply a single earlier migration that had been skipped. The correct command was VERSION=xxxxx rails db:migrate:up, but the :up was accidentally dropped and the command run was VERSION=xxxxx rails db:migrate. Because later migrations already existed in the schema, Rails ran down on all of them, dropping columns that contained data. The command gave no warning, no summary of what was about to happen, and no chance to confirm.
The migration output does print each up and down as it runs, but that only confirms the destruction after it has already happened. By the time the operator sees it in the log, the columns are gone. What is missing is a check before execution, when there is still a chance to stop.
This is not a new class of problem. The same footgun appears in older discussions (for example #27617, which addressed the related case of invalid VERSION values). But the case where VERSION is a valid, existing migration that happens to be earlier than the current schema is still silent and destructive.
Scope
The proposal only affects db:migrate VERSION=X when it would run at least one down migration. It does not affect:
-
db:migratewithoutVERSION(purely additive) -
db:migrate VERSION=XwhereXis ahead of current schema (purely additive) -
db:rollback,db:migrate:down,db:migrate:redo, which are explicitly named for their destructive intent
Possible designs
I am genuinely unsure which direction fits Rails best, and would appreciate input.
Option A: Fail loudly by default, opt in to proceed
If db:migrate VERSION=X would run any down migrations, raise an error listing the migrations that would be reverted, and require an explicit env var (for example ALLOW_DOWN=true) to proceed. This behaves the same in local and CI environments, and fails safely when the command is triggered by automation.
Option B: Interactive confirmation, with an override for non-interactive use
Print the list of migrations that would be reverted and ask the user to type yes to proceed. When stdin is not a TTY (CI, scripts), either auto-skip the prompt using an explicit env var (for example FORCE=true) or abort with an error requiring the same env var. This is more ergonomic for local development but adds a TTY branch to the logic.
Option C: Fail-loud by default, with the interactive prompt available as an opt-in
Ship Option A as the default behavior, and let application authors opt into Option B via a config flag such as config.active_record.migration_confirmation = :prompt. The default remains fail-loud (safer for CI and unattended environments), while teams that prefer interactive confirmation during local development can enable it explicitly. This gives both styles a home without forcing a choice on the framework level.
Rollout
Regardless of which option is chosen, the new behavior should be gated behind a config flag such as config.active_record.confirm_implicit_migration_rollbacks, loaded via config.load_defaults so existing applications are not affected on upgrade.
-
New applications generated on the Rails version that ships this feature would have it enabled automatically via
load_defaults. -
Existing applications upgrading to that version would see a commented-out line in the generated
new_framework_defaults_X.Y.rbfile, with a short comment explaining what the behavior change is. Teams opt in when they are ready, and their existing deploy scripts continue to work until they do.
This follows the same pattern Rails already uses for behavior changes like belongs_to_required_by_default, has_many_inversing, and similar.
Prior art
- #27617 addressed a related but distinct case (invalid
VERSIONvalues), closed after Rails 6 began raising errors for malformed versions. That fix does not apply here because theVERSIONvalue is valid. strong_migrationscovers some overlapping concerns, but this behavior originates in Rails core’s own default command, so I believe the safety layer belongs in core as well.
Open questions
- Which of the three designs feels most idiomatic for Rails?
- What should the env var be named? (
ALLOW_DOWN,FORCE, something else?) - Are
config.active_record.confirm_implicit_migration_rollbacks(to enable the feature) andconfig.active_record.migration_confirmation(to choose the mode) reasonable names, or are there better naming conventions?
Happy to open a PR once there is some direction on the design.