PostgreSQL for Rails devs: JSONB has arrived
Postgres 9.4 brings JSONB — indexed, queryable JSON inside a relational column. A deep-dive on when to reach for it, how to query and index it, the Rails 4.2 integration, and the trap of treating your SQL database like a document store.
A couple of years ago, “I need flexible, schemaless fields” was a common reason
teams reached for MongoDB. Postgres 9.4, released at the end of last year, takes a
large bite out of that argument. Its new jsonb type stores JSON in a binary
format that is indexable and queryable — schemaless document data living inside
a column of an otherwise fully relational, transactional table. For Rails
developers, paired with the JSON support that landed in 4.2, this is one of the more
quietly important features in years. Here is how to use it well, and where not to.
json vs jsonb: always reach for jsonb
Postgres now has two JSON types, and the distinction matters. The original json
type stores an exact text copy of what you inserted — whitespace, key order,
duplicate keys and all. It re-parses on every operation, and it cannot be indexed
meaningfully. jsonb parses the JSON once on write and stores it in a decomposed
binary form: key order is not preserved, duplicates are removed, and — crucially —
it can be indexed and queried efficiently.
The trade is tiny: jsonb writes are marginally slower (it parses up front) and it
does not round-trip formatting. In return you get fast containment queries and
indexing. For essentially every application use case, use jsonb. The plain
json type is for the rare case where you must preserve the exact input byte for
byte.
A first column
In Rails 4.2 a jsonb column is a first-class migration type:
class AddPreferencesToUsers < ActiveRecord::Migration
def change
add_column :users, :preferences, :jsonb, null: false, default: {}
add_index :users, :preferences, using: :gin
end
end
user.preferences = { "theme" => "dark", "notifications" => { "email" => true } }
user.save
user.preferences["theme"] # => "dark" — Rails casts it to a Ruby hash for you
Two details worth copying. Give the column a default: {} and null: false so you
never have to nil-check it — every row has at least an empty object. And add a GIN
index from the start if you will query into it, because that is what makes the
containment operators fast.
Querying inside the document
This is where jsonb earns its place — you can query into the structure from SQL,
not just fetch the blob and parse it in Ruby. The core operators:
-- -> returns a JSON value, ->> returns text
SELECT preferences -> 'notifications' ->> 'email' FROM users;
-- @> "contains": does the column contain this fragment? (GIN-indexable)
SELECT * FROM users WHERE preferences @> '{"theme": "dark"}';
-- ? does this top-level key exist?
SELECT * FROM users WHERE preferences ? 'beta_access';
From Rails you drop these into a where:
User.where("preferences @> ?", { theme: "dark" }.to_json)
User.where("preferences ->> 'theme' = ?", "dark")
The star is the @> containment operator, because it is what the GIN index
accelerates. A GIN (Generalized Inverted Index) on a jsonb column indexes its keys
and values so a containment query does not scan every row. If you only ever use the
containment and key-existence operators, the more compact jsonb_path_ops GIN
variant is smaller and faster still:
CREATE INDEX idx_users_prefs ON users USING gin (preferences jsonb_path_ops);
Without the index, @> works but scans the table; with it, you have indexed lookups
into semi-structured data — the thing that used to require leaving Postgres.
Rails integration: store_accessor
For a known set of attributes living inside a jsonb column, store_accessor gives
you real attribute methods backed by the JSON, so the rest of your code does not
need to know it is talking to a JSON column:
class User < ActiveRecord::Base
store_accessor :preferences, :theme, :locale, :timezone
end
user.theme = "dark" # writes preferences["theme"]
user.theme # => "dark"
user.locale = "en"
This is a clean way to add “soft” columns — flexible per-record attributes you can add without a migration — while still reading and writing them like normal fields.
When to use it, and the trap to avoid
This is the important part, because jsonb is easy to overuse. The right instinct:
a jsonb column complements your schema; it does not replace it. Reach for it
when:
- The data is genuinely variable or sparse — user preferences, per-product custom attributes, settings that differ by record, third-party API payloads you want to keep verbatim.
- You want to add fields without a migration during fast iteration, then promote the ones that stabilise into real columns later.
- You are storing a document you mostly read and write as a whole and only occasionally query into.
Do not use it as a substitute for proper columns on data that is structured and
relational. The trap is seductive: dump everything into one jsonb blob and skip
schema design. You pay for it later. Columns inside JSON have no foreign keys, no
NOT NULL or CHECK constraints, weaker type guarantees, and queries that are
clumsier and harder to optimise than a plain indexed column. The discipline is to
ask of each field: is this a first-class, queried, constrained attribute of the
row? If yes, it is a column. If it is genuinely free-form or sparse, it is jsonb.
The beauty of Postgres here is that you do not have to choose globally. The same
table can have rigid, constrained, foreign-keyed columns for the structured core of
your data and a jsonb column for the flexible edges — relational integrity and
schemaless flexibility in one transactional store. That is a genuinely better answer
than running a separate document database for the flexible 10% of your model, and it
is why “we need schemaless fields” is no longer, on its own, a reason to leave
Postgres.
Verdict
jsonb is one of those features that quietly expands what a single database can do.
Indexed, queryable JSON inside a relational table gives Rails apps a pragmatic middle
ground between rigid columns and a document store, without the operational cost of
running two databases. Use jsonb (never plain json), index it with GIN, reach for
store_accessor for known attributes, and keep the discipline that structured data
belongs in columns. Used that way, it is one more reason Postgres remains the default
we are happiest building on.