Dropping a column from a database table is one of the simplest possible migrations, if you just look at the syntax. But if you run that migration in a large application used by a ton of users where taking the application down for maintenance is not an option, you may run into problems.
One thing I didn't realize until researching this post: ActiveRecord caches each model's schema the first time the model is loaded, and in production, with eager loading on, that means the whole schema is effectively cached at boot.
This matters because of the deploy order. The migration runs first, then the app server restarts onto the new schema. In between, there's a window where the old process is still serving traffic with its cached view of the schema. If a request comes in during that window and the migration dropped a column, the running app will try to SELECT or INSERT a column that no longer exists and you get errors in production.
Rails provides a mechanism to handle this cleanly: ignored_columns. Let's learn what problem it solves and how it works. We'll also look at some techniques to use for safely dropping important columns in heavily trafficked, critical databases. I didn't come up with them, these are just industry-wide best practices and you can learn more about them by reading this book: Refactoring Databases
Problem with dropping columns directly
Consider a projects table with a category column you no longer need. The naive approach is to write a migration and deploy it.
class DropCategoryFromProjects < ActiveRecord::Migration[8.0]
def change
remove_column :projects, :category, :string
end
end
This works fine if your deployment process restarts the application and runs migrations atomically, with no traffic hitting the app in between. But most production deployments don't work that way. In a typical setup, the migration runs first, the column is removed from the database, and then the application code is restarted. During that gap, the old application code is still running with a cached schema that includes category. Every query that touches the projects table, whether it explicitly references category or not, can break.
The exact error depends on how ActiveRecord builds its queries. If you're doing Project.create!(name: "foo"), ActiveRecord might generate an INSERT that includes every column it knows about, setting unspecified ones to their defaults. The database will reject the query because the column no longer exists. SELECT queries using * will also return a different number of columns than ActiveRecord expects, causing attribute mapping errors.
Even if the deployment is fast and the window is small, it is not zero and under load, that window is more than enough time for requests to fail.
The fix is to tell ActiveRecord to stop using the column before you drop it. That's what ignored_columns does.
How ignored_columns solves this
First, you add the column that you want to drop to an ignored columns list, as follows.
class Project < ApplicationRecord
self.ignored_columns += ["category"]
end
Once a column is in ignored_columns, ActiveRecord pretends it doesn't exist. It won't appear in columns_hash, won't have an attribute accessor, and won't be included in any generated SQL. The column is still in the database, but the application no longer knows about it.
This turns a risky one-step operation into a safe two-step process:
Step 1: Add the column to ignored_columns and deploy the application. At this point, the column still exists in the database, but no code references it. You can verify this by running your test suite. If anything was still using the column, you'll get NoMethodError or query failures in your tests rather than in production.
Step 2: After the first deploy is fully rolled out and stable, write and deploy the migration to actually drop the column.
class DropCategoryFromProjects < ActiveRecord::Migration[8.0]
def change
remove_column :projects, :category, :string
end
end
Because the application is already ignoring the column, the migration can run without causing any errors. There's no dangerous window. The application doesn't care whether the column exists or not, because it stopped looking for it in the previous deploy.
How it works
The implementation is worth understanding, because it shows how ActiveRecord's schema caching works and why the timing problem exists in the first place.
When you set ignored_columns on a model, the setter does three things:
# activerecord/lib/active_record/model_schema.rb
def ignored_columns=(columns)
check_model_columns(@only_columns.present?)
reload_schema_from_cache
@ignored_columns = columns.map(&:to_s).freeze
end
First, it checks that you're not also using only_columns (a newer addition that takes the opposite approach: you specify the columns you want instead of the ones you don't). You can't use both.
Second, it calls reload_schema_from_cache, which clears all the cached column data: @columns_hash, @columns, @column_names, @attributes_builder, and several other cached values. It also sets @schema_loaded = false and recursively invalidates all subclasses.
Third, it stores the ignored column names as frozen strings.
The filtering itself happens in load_schema!, which runs the next time anything accesses the model's column information:
def load_schema!
columns_hash = schema_cache.columns_hash(table_name)
if only_columns.present?
columns_hash = columns_hash.slice(*only_columns)
elsif ignored_columns.present?
columns_hash = columns_hash.except(*ignored_columns)
end
@columns_hash = columns_hash.freeze
end
The model fetches the full column hash from the schema cache (which reflects the actual database schema), then removes the ignored columns using Hash#except. The filtered result is frozen and cached. From this point on, every method that depends on columns_hash (which is most of ActiveRecord's column-related API) works with the filtered set. columns, column_names, attribute_names, the attributes builder, all of them derive from this filtered hash.
The inheritance behavior is also worth noting. When a subclass is created, its @ignored_columns is set to nil, which means the getter falls through to the superclass:
def ignored_columns
@ignored_columns || superclass.ignored_columns
end
So if you set ignored_columns on ApplicationRecord, every model in your app inherits it. If you set it on a specific model, only that model and its STI subclasses are affected.
When the column holds critical data
The two-step process above assumes you're dropping a column you don't need anymore. When the column contains data that needs to go somewhere else, the process gets a few more steps, but ignored_columns still plays the same role.
Say you're extracting a phone_number column from users into a separate phone_numbers table because users can now have multiple phone numbers.
Step 1: Create the new table and backfill the data, while keeping the old column intact.
class CreatePhoneNumbers < ActiveRecord::Migration[8.0]
def up
create_table :phone_numbers do |t|
t.references :user, null: false, foreign_key: true
t.string :number, null: false
t.boolean :primary, default: true
t.timestamps
end
execute <<-SQL
INSERT INTO phone_numbers (user_id, number, created_at, updated_at)
SELECT id, phone_number, NOW(), NOW()
FROM users
WHERE phone_number IS NOT NULL
SQL
end
def down
drop_table :phone_numbers
end
end
Step 2: Update the application code to read from and write to the new phone_numbers table. During this transition, you might dual-write to both the old column and the new table to make rollbacks safe.
Step 3: Once all code uses the new table and the old column is no longer referenced, add it to ignored_columns and deploy.
class User < ApplicationRecord
self.ignored_columns += ["phone_number"]
has_many :phone_numbers
end
Step 4: Drop the column.
class DropPhoneNumberFromUsers < ActiveRecord::Migration[8.0]
def change
remove_column :users, :phone_number, :string
end
end
Each step is independently deployable and reversible. If something goes wrong at step 2, the old column still has the data. If something breaks at step 3, you remove the ignored_columns entry and the column comes back into view without any data loss.
Note: It's important to remember that ignored_columns only affects ActiveRecord's view of the schema. Raw SQL queries using execute or find_by_sql that reference the column will still work (or break) based on the actual database state, not the ignored columns list. If you have raw SQL that touches the column, you need to update that code separately.