LaunchKit
← All posts
· 13 min read · by The LaunchKit team · 2 views

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:

The same five-key jsonb document indexed by the two GIN operator classes side by side. The default jsonb_ops puts ten entries per row in the index, one for every key and every value, and answers containment and the key-exists family alike; jsonb_path_ops stores one hash of each path plus its value, is twenty-four percent smaller, and answers containment only, so a key-exists query falls back to a sequential scan.

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.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.