jsonb columns in Rails, and the schema you gave up
A jsonb column is the one schema decision Postgres lets you defer, and deferring it is the whole point. You get a column that holds anything, indexed well enough to query, without a migration every time the shape changes. What you give up is the thing a column was for: a declared type, a declared set of keys, and a database that refuses the write when you get one of them wrong.
Every Rails jsonb column in the LaunchKit boilerplate, seventeen of them against rails 8.1.3.1 and pg 1.6.3, falls into one of three shapes, and the shape is the interesting part:
null: false with a default 5 settings.features, settings.pricing, ai_templates.{params,variables,versions}
nullable with a default 6 ruby_llm_models.{modalities,capabilities,pricing,metadata}, ...
nullable, no default 6 messages.{citations,raw_content,raw_reasoning,server_tool_calls}, ...
Everything below was measured on PostgreSQL 17.7, against tables of 500000 rows.
Declaring one, and what the default actually is
t.jsonb :features, default: {}, null: false writes a PostgreSQL column default. Here is the
one in the boilerplate, from db/migrate/20260501102641_create_settings.rb:
# Feature flags + pricing (jsonb)
t.jsonb :features, default: {}, null: false
t.jsonb :pricing, default: {}, null: false
That default is applied by Postgres, on INSERT, when the column is not named in the statement. It is not a Rails default and Rails does not apply it. The distinction is invisible until something writes around Active Record, and then it is the whole bug:
CREATE TABLE d2 (id bigserial PRIMARY KEY, prefs jsonb DEFAULT '{}');
INSERT INTO d2 (prefs) VALUES (NULL); -- prefs IS NULL -> t
INSERT INTO d2 DEFAULT VALUES; -- prefs IS NULL -> f
A named NULL beats the default. null: false is what closes that, which is why the two columns
above carry both and why the six nullable-no-default columns are a different animal.
Message.new[:citations] is nil, not [], so Message.new[:citations]["source"] raises
NoMethodError: undefined method '[]' for nil. The ruby_llm gem knows this and wraps the reader, in
message_methods.rb:76:
def citations
Array(optional_column(:citations)).map { |citation| RubyLLM::Citation.from_h(citation) }
end
Which trades one error for another: Message.new.citations["source"] now raises TypeError: no
implicit conversion of String into Integer, because the Array() handed you an Array and you
indexed it by key. A nullable jsonb column with no default costs every reader that guard.
One thing that is no longer a trap: ALTER TABLE ... ADD COLUMN prefs jsonb NOT NULL DEFAULT '{}'
makes rows inserted before the migration read back {} rather than NULL, without touching a single
page of the table. The docs put it precisely: "From PostgreSQL 11, adding a column with a constant
default value no longer means that each row of the table needs to be updated when the ALTER TABLE
statement is executed. Instead, the default value will be returned the next time the row is
accessed." There is no backfill to schedule and no migration to write in two passes.
The shared mutable hash, and where it still lives
Active Record no longer hands two records the same Hash. The classic jsonb default bug, where
attribute :features, default: {} gives every instance one object and a mutation on one record
turns up on the next, does not reproduce on rails 8.1.3.1:
Setting.new.features.equal?(Setting.new.features) # => false, column default
k2 = Class.new(Setting) { attribute :features, :json, default: {} }
k2.new.features.equal?(k2.new.features) # => false, Rails attribute default
ActiveRecord::Base#init_internals deep-dups _default_attributes for every instance, so the
default is copied before anyone can reach it.
Plain ActiveModel does not, and that is where the bug went:
class PlainForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :prefs, default: {}
end
a = PlainForm.new
b = PlainForm.new
a.prefs.equal?(b.prefs) # => true
a.prefs[:x] = 1
b.prefs # => {x: 1}
PlainForm.new.prefs # => {x: 1}
The third line is the one that matters. The poisoned hash is not shared between two live objects, it
is the class default, so every PlainForm.new for the rest of the process boot carries {x: 1}.
Form objects and service objects built on ActiveModel::Attributes are exactly the place a
developer reaches for default: {} and exactly the place Rails has not fixed it. Use
default: -> { {} } there. On an Active Record model with a real jsonb column you do not need it,
and writing it costs nothing.
Worth knowing while you are in there: :json is not a registered ActiveModel type.
attribute :prefs, :json on a plain ActiveModel class raises ArgumentError: Unknown type :json,
because the type is registered by Active Record, not ActiveModel.
Querying with the Postgres operators
Four operators carry almost every jsonb query. -> extracts and returns jsonb, ->> extracts and
returns text, @> asks whether the left document contains the right one, and ? asks whether a
string appears as a top-level key or array element.
'{"ai":false}'::jsonb -> 'ai' -- false, pg_typeof = jsonb
'{"ai":false}'::jsonb ->> 'ai' -- false, pg_typeof = text
'{"ai":false,"blog":true}'::jsonb @> '{"ai":false}'::jsonb -- t
'{"ai":false}'::jsonb ? 'ai' -- t
The trap in Active Record is that the obvious Ruby does none of this. where with a Hash value on a
jsonb column builds an equality test against the whole document:
Setting.where(features: { "ai" => false }).to_sql
# SELECT "settings".* FROM "settings" WHERE "settings"."features" = '{"ai":false}'
Key order does not matter, because jsonb normalises, so {"ai":false,"blog":true} equals
{"blog":true,"ai":false}. One extra key does matter: that same row is not equal to {"ai":false},
and the query returns nothing. Written as containment it is a string condition and it works:
Setting.where("features @> ?", { ai: false }.to_json)
# SELECT "settings".* FROM "settings" WHERE (features @> '{"ai":false}')
The second trap is three-valued logic. ->> on a missing key is SQL NULL, so a negated comparison
is neither true nor false and the row is dropped. On a 500000-row table where 500 rows carry a
coupon key:
SELECT count(*) FROM events WHERE payload ->> 'coupon' <> 'NOPE'; -- 500
Not 500000. No row in the table has coupon set to NOPE, so every row should qualify; the 499500
rows with no coupon key at all compare as unknown and are dropped, silently.
payload ->> 'coupon' IS DISTINCT FROM 'NOPE' is the form that means what you read.
Containment, and the GIN index that makes it worth doing
Containment is the reason to reach for jsonb rather than a text column, and it is only a reason once
there is a GIN index under it. Without one, @> is a filter evaluated per row:
Parallel Seq Scan on events
Filter: (payload @> '{"coupon": "PRESALE40"}'::jsonb)
Buffers: shared hit=9615
Execution Time: 47.364 ms
With CREATE INDEX ... USING gin (payload), the same query on the same 500000 rows:
Bitmap Heap Scan on events (actual rows=500)
Recheck Cond: (payload @> '{"coupon": "PRESALE40"}'::jsonb)
Heap Blocks: exact=12
Execution Time: 0.118 ms
Twelve heap blocks instead of 9615 buffers. The boilerplate declares two of these, in
db/migrate/20260116153821_create_ruby_llm_records.rb:
t.index :capabilities, using: :gin
t.index :modalities, using: :gin
Read the plan rather than trusting the index exists, for the same reason the keyset cursor in pagination without a gem needed its EXPLAIN read: a GIN index is consulted at the planner's discretion, and on the first run of this measurement it was correctly ignored, because every row in the table carried the key under test and a seq scan was cheaper. An index that is never chosen still costs you every write.
What a GIN index costs on the write side
Write cost is the argument against indexing every jsonb column, and it is large. Three identical tables, 200000 rows inserted into each, differing only in the index:
no index 691.960 ms
GIN, default opclass 2003.280 ms
GIN, jsonb_path_ops 2357.518 ms
Roughly three times the insert cost. The reason is structural: a btree index adds one entry per row, a GIN index adds one entry per key and value in the document, so the five-key documents above cost ten index entries each.
PostgreSQL softens this with a pending list. FASTUPDATE is on by default and buffers new entries
in an unsorted list, flushed when it passes gin_pending_list_limit, which is 4MB on a stock
PostgreSQL 17.7. The flush moves to autovacuum, out of the foreground. The docs are direct about the
bill: "searches must scan the list of pending entries in addition to searching the regular index,
and so a large list of pending entries will slow searches significantly." A write-heavy table with a
GIN index has a read latency that depends on how recently autovacuum ran, which is not a property
most people want in a query they just benchmarked.
jsonb_path_ops, and the operator it throws away
jsonb_path_ops is the non-default GIN operator class, and it is the right one more often than its
usage suggests. Instead of indexing every key and every value separately, it stores a hash of the
whole path plus its value. On the same 500000 rows with five keys each:
default jsonb_ops 11952128 bytes
jsonb_path_ops 9093120 bytes
Twenty-four percent smaller here, and the docs say it is "usually much smaller ... and the specificity of searches is better, particularly when queries contain keys that appear frequently in the data". The insert benchmark above did not reproduce a write advantage: across two runs it came out 2513 ms then 2357 ms against 3052 ms then 2003 ms for the default class, which is noise, not a result. Take the size and the specificity, not a write-speed claim.
What it throws away is the key-exists family. ?, ?| and ?& are not supported. They do not
error, which is the problem: the planner just stops using the index.
-- with default jsonb_ops
Bitmap Index Scan on idx_ev_gin Index Cond: (payload ? 'coupon') 0.165 ms
-- with jsonb_path_ops only
Parallel Seq Scan on events Filter: (payload ? 'coupon') 17.730 ms
So the rule is readable: if every query you write is containment, @>, @? or @@, take
jsonb_path_ops. If you ever ask whether a key is present at all, you need the default class, and
you pay for it in bytes.
Validation is the part Rails does not do
A jsonb column accepts any valid JSON document, and Rails adds nothing on top. There is no schema,
so there is no typo. Setting.current.update!(features: { "blogg" => false }) succeeds, and
Feature.enabled?("blog") goes on answering true, because the override it looks for was never
written. Nothing raises, no spec fails, and the admin who clicked the toggle watches the blog stay
up. This is the honest weakness of the whole feature, and no amount of jsonb validation in the model
turns it into a column.
The boilerplate has a live example of the class of bug a schemaless column invites, in
app/models/feature.rb:
def enabled?(key)
definition = find(key)
return false unless definition
override = Setting.current.features[definition.key]
override.nil? ? definition.default : ActiveModel::Type::Boolean.new.cast(override)
end
Two defensive moves in five lines. ActiveModel::Type::Boolean.new.cast is there because a jsonb
column does not cast on assignment: a boolean column turns "false" into false on the way in, and
a jsonb column stores the string. {"ai" => "false"} read naively is truthy in Ruby, so the
disabled feature reads as enabled. And the override.nil? test rather than override || default is
there because false is a legitimate stored value, and false || true is true, which would make
every disabled feature come back on. Both bugs exist only because the column has no type to enforce.
While you are near Boolean#cast, its FALSE_VALUES set is worth reading once: "off" casts to
false and "no" casts to true.
What actually rejects a bad key
Two things reject a bad key, and neither of them is Rails. The first is a database CHECK constraint, which for a whitelist of keys is one expression:
ALTER TABLE settings ADD CONSTRAINT features_keys_known
CHECK (features - ARRAY['ai','referrals','blog','api','support','signup'] = '{}'::jsonb);
The boilerplate does not ship this constraint; the expression above was checked against a scratch
table. jsonb - text[] deletes those keys; anything left over is a key nobody declared. The insert of
{"blogg": false} fails with SQLSTATE 23514, which Active Record raises as
ActiveRecord::CheckViolation. That is a 500, not a form error, so it is a backstop and not a
validation. Note also that CHECK expressions cannot contain a subquery: deriving the key list from
another table gives you ERROR: cannot use subquery in check constraint. The six feature keys have
to be typed into the migration, which means adding a feature is now a code change plus a migration,
which is most of what the jsonb column was bought to avoid.
The second is store_model, 4.6.1 as of August 2026, which lets you declare an ActiveModel class and attach it to the column:
class Configuration
include StoreModel::Model
attribute :model, :string
attribute :color, :string
end
class Product < ApplicationRecord
attribute :configuration, Configuration.to_type
end
You get types, validations and errors on the declared keys. You do not get rejection of the undeclared ones: its own documentation says "unknown attributes are always stored in the database". The typo still writes.
When a jsonb column is a table you did not want to write
Take a position: a jsonb column is right when the keys are not part of your domain model, and
wrong the moment they are. A provider's raw API response, a webhook body kept for replay, a
params bag handed to a template, model pricing metadata that changes when a vendor changes it:
nobody queries these by key, nobody validates them, and writing a column per field would be
transcribing somebody else's schema into yours. Those are the boilerplate's raw_content,
raw_reasoning and ruby_llm_models.metadata, and they should stay jsonb.
settings.features is the other case, and the argument there runs against this codebase. Six known
keys, a fixed set defined in Feature::REGISTRY, boolean values, read on nearly every request, and
a validation problem that took two defensive lines in enabled? to paper over. A
feature_flags table with a key column and a unique index, or six boolean columns, would have had
the typo rejected by the database, no casting, and no nil? dance. The counter-argument is real and
it is why the column is still there: the admin can flip a flag without a migration, and the
generator that scaffolds a new feature writes one registry line instead of one migration. That is a
deliberate trade, not an oversight. What would overturn it is the first flag whose value stops being
a boolean.
The test worth applying: if you can write down the keys, you have a schema, and the case for jsonb is that you did not want to run a migration. If you find yourself indexing a single key with an expression index and adding a CHECK constraint for it, you have rebuilt a column the slow way. The same judgement applies one layer up, where a Rails enum beats a free-text status the moment the set of values is known.
Comments
No comments yet. Be the first.