Safely removing Rails database columns without breaking production
Dropping a database column looks harmless until you remember that production does not move in one clean motion. There is the database migration. There are old app processes still serving requests. There is Active Record's schema cache. There are background jobs, consoles, and maybe a worker that nobody restarted because it has been quiet for months.
That is why "remove the column" is usually the least interesting part of the work. The real job is getting the application to stop caring about the column before the database stops storing it.
The trap in the obvious migration
Imagine an accounts table with a legacy_status column. The new billing flow uses a proper state record now, the column is no longer useful, and the migration almost writes itself.
class DropLegacyStatusFromAccounts < ActiveRecord::Migration[8.0]
def change
remove_column :accounts, :legacy_status, :string
end
end
The migration is correct Ruby. It may even be correct SQL. The problem is timing.
In a typical deployment, the migration can run before every process is using the new code. Old Rails processes may still have the model schema cached in memory. If those processes still believe accounts.legacy_status exists, Active Record can build queries around a column that the database has already dropped. Under traffic, that small deployment gap is enough to turn a boring cleanup into production errors.
This is the part that trips people up: the code does not need to explicitly call account.legacy_status to be risky. Active Record builds column-aware SQL from its model schema. When the model's view of the table and the database's real table shape disagree, harmless-looking reads and writes can fail.
Make the application forget first
Rails gives us a simple tool for this: ignored_columns. Put the column on the model's ignore list, deploy that change, and let production run with the column still present in the database.
class Account < ApplicationRecord
self.ignored_columns += %w[legacy_status]
end
After that deploy, Active Record behaves as if the column does not exist. The column is removed from the model's column metadata, Rails does not define the normal attribute methods for it, and generated SQL should stop referencing it. The database still has the data, so rollback is cheap. The application has already moved on, so the later drop is much less dramatic. This turns the operation into two deploys.
First deploy the model change with ignored_columns. Watch the app, the jobs, and the tests. If anything still expects the attribute, it should now fail while the database column is still available. That failure is useful. It tells you which code path was missed before you have destroyed any data.
Then, after the app has been fully rolled out and has run cleanly, deploy the migration that removes the column.
class DropLegacyStatusFromAccounts < ActiveRecord::Migration[8.0]
def change
remove_column :accounts, :legacy_status, :string
end
end
Now the migration no longer has to race the old application code. The deployed app stopped asking Active Record about legacy_status in the previous release.
Why this works
ignored_columns changes the model's view of the schema, not the database schema itself. That distinction is the whole trick.
When you assign ignored columns, Rails stores those names as strings and refreshes the model's cached schema information. The next time Active Record loads column metadata for the model, the ignored names are filtered out before the result is cached again. Methods that depend on column metadata, such as columns_hash, column_names, generated attribute methods, and the attributes builder, now work from the filtered shape.
So the database may still have legacy_status, but Account no longer treats it as an attribute. If some application code calls account.legacy_status, you get a loud failure while the system is still recoverable. Remove the ignore line and the column comes back into view.
That is a very different failure mode from dropping the column first and discovering that a worker, callback, report, or console script still touches it.
When the column still contains data
If the column contains data you need to keep, do not jump straight to ignored_columns. Move the product behavior first.
Suppose users.phone_number is being replaced by a phone_numbers table because users can now have multiple numbers. The safe flow starts by creating the new table and backfilling it. Then the app reads from and writes to the new table. During the transition, you may dual-write if rollback safety matters. Only after the old column is no longer part of the application behavior should you add it to ignored_columns.
That order matters. ignored_columns is a retirement signal, not a migration plan for live data. It tells Rails to stop seeing the column. If the business still depends on the data inside that column, you have more work to do before you can safely make it invisible.
What ignored_columns does not protect
This pattern is an Active Record pattern. It does not magically rewrite raw SQL, database views, triggers, BI exports, cron scripts, or external services that query the database directly.
If you have find_by_sql, connection.execute, handcrafted report queries, or application code outside the Rails model layer, search for the column name before treating the drop as safe. The database will happily accept raw SQL that references the column until the day the column disappears. After that, those queries fail exactly as before.
The same caution applies to long-running processes. A staged deploy assumes every relevant process eventually picks up the code that ignores the column. Web processes, job workers, scheduled task runners, and consoles all count. If a process can write to the table, it needs to be part of the rollout story.
Make destructive changes boring
ignored_columns fits that rhythm beautifully because it gives the Rails model a temporary shape that is stricter than the database. The app gets to practice life after the column while the database still keeps a safety net underneath it. When the actual drop arrives, it should feel almost anticlimactic.
That is the goal. Production schema changes should be boring, observable, and reversible until the last possible moment.
Happy dropping!