ruby 64 lines · 3 tabs

Postgres JSONB Partial Index for Feature Flags

Shared by codesnips Jan 2026
3 tabs
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
3 files · ruby Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Postgres JSONB Partial Index for Feature Flags — share card
Link copied