Skip to content

How to Use Feature Flags for Safe Database Migrations

Developer workspace showing software development environment

Rolling back database migrations is hard. When code and schema changes are deployed together, a failed migration means untangling both the database changes and the code changes. I learned this the hard way after a production incident where I had to coordinate a database revert with a code rollback, and the whole process took hours instead of minutes.

Feature flags changed how I approach database migrations. Instead of one massive migration that couples schema and code changes, I now break changes into thin slices that can be rolled back by flipping a configuration flag, no database revert required.

The Problem with Traditional Migrations

Traditional database migrations bundle schema changes and code changes together. When you deploy, you run both at the same time. If something goes wrong, you need to revert both. This creates several problems:

  1. Rollback complexity: Reverting a database migration is not always straightforward, especially if data has been modified.
  2. Tight coupling: Code expects specific schema, schema expects specific code.
  3. All-or-nothing risk: Either the entire migration succeeds, or you roll back everything.

The Feature Flag Solution

Feature flags decouple code deployment from schema activation. The idea is simple: deploy schema changes that are backward compatible, then use a flag to control which code path (old or new schema) is active.

Here’s the lifecycle:

Feature flag migration lifecycle
+------------------+ +------------------+ +------------------+
| 1. Add Schema | -> | 2. Toggle Code | -> | 3. Migrate Data |
| (backward | | (flag off) | | (gradually) |
| compatible) | | | | |
+------------------+ +------------------+ +------------------+
|
v
+------------------+ +------------------+ +------------------+
| 6. Drop Old | <- | 5. Toggle Old | <- | 4. Enable New |
| Schema | | Code Off | | Code Path |
| (next release) | | | | (flag on) |
+------------------+ +------------------+ +------------------+

If any step fails, flip the flag back. The old code path still works because the old schema is still there.

A Practical Example: Migrating from Password Auth to OAuth

Let’s say I need to migrate user authentication from password-based to OAuth-based. Here’s how I would approach this the traditional way versus the feature flag way.

Traditional Approach (Risky)

dangerous_migration.rb
# Dangerous: one migration changes everything
class ChangeUserAuthentication < ActiveRecord::Migration[5.0]
def change
remove_column :users, :password_hash # Removed immediately!
add_column :users, :oauth_provider, :string
add_column :users, :oauth_token, :string
end
end

This migration is risky because:

  • The password_hash column is gone immediately
  • Any code expecting that column will break
  • Rolling back requires adding the column back
  • Data migration (converting passwords to OAuth) is not handled

Feature Flag Approach (Safe)

Step 1: Add new columns (backward compatible)

add_oauth_columns.rb
class AddOAuthColumns < ActiveRecord::Migration[5.0]
def change
add_column :users, :oauth_provider, :string
add_column :users, :oauth_token, :string
# Keep password_hash column - don't remove yet
end
end

The old code still works. The new columns exist but are not used.

Step 2: Write code that handles both paths

user_authentication.rb
class UserAuthentication
def authenticate(user, credentials)
if FeatureFlags.enabled?(:oauth_auth)
OAuthService.authenticate(user, credentials)
else
LegacyPasswordAuth.authenticate(user, credentials)
end
end
end

The flag is off by default. All traffic uses the legacy path.

Step 3: Gradually enable new path

I enable the flag for a small percentage of users, monitor for errors, and gradually increase. If something goes wrong, I flip the flag back to false.

Step 4: Migrate existing data

migrate_users_to_oauth.rb
class MigrateUsersToOAuth
def migrate(user)
return if user.oauth_provider.present?
# Migrate user to OAuth based on their existing credentials
oauth_credentials = OAuthService.convert_from_password(user.password_hash)
user.update!(
oauth_provider: oauth_credentials[:provider],
oauth_token: oauth_credentials[:token]
)
end
end

Step 5: Remove old code path (flag permanently on)

After all users are migrated and the new system is stable, I remove the conditional code:

user_authentication_simplified.rb
class UserAuthentication
def authenticate(user, credentials)
OAuthService.authenticate(user, credentials)
end
end

Step 6: Drop deprecated schema (next major release)

remove_legacy_password_column.rb
class RemoveLegacyPasswordColumn < ActiveRecord::Migration[6.0]
def up
remove_column :users, :password_hash
end
def down
add_column :users, :password_hash, :string
end
end

This happens in a later release, giving plenty of time to ensure nothing depends on the old column.

Why This Matters for Teams

For small projects, a single migration might be fine. But for larger teams working on shared codebases, feature flags provide critical benefits:

  • Independent deployments: Database changes can go out without activating new code behavior.
  • Gradual rollout: Enable new features for a subset of users first.
  • Instant rollback: Flip a flag instead of reverting a migration.
  • Clear ownership: Schema changes and code changes become separate concerns.

As Martin Fowler notes, feature flags are a powerful technique for modifying system behavior without changing code. This applies equally well to database migrations.

Common Mistakes to Avoid

  1. Performing massive schema changes in one migration: Break changes into thin slices.
  2. Deploying code and schema simultaneously: Decouple them with feature flags.
  3. Skipping data migration planning: When changing column types or moving data, plan the migration path.
  4. Not keeping backward compatibility: The old code path should still work during the transition.

Summary

In this post, I showed how feature flags can make database migrations safer by decoupling code deployment from schema activation. The key is to break large schema changes into thin slices: add new schema elements first, use flags to control which code path is active, migrate data gradually, and clean up deprecated schema in a later release. This approach turns rollback from a database operation into a configuration change, which is much easier to execute and reverse.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments