/ Tags: RAILS / Categories: RAILS

ActiveRecord::Store — Structured Data in One Column Without a Migration Per Field

Some data doesn’t deserve a column. User interface preferences, per-account feature toggles, the last three filters someone applied to a report — attributes that are read constantly, queried almost never, and change shape every other sprint. Adding a migration for each one bloats the table and turns a five-minute change into a deploy. ActiveRecord::Store gives those attributes real accessors while keeping them in a single serialized column.

The Setup


You need one column to hold the bag. On PostgreSQL, make it jsonb.

Migration:

class AddPreferencesToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :preferences, :jsonb, null: false, default: {}
  end
end

Example:

class User < ApplicationRecord
  store_accessor :preferences, :theme, :timezone, :digest_frequency, :sidebar_collapsed
end

That’s it. Each name becomes a real getter and setter on the model:

user = User.new(theme: "dark", timezone: "Asia/Kathmandu")
user.theme                # => "dark"
user.sidebar_collapsed    # => nil
user.preferences          # => { "theme" => "dark", "timezone" => "Asia/Kathmandu" }

user.digest_frequency = "weekly"
user.save

They behave like columns in the places that matter — form builders, assign_attributes, strong parameters, update. A view doesn’t need to know theme isn’t a column.

store vs store_accessor


There are two entry points and the difference trips people up.

Example:

# store_accessor — column already exists, you only want the readers/writers
class User < ApplicationRecord
  store_accessor :preferences, :theme, :timezone
end

# store — declares the serialization coder as well
class User < ApplicationRecord
  store :settings, accessors: [:theme, :timezone], coder: JSON
end

Use store_accessor with a native jsonb/json column — the database handles serialization and you get queryability for free. Use store with coder: JSON when the underlying column is text, which is the portable option on MySQL and SQLite. Never use the default YAML coder for user-influenced data; YAML.unsafe_load on attacker-controlled input is a remote code execution vector, and even the safe loader will surprise you with type coercion.

Prefixes and Suffixes


Two stores on one model will collide on common names. Rails has a built-in namespace option.

Example:

class Account < ApplicationRecord
  store_accessor :billing_settings,      :currency, :invoice_email, prefix: true
  store_accessor :notification_settings, :currency, :digest_hour,   prefix: :notify
end

account.billing_settings_currency = "NPR"
account.notify_currency = "USD"

prefix: true uses the column name. Passing a symbol lets you shorten it. There’s a matching suffix: option when that reads better.

Querying Stored Attributes


This is where the trade-off lives. store_accessor gives you Ruby-level attributes, not SQL-level ones — User.where(theme: "dark") will raise, because there is no theme column.

On PostgreSQL with jsonb you can still query, you just have to say so:

Example:

User.where("preferences ->> 'theme' = ?", "dark")

# Containment — uses a GIN index
User.where("preferences @> ?", { theme: "dark" }.to_json)

# Rails 7.1+ shorthand for the containment operator
User.where(preferences: { theme: "dark" })

Index:

class IndexUserPreferences < ActiveRecord::Migration[7.1]
  disable_ddl_transaction!

  def change
    add_index :users, :preferences, using: :gin, algorithm: :concurrently

    add_index :users,
              "(preferences ->> 'theme')",
              name: "index_users_on_preferences_theme",
              algorithm: :concurrently
  end
end

A GIN index supports containment queries across the whole document. A B-tree on a single extracted expression is smaller and faster for one hot key. Pick based on which queries you actually run.

Wrap the raw SQL in a scope so the string doesn’t leak everywhere:

class User < ApplicationRecord
  scope :with_preference, ->(key, value) {
    where("preferences @> ?", { key => value }.to_json)
  }
end

Typing and Validating What You Store


A JSON bag has no schema, so user.sidebar_collapsed = "false" stores the string "false", which is truthy. This is the most common bug in store-backed attributes.

Example:

class User < ApplicationRecord
  store_accessor :preferences, :theme, :digest_hour, :sidebar_collapsed

  attribute :digest_hour, :integer
  attribute :sidebar_collapsed, :boolean

  validates :theme, inclusion: { in: %w[light dark system], allow_nil: true }
  validates :digest_hour, numericality: {
    only_integer: true, in: 0..23, allow_nil: true
  }
