Spot the Tax · Card 4 of 20
JSON-as-text blocks the queries you'll need later
Why serialize :metadata, JSON is fine until someone asks for a report.
The code
What will this cost you in six months?
class Order < ApplicationRecord
serialize :metadata, JSON
end
# Writing:
order.metadata = { plan: "pro", referrer: "google", utm_campaign: "spring" }
order.save
# Reading later, in a report:
Order.where("created_at > ?", 90.days.ago).select do |o|
o.metadata["plan"] == "pro"
end The problem
The metadata column is just a regular text column that happens to contain JSON. As far as the database is concerned, the whole field is one opaque string. Writing a hash to it serializes the whole thing to a JSON string and stores it as text, and reading it back parses the string into a hash again. As long as you're only ever loading or saving a single record, that's fine. But as soon as you need to query inside the metadata — like asking "how many orders had plan = 'pro' last quarter" — the database can't help, because it has no way to look inside a text field. So every row has to be loaded, every JSON string has to be parsed in Ruby, and the filtering has to happen in your application instead of in the database.
Take a moment. Before revealing, think about how you'd actually answer the report's question. What would you change about the schema so the database could help you?
The solution
If a particular field inside the JSON ends up being something you query, take it out of the blob and make it a real column with an index. If the metadata genuinely is opaque (you really never look inside it), then at least use Postgres jsonb instead of a serialized text column. jsonb stores the JSON in a binary format the database actually understands, which means it can index paths inside the document and do the filtering for you.
- Queries inside the data become possible
- The database uses indexes instead of full scans
- Reads and writes don't pay a parse/dump tax
# Option 1: promote the queryable field to a column
add_column :orders, :plan, :string
add_index :orders, :plan
# Option 2: switch to jsonb for genuinely opaque data
change_column :orders, :metadata, :jsonb, using: "metadata::jsonb"
add_index :orders, :metadata, using: :gin The principle at play — Indexable storage
Databases are fast at queries because they build indexes alongside your tables. An index is a separate data structure that maps each value to the rows where it appears, so when you ask the database for plan = 'pro', it can jump straight to the matching rows instead of scanning the entire table.
The catch is that indexes work on column values. They don't look inside text content. When you store JSON in a regular text column, the database has no idea what's inside that string — it just sees one big opaque blob. There's no way for it to index metadata.plan, so any filtering on that field has to happen in your application after the database has handed every row back.
Postgres jsonb is what changes this. It stores JSON in a binary format the database understands, which means you can build indexes on specific paths inside the document. The database goes back to doing what it's good at. JSON-as-text is fine if you genuinely treat the metadata as opaque, but as soon as you start querying inside it, the storage choice you made earlier decides whether the database can help you or not.