Feature Flags in Rails: Ship Faster, Break Less
Last month, a client pushed a new checkout flow to production at 2 PM on a Friday. By 2:15 PM, conversion rates had dropped 40%. The fix took three hours to write, test, and deploy. With a feature flag, they could have disabled the new flow in under ten seconds.
Feature flags let you decouple deployment from release. You push code to production but control who sees it and when. When things go wrong—and they will—you flip a switch instead of scrambling for a hotfix.
The Simplest Flag That Works
Before reaching for a gem, consider how little you actually need:
# config/feature_flags.yml
production:
new_checkout: false
beta_dashboard: true
# app/models/feature_flag.rb
class FeatureFlag
def self.enabled?(flag)
config = Rails.application.config_for(:feature_flags)
config[flag.to_s] == true
end
end
In your views and controllers:
<% if FeatureFlag.enabled?(:new_checkout) %>
<%= render "checkout/new_flow" %>
<% else %>
<%= render "checkout/legacy_flow" %>
<% end %>
This works. Changing a flag requires a deploy, but for many teams that’s fine. Deploys should be boring anyway.
When You Need More Control
The YAML approach falls apart when you need:
- Per-user or percentage-based rollouts
- Instant toggling without deploys
- Different flags per environment
- Audit trails of who changed what
At this point, move the flags to your database:
# db/migrate/create_feature_flags.rb
class CreateFeatureFlags < ActiveRecord::Migration[7.1]
def change
create_table :feature_flags do |t|
t.string :name, null: false, index: { unique: true }
t.boolean :enabled, default: false
t.integer :rollout_percentage, default: 0
t.jsonb :metadata, default: {}
t.timestamps
end
end
end
# app/models/feature_flag.rb
class FeatureFlag < ApplicationRecord
def self.enabled?(name, user: nil)
flag = find_by(name: name)
return false unless flag
return flag.enabled if user.nil?
flag.enabled && within_rollout?(flag, user)
end
def self.within_rollout?(flag, user)
return true if flag.rollout_percentage >= 100
return false if flag.rollout_percentage <= 0
# Consistent hashing: same user always gets same result
hash = Digest::MD5.hexdigest("#{flag.name}:#{user.id}").to_i(16)
(hash % 100) < flag.rollout_percentage
end
end
The consistent hashing matters. Without it, a user might see the feature on one page load and not the next. Confused users file bug reports.
Building an Admin Interface
Flags in the database need a way to toggle them. A basic admin controller:
class Admin::FeatureFlagsController < AdminController
def index
@flags = FeatureFlag.order(:name)
end
def update
@flag = FeatureFlag.find(params[:id])
old_state = @flag.enabled
if @flag.update(flag_params)
AuditLog.record(
user: current_admin,
action: "feature_flag_changed",
details: {
flag: @flag.name,
from: old_state,
to: @flag.enabled
}
)
redirect_to admin_feature_flags_path, notice: "Flag updated"
else
render :index, status: :unprocessable_entity
end
end
private
def flag_params
params.require(:feature_flag).permit(:enabled, :rollout_percentage)
end
end
The audit log seems like overhead until the day someone asks “who turned on that feature at 4 AM?” You’ll want that answer.
Percentage Rollouts Done Right
Rolling out to 10% of users sounds straightforward. The gotchas hide in the details.
First, decide what “10%” means. Is it 10% of all users? 10% of active users? 10% of requests? For user-facing features, percentage of users makes sense. For performance experiments, percentage of requests might work better.
Second, monitor the rollout cohort separately:
# In your checkout controller
def create
using_new_checkout = FeatureFlag.enabled?(:new_checkout, user: current_user)
StatsD.increment(
"checkout.attempt",
tags: ["new_flow:#{using_new_checkout}"]
)
if using_new_checkout
# new flow logic
else
# legacy logic
end
end
If you can’t see whether the new flow performs better or worse, the flag isn’t doing its job. Ship the metrics alongside the feature. Good structured logging makes it easy to correlate flag state with request outcomes.
Cleaning Up Old Flags
Feature flags accumulate like browser tabs. A codebase littered with abandoned conditionals becomes hard to read and harder to maintain.
Set a policy: every flag gets a removal date. Track it in the metadata:
FeatureFlag.create!(
name: "new_checkout",
enabled: false,
metadata: {
owner: "payments-team",
remove_by: "2026-04-01",
ticket: "PAY-1234"
}
)
Run a weekly job that pings Slack when flags pass their removal date. Stale flags need attention—either they worked and should be permanent, or they didn’t and should go.
A pragmatic cleanup rake task:
# lib/tasks/feature_flags.rake
namespace :feature_flags do
desc "List flags past their removal date"
task stale: :environment do
today = Date.current
FeatureFlag.find_each do |flag|
remove_by = flag.metadata["remove_by"]&.to_date
next unless remove_by && remove_by < today
puts "STALE: #{flag.name} (should have been removed by #{remove_by})"
end
end
end
The Flipper Alternative
If building your own feels like reinventing wheels, Flipper handles this well:
# Gemfile
gem "flipper"
gem "flipper-active_record"
gem "flipper-ui"
# config/initializers/flipper.rb
Flipper.configure do |config|
config.default do
adapter = Flipper::Adapters::ActiveRecord.new
Flipper.new(adapter)
end
end
# Usage
if Flipper.enabled?(:new_checkout, current_user)
# new flow
end
# Rollout to 20%
Flipper.enable_percentage_of_actors(:new_checkout, 20)
# Enable for specific users
Flipper.enable_actor(:new_checkout, beta_user)
Flipper includes a web UI, percentage rollouts, actor-based targeting, and group-based rules. It’s mature and well-documented. For most Rails apps, it’s the right choice.
Flags in Tests
Feature flags in tests create flaky specs if you’re not careful. The flag state bleeds between examples.
Reset flags in your test setup:
# spec/rails_helper.rb
RSpec.configure do |config|
config.before(:each) do
FeatureFlag.update_all(enabled: false, rollout_percentage: 0)
end
end
For specs that need a flag enabled:
describe "new checkout flow" do
before do
FeatureFlag.find_or_create_by!(name: "new_checkout")
.update!(enabled: true)
end
it "shows the new payment options" do
# ...
end
end
Or create a helper that handles the before/after:
def with_feature(name, enabled: true)
flag = FeatureFlag.find_or_create_by!(name: name)
original = flag.enabled
flag.update!(enabled: enabled)
yield
ensure
flag.update!(enabled: original)
end
it "shows the new checkout when enabled" do
with_feature(:new_checkout) do
visit checkout_path
expect(page).to have_content("New checkout experience")
end
end
When Flags Hurt More Than Help
Feature flags add complexity. Every conditional path doubles the states your code can be in. Two flags means four combinations. Five flags means thirty-two.
Avoid flags for:
- Database schema changes (those need proper zero-downtime migrations, not flags)
- Bug fixes (just fix the bug)
- Changes nobody needs to roll back (renaming an internal class)
Use flags for:
- User-facing features with risk
- Gradual rollouts to catch performance issues
- A/B tests with measurable outcomes
- Features you might need to kill quickly
The goal is confidence, not coverage. Flag the things that scare you, not everything.
Making the Call
Start with the YAML approach if you deploy frequently and want minimal infrastructure. Move to database-backed flags when you need instant toggling or user-based rollouts. Consider Flipper when you’d rather configure than code.
Whatever you choose, keep the interface consistent. FeatureFlag.enabled?(:name) or Flipper.enabled?(:name) everywhere. When it’s time to remove a flag, a project-wide search should find every usage.
Feature flags won’t prevent all production incidents. They will make the 3 AM recovery faster. For a Friday afternoon deployment, that’s worth a lot.
Frequently Asked Questions
What’s the difference between feature flags and A/B testing?
Feature flags control whether a user sees a feature at all. A/B testing measures which variant performs better. In practice, feature flags are the mechanism that enables A/B tests — you use the flag to split traffic and your analytics to measure the outcome. You can run A/B tests without feature flags, but flags make it much easier to control the rollout.
Should feature flags live in the database or in configuration files?
Start with configuration files (YAML) if your team deploys frequently and you only need on/off toggles. Move to database-backed flags when you need instant toggling without deploys, percentage-based rollouts, or per-user targeting. The database approach adds a query per flag check, so consider caching flag values with a short TTL.
How many feature flags is too many?
There’s no magic number, but if you have more than 10-15 active flags, your codebase is accumulating complexity fast. Each flag doubles the possible code paths. Set removal dates on every flag and enforce cleanup. Most flags should live for weeks, not months.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation