How to Build a Feature Flag System in Ruby on Rails That Protects Your Users
Learn how to build a feature flag system in Ruby on Rails from scratch — control rollouts, test in production safely, and ship with confidence.
Feature flags are a simple idea that can save you from a lot of trouble. Instead of deploying code that everyone can see right away, you hide it behind a switch. You turn that switch on for yourself first, then for a few test users, and finally for everyone. This way you can test in production without breaking things for your users. I have used this trick for years, and it never gets old. Let me walk you through building your own feature flag system in Ruby on Rails. I will keep everything plain and step by step, like I am sitting next to you.
Why you need a simple switch
Imagine you just finished a new checkout flow. You are proud of it. But you also know that your old checkout works fine. You do not want to risk a bad experience for all customers at once. So you hide the new code behind a flag. You turn it on for your team, test it live, fix bugs, then roll it out to one percent of users, then ten, then all. This is the safe way to ship.
I once worked on a team that deployed a big redesign without flags. It broke the payment page for an hour. After that, I swore I would never ship a feature without a switch again.
The simplest possible flag: environment variables
You can start with almost nothing. A boolean in your environment file works for a single developer or a small team.
# config/initializers/feature_flags.rb
ENV['NEW_CHECKOUT'] = 'true' if Rails.env.development?
Then in your code you check:
if ENV['NEW_CHECKOUT'] == 'true'
# render new checkout
else
# render old checkout
end
This is fine for learning. But it has problems. You cannot change the flag without redeploying. You cannot control who sees what. And if you forget to set the variable in production, the feature stays off. I have done that mistake, and it cost me a late night.
Moving flags to the database
A better approach stores flags in the database. You can change them at runtime through a simple admin page. I prefer a single model called FeatureFlag. Here is the migration:
# db/migrate/20250101000000_create_feature_flags.rb
class CreateFeatureFlags < ActiveRecord::Migration[7.0]
def change
create_table :feature_flags do |t|
t.string :name, null: false
t.boolean :enabled, default: false
t.integer :percentage, default: 0
t.timestamps
end
add_index :feature_flags, :name, unique: true
end
end
The name is the key you will use in code. enabled is a simple on/off. percentage is for gradual rollouts to a random slice of users.
Now create the model:
# app/models/feature_flag.rb
class FeatureFlag < ApplicationRecord
validates :name, presence: true, uniqueness: true
def enabled_for_user?(user = nil)
return false unless enabled
return true if percentage >= 100
return false if percentage <= 0
# deterministic hash based on user id or random
return true if user.nil? && percentage > 0
user_id = user&.id || rand(100_000)
(user_id % 100) < percentage
end
end
The enabled_for_user? method uses a simple modulo trick. If a user has id 42 and the percentage is 10, then 42 % 100 = 42, which is not less than 10, so the flag is off for that user. This means the same user will always see the same experience as long as their id does not change. For anonymous users, we fall back to randomness.
A helper to check flags in views and controllers
You do not want to call FeatureFlag.find_by(name: 'new_checkout') everywhere. Create a little service object or a helper.
# app/helpers/feature_flag_helper.rb
module FeatureFlagHelper
def feature_enabled?(name, user = nil)
flag = FeatureFlag.find_by(name: name)
return false unless flag
flag.enabled_for_user?(user)
end
end
Then in your application controller, include the helper so it is available everywhere:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include FeatureFlagHelper
helper_method :feature_enabled?
end
Now in a view:
<% if feature_enabled?('new_checkout', current_user) %>
<%= render 'checkouts/new_checkout' %>
<% else %>
<%= render 'checkouts/old_checkout' %>
<% end %>
In a controller you can use it the same way:
class CheckoutsController < ApplicationController
def show
if feature_enabled?('new_checkout', current_user)
# new logic
else
# old logic
end
end
end
This is already powerful. But you still have a problem: every check hits the database. That is slow on every request. We need caching.
Caching flag checks to avoid database queries
Rails has low-level caching. I store each flag in the cache for a few seconds. This way the database is not hammered.
# app/models/feature_flag.rb
class FeatureFlag < ApplicationRecord
after_save :clear_cache
def self.cached_flag(name)
Rails.cache.fetch("feature_flag:#{name}", expires_in: 5.minutes) do
find_by(name: name)
end
end
def enabled_for_user?(user = nil)
# same as before, but uses cached_flag inside helper
end
private
def clear_cache
Rails.cache.delete("feature_flag:#{name}")
end
end
Update the helper to use the cached version:
module FeatureFlagHelper
def feature_enabled?(name, user = nil)
flag = FeatureFlag.cached_flag(name)
return false unless flag
flag.enabled_for_user?(user)
end
end
Now when you change a flag in the admin panel, the cache is cleared automatically because of the after_save callback. This is fast and works well for most applications.
Adding an admin interface
You need a way to turn flags on and off without touching the database console. I build a simple admin controller under namespace Admin.
# config/routes.rb
namespace :admin do
resources :feature_flags, only: [:index, :edit, :update]
end
Controller:
# app/controllers/admin/feature_flags_controller.rb
module Admin
class FeatureFlagsController < ApplicationController
before_action :require_admin
def index
@flags = FeatureFlag.all
end
def edit
@flag = FeatureFlag.find(params[:id])
end
def update
@flag = FeatureFlag.find(params[:id])
if @flag.update(flag_params)
redirect_to admin_feature_flags_path, notice: 'Flag updated.'
else
render :edit
end
end
private
def flag_params
params.require(:feature_flag).permit(:enabled, :percentage)
end
end
end
Views are straightforward. A form with a checkbox for enabled and a number field for percentage. I wire them with simple Bootstrap or just plain HTML.
Here is the edit view:
<!-- app/views/admin/feature_flags/edit.html.erb -->
<h1>Edit <%= @flag.name.humanize %></h1>
<%= form_with model: [:admin, @flag] do |f| %>
<div>
<%= f.label :enabled %>
<%= f.check_box :enabled %>
</div>
<div>
<%= f.label :percentage %>
<%= f.number_field :percentage, min: 0, max: 100 %>
</div>
<%= f.submit 'Save' %>
<% end %>
Add a simple require_admin method in your ApplicationController:
def require_admin
redirect_to root_path unless current_user&.admin?
end
Now any admin can go to /admin/feature_flags and change flags in real time. No code change needed.
Gradual rollout with A/B testing
The percentage field lets you do gradual rollouts. But I found that random modulo based on user id is not perfect for anonymous users. Sometimes you want to ramp up traffic slowly. I use a background job that increases the percentage every hour until it reaches 100.
Create a simple rake task or a cron job:
# lib/tasks/feature_flags.rake
namespace :feature_flags do
desc "Gradually enable a feature flag by 10% every 10 minutes"
task :ramp_up, [:name] => :environment do |t, args|
flag = FeatureFlag.find_by!(name: args[:name])
return if flag.percentage >= 100
new_percentage = [flag.percentage + 10, 100].min
flag.update!(percentage: new_percentage)
puts "#{flag.name} now at #{new_percentage}%"
end
end
Schedule it with cron or Heroku Scheduler to run every 10 minutes.
A more advanced flag: targeting specific users or groups
Sometimes you want to turn a flag on for specific user ids, like your QA team. Add a user_ids column to the feature_flags table.
# migration
add_column :feature_flags, :user_ids, :text, array: true, default: []
Then update the model:
def enabled_for_user?(user = nil)
return false unless enabled
return true if user_ids.include?(user&.id)
return true if percentage >= 100
# gradual rollout for others
user_id = user&.id || rand(100_000)
(user_id % 100) < percentage
end
In the admin form you can allow a comma-separated list.
Testing feature flags in your specs
You need to make sure your flags work correctly in tests. I write a simple helper for RSpec.
# spec/support/feature_flags.rb
module FeatureFlagTestHelper
def enable_feature(name)
FeatureFlag.find_or_create_by!(name: name).update!(enabled: true, percentage: 100)
end
def disable_feature(name)
FeatureFlag.find_or_create_by!(name: name).update!(enabled: false, percentage: 0)
end
end
RSpec.configure do |config|
config.include FeatureFlagTestHelper
end
In tests:
RSpec.describe CheckoutsController do
scenario 'new checkout when flag is on' do
enable_feature('new_checkout')
get :show
expect(response).to render_template('checkouts/new_checkout')
end
scenario 'old checkout when flag is off' do
disable_feature('new_checkout')
get :show
expect(response).to render_template('checkouts/old_checkout')
end
end
Remember to clean the database between tests, because flags persist.
Handling errors gracefully
If the feature_flags table does not exist yet (during migrations or first deployment), your app should not crash. Wrap the lookup in a rescue.
def feature_enabled?(name, user = nil)
flag = FeatureFlag.cached_flag(name)
return false unless flag
flag.enabled_for_user?(user)
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
false
end
I also add a fallback in the initializer:
if ActiveRecord::Base.connection.table_exists?(:feature_flags) rescue false
# already covered
end
But usually the rescue is enough.
A note on naming and conventions
I use snake_case for flag names: new_checkout, dark_mode, beta_search. Keep them short and descriptive. If you have many flags, consider grouping them by module. For example, checkout_v2, search_autocomplete. Avoid long names like use_new_authentication_flow_with_oauth. Keep it simple.
Personally, I have a list in config/initializers/feature_flags.rb that seeds the database with default flags on deploy:
%w[
new_checkout
dark_mode
beta_search
].each do |name|
FeatureFlag.find_or_create_by!(name: name) do |flag|
flag.enabled = false
flag.percentage = 0
end
end
This ensures every environment has the same flags.
Performance considerations
If you have a hundred flags, every request will issue a hundred cache lookups. That is fine if each cache lookup is fast (a few milliseconds). But if you have very high traffic, you can cache the entire flag set in one key.
def self.all_cached
Rails.cache.fetch('feature_flags:all', expires_in: 5.minutes) do
FeatureFlag.all.index_by(&:name)
end
end
Then in the helper:
def feature_enabled?(name, user = nil)
flags = FeatureFlag.all_cached
flag = flags[name]
return false unless flag
flag.enabled_for_user?(user)
end
This reduces cache calls to one per request.
When to remove a flag
Once a feature is fully rolled out to everyone, you should remove the flag from the code. Otherwise you end up with dead code and confusing conditionals. I add a comment in the code like # REMOVE_FLAG: new_checkout after 2025-04-01 to remind myself. Then after the rollback period, I delete the old code path and the flag.
I once left a flag in production for three months. It confused a new developer who thought the feature was still experimental. Clean up early.
A complete example with background job for flag expiration
You can add an expires_at column to the flags so they auto-disable after a certain date.
add_column :feature_flags, :expires_at, :datetime
Add a nightly job:
class DisableExpiredFlagsJob < ApplicationJob
def perform
FeatureFlag.where('expires_at < ?', Time.current).update_all(enabled: false)
end
end
Schedule it with cron.
Personal touches and lessons
The first feature flag system I built did not have caching. I wondered why the app slowed down. Then I added cache and it was fine. Later I forgot to clear the cache after updating a flag via a script, and the old value stuck for five minutes. I learned to always clear the cache in the model.
Another time I used random percentages without user ids. The same user would see the new feature on one page and the old on another because the random number changed. That was confusing. The deterministic modulo fix solved it.
Also, do not use flags for long-term experiments. If a feature stays behind a flag for months, it becomes a burden. Ship it fully or kill it.
Putting it all together
Here is the skeleton of your feature flag system:
- A
FeatureFlagmodel with name, enabled, percentage, user_ids, expires_at. - A helper method that checks the flag with caching.
- A background job for gradual rollout.
- A simple admin interface.
- Test helpers.
- A cron job to expire flags.
You can start with a single flag in development, then gradually add more. The code is small, easy to understand, and does not require any extra gems. I have used this approach in production for years. It pays for itself the first time you roll out a risky change without incident.
Now go write your own flag system. Start with one small switch, and you will never ship blindly again.