As Rails developers, we are obsessed with schema design. We love 3rd Normal Form. We love foreign keys. We love data integrity.
But the real world is messy.
Sometimes you are building an E-commerce platform where a T-Shirt has a size and material, but a Laptop has a cpu_speed and ram_slots.
How do you model that in a relational database?
- Single Table Inheritance (STI)? You end up with a
productstable with 50 columns that are mostlyNULL. - EAV (Entity-Attribute-Value) Tables? Please, no. The SQL queries alone will make you cry.
- Separate Tables? Now you can't easily query "all products."
This is the sweet spot for ActiveRecord::Store. It is the bridge that lets you treat your SQL database like a Document Store, without abandoning the safety of Active Record.
The "Polymorphic" Product Problem
Let's look at how ActiveRecord::Store solves the "variant" issue without creating a database nightmare.
The Migration
You only need one column. Make it jsonb (Postgres) so you can actually index it later if you get desperate.
add_column :products, :properties, :jsonb, default: {}
The Model
Here is where the elegance happens. We don't just dump data into a hash; we define a schema in code rather than in SQL.
class Product < ApplicationRecord
# Base properties for all products
end
class Tshirt < Product
store :properties, accessors: [:size, :material, :color], coder: JSON
validates :size, presence: true
end
class Laptop < Product
store :properties, accessors: [:cpu, :ram, :storage_type], coder: JSON
validates :cpu, presence: true
end
Now, when you interact with a Tshirt, you treat size exactly like a database column.
tshirt.update(size: 'L') works.
form_for @tshirt works.
Strong Params works.
Rails hides the JSON serialization completely.
The Hidden "Gotchas" (Read this before deploying)
If ActiveRecord::Store is so great, why don't we use it for everything? Because it comes with three specific "footguns" that can hurt you if you aren't careful.
1. The "String vs. Symbol" Trap
When you save data to a JSON column, it is serialized. When you pull it back out, keys are strings.
Rails tries to help, but if you access the raw hash, you might get burned.
product.properties[:size] # might be nil
product.properties["size"] # might be 'L'
Solution: Always use the accessor methods (product.size), never the raw hash.
2. The Type Casting Issue
In a normal Rails integer column, if you save "5", Rails makes it 5.
In a Store, if you save "5", it stays "5" (String).
Because the underlying column is JSON, it creates a flexible typing system that Rails validations sometimes ignore until it's too late.
Solution: Use the attribute API explicitly if you need types.
# Modern Rails way to force types inside a store
attribute :ram, :integer
3. Dirty Tracking
In older versions of Rails, changing a single key in the store marked the entire column as dirty. This meant if two people updated different "attributes" in the store at the same time, the last one to save would overwrite the other's changes (Race Condition).
Solution: Be very careful with high-concurrency updates on Stored columns.
When to use it vs. When to run a migration
I use a simple decision matrix:
- Is this data needed for a
JOIN?- Yes -> Real Column.
- Is this data needed for frequent
ORDER BYorGROUP BYclauses?- Yes -> Real Column.
- Is this data "content" specific to a subtype of the model?
- Yes -> ActiveRecord::Store.
- Is this data purely cosmetic (UI settings)?
- Yes -> ActiveRecord::Store.
The Modern Alternative: store_model
If you find yourself loving ActiveRecord::Store but hating the lack of types and nested validations, check out the store_model gem.
It allows you to wrap your JSON column in a real Ruby class (ActiveModel) with its own validations, seemingly giving you the best of both worlds:
class Specs
include StoreModel::Model
attribute :cpu, :string
attribute :ram, :integer
validates :ram, numericality: { greater_than: 0 }
end
class Laptop < Product
attribute :properties, Specs.to_type
end
Conclusion
ActiveRecord::Store is one of those Rails features that feels like cheating. It allows you to iterate on features at lightning speed without being bogged down by migrations.
Just remember: It is duct tape. It is incredibly useful, strong, and versatile. But you probably shouldn't use it to build the foundation of your house.
Top comments (0)