end

Declaring attribute alongside store_accessor applies Rails’ type casting to the stored value, so "7" becomes 7 and "false" becomes false on the way in. Validations then work exactly as they do on real columns, including error messages on the right attribute.

Defaults

store_accessor has no default option. Handle it in the reader or with a callback:

def theme
  super || "system"
end

Overriding with super keeps the setter and dirty tracking intact, which a ||= in a new method would not.

Dirty Tracking Gotchas


Rails tracks changes on the column, not on individual keys, and in-place mutation of the hash goes unnoticed.

Example:

user.theme = "dark"
user.changed?                        # => true
user.preferences_changed?            # => true
user.saved_change_to_theme?          # => works after save (Rails 7+)

# But:
user.preferences["theme"] = "dark"   # mutating the hash directly
user.changed?                        # => false — the object identity didn't change
user.save                            # saves nothing

Always go through the accessor, or call will_change! if you must mutate directly:

user.preferences_will_change!
user.preferences["theme"] = "dark"
user.save

Pro-Tip: The failure mode nobody plans for is the rename. Six months in, digest_frequency should have been email_digest_frequency, and now there are two hundred thousand rows with the old key and application code reading the new one — with no schema to tell you, and no error when a lookup silently returns nil. Treat stored keys as a versioned contract: keep the accessor list in the model as the single source of truth, write a data migration in the same PR as any rename, and add a spec that asserts the set of keys present in production data is a subset of the declared accessors. A store column that nobody audits becomes a landfill of dead keys within a year.

When to Use a Real Column Instead


Reach for a column when any of these are true:

  • You filter or sort on it in a list view. Expression indexes work, but a real column is simpler and the query planner likes it better.
  • It participates in a join or a foreign key. JSON can’t.
  • It has a NOT NULL business rule. Database-level constraints on JSON keys are possible via check constraints and are unpleasant to maintain.
  • More than a handful of rows share the same shape and you need aggregate reporting. SUM over an extracted JSON field is slower and harder to read than over a numeric column.
  • Another system reads the table directly. A BI tool or an ETL job shouldn’t need to know your key names.

Stores are for the long tail: sparse, optional, read-by-key, changing frequently. That’s a real category and it’s larger than most schemas admit — but it isn’t everything.

Conclusion


ActiveRecord::Store buys you attribute ergonomics without a migration per field, which is the right trade for preferences, toggles, and sparse metadata that no report will ever group by. Pair it with jsonb, declare attribute types so casting and validation behave, index the keys you actually query, and always write through the accessor so dirty tracking works. The discipline it demands is the one a schema normally enforces for you: know what keys exist, and migrate the data when they change.

FAQs


Q1: Does store_accessor work with strong parameters?
Yes — permit the accessor names individually, exactly as you would column names: params.require(:user).permit(:theme, :timezone). Permitting the whole hash requires permit(preferences: {}), which accepts arbitrary keys and is worth avoiding.

Q2: Can I use store_accessor with a hstore column?
You can, and Rails supports it, but hstore stores only string values with no nesting. jsonb is a superset for practically every use case and is the better default on modern PostgreSQL.

Q3: How do stored attributes behave in form_for?
Identically to columns. f.text_field :theme works because the accessor exists, and validation errors attach to :theme so f.object.errors[:theme] renders normally.

Q4: Is there a size limit on the JSON column?
PostgreSQL’s jsonb allows up to 255MB, so no practical limit. The real limit is performance — the whole document is read and rewritten on every update, so a multi-megabyte bag will slow down every save on that row.

Q5: What happens if the stored JSON contains a key with no declared accessor?
Nothing breaks; the key stays in the hash and is readable via user.preferences["key"]. It just has no generated method, no type casting, and no validation — which is exactly how dead keys accumulate unnoticed.

cdrrazan

Rajan Bhattarai

Full Stack Software Developer! 💻 🏡 Grad. Student, MCS. 🎓 Class of '23. GitKraken Ambassador 🇳🇵 2021/22. Works with Ruby / Rails. Photography when no coding. Also tweets a lot at TW / @cdrrazan!

Read More