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_frequencyshould have beenemail_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 NULLbusiness 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.
SUMover 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.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