class AddSettingsToAccounts < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :accounts, :settings, :jsonb, null: false, default: {}
add_index :accounts, "((settings->>'beta_ui')::boolean)",
name: "index_accounts_on_beta_ui",
where: "settings ? 'beta_ui'",
algorithm: :concurrently
add_index :accounts, :settings,
using: :gin,
opclass: :jsonb_path_ops,
name: "index_accounts_on_settings_gin",
algorithm: :concurrently
end
end
class Account < ApplicationRecord
store_accessor :settings, :beta_ui, :new_billing, :dark_mode
scope :with_flag, ->(key) do
where("settings @> ?", { key => true }.to_json)
end
scope :beta_ui_enabled, -> do
where("(settings->>'beta_ui')::boolean IS TRUE")
end
def flag?(key)
ActiveModel::Type::Boolean.new.cast(settings[key.to_s])
end
def set_flag!(key, value)
self.class.where(id: id).update_all([
"settings = jsonb_set(settings, ?, ?::jsonb, true)",
"{#{key}}", value.to_json
])
end
end
class FeatureFlagsController < ApplicationController
before_action :set_account
def index
render json: @account.settings
end
def toggle
key = params.require(:key)
unless Account.store_accessors.include?(key.to_s)
return render json: { error: "unknown flag" }, status: :unprocessable_entity
end
enabled = ActiveModel::Type::Boolean.new.cast(params[:enabled])
@account.set_flag!(key, enabled)
render json: { key => enabled }
end
private
def set_account
@account = Account.find(params[:account_id])
end
end
Feature flags are frequently stored as a jsonb column so a single row can hold an arbitrary bag of toggles without schema churn. The problem is querying them at scale: WHERE settings->>'beta_ui' = 'true' will sequentially scan the table unless Postgres has a matching index, and a full GIN index over the whole jsonb blob is large and indexes keys that are never queried. This snippet shows how to index only the flags that matter with expression-based partial indexes.
In migration, the settings column is added with a jsonb default of {} so no row ever has a NULL blob to special-case. Two indexes are created: a partial B-tree on the boolean expression (settings->>'beta_ui')::boolean that only includes rows where the key is present, and a narrow GIN index using the jsonb_path_ops operator class for containment (@>) queries. The partial predicate (where clause) keeps the index small — only rows that actually opted into beta_ui are stored — which is exactly the working set most flag lookups touch. algorithm: :concurrently plus disable_ddl_transaction! lets the index build without locking writes on a live table.
In Account model, store_accessor :settings exposes each flag as a normal attribute so callers never hand-write jsonb paths. The with_flag scope emits a containment query (settings @> ?) that the GIN index serves, while beta_ui_enabled targets the partial B-tree. Casting matters: JSON booleans round-trip as the strings "true"/"false", so the index expression and the query must agree on the cast, otherwise Postgres silently falls back to a scan.
In FeatureFlagsController, toggle uses jsonb_set inside an atomic update_all so concurrent flag writes to different keys on the same row don't clobber each other the way a read-modify-write in Ruby would. The main trade-off is that each queryable flag needs its own expression index, so this pattern suits a handful of hot flags rather than hundreds of rarely-read ones — for those, the GIN containment index alone is enough.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Share this code
Here's the card — post it anywhere.