PostgreSQL JSONB vs Normalized Columns for Evolving Schemas
Quick answer
Use normalized columns for data you filter, join, sort, or constrain regularly, and use JSONB for genuinely variable or sparsely used attributes. JSONB is...
Use normalized columns for data you filter, join, sort, or constrain regularly, and use JSONB for genuinely variable or sparsely used attributes. JSONB is flexible and well indexed in PostgreSQL, but it gives up type safety, foreign keys, and clear statistics. A hybrid design, with core fields as columns and the long tail as JSONB, works well for many evolving schemas.
What Does JSONB Give You, and What Does It Cost?
JSONB stores a parsed binary representation of JSON, which supports indexing and a rich set of operators. It lets you add attributes without a migration, which is attractive when requirements change quickly or when you store data whose shape you do not control, such as webhook payloads.
The cost is that the database knows less about your data. A normal column has a type, a NOT NULL constraint, a default, and per-column statistics that help the planner estimate row counts. Inside JSONB, none of that is enforced automatically. Typos in keys, wrong value types, and missing fields become application bugs rather than database errors.
Statistics deserve special attention. The planner keeps detailed statistics per column, but it has far less insight into individual keys inside a JSONB value. That can lead to poor row estimates and surprising query plans on filters that look simple.
When Are Normalized Columns the Better Choice?
- You filter or sort on the field in hot queries.
- You need foreign keys, unique constraints, or CHECK constraints.
- You aggregate or join on the field.
- The field exists on nearly every row.
Columns also update more cheaply. Changing one key inside a large JSONB value rewrites the whole value, which increases write amplification and bloat for frequently updated documents. If one attribute changes often, it belongs in its own column.
ALTER TABLE products
ADD COLUMN price_cents integer NOT NULL DEFAULT 0,
ADD CONSTRAINT price_non_negative CHECK (price_cents >= 0);
Large JSONB values are also stored out of line through TOAST once they exceed a size threshold. Reading a single key from a big document can therefore mean fetching and decompressing the entire value, which is a hidden cost compared with reading a small integer column.
How Should You Index JSONB?
A GIN index supports containment and key-existence queries across the whole document. An expression index on a specific path is often smaller and faster when you query one key repeatedly.
CREATE INDEX idx_products_attrs ON products USING gin (attrs jsonb_path_ops);
CREATE INDEX idx_products_color ON products ((attrs->>'color'));
Operator class behavior differs, and the best choice depends on the operators you use, so check the PostgreSQL documentation for your version. Always confirm with EXPLAIN that the planner actually uses the index you created, and remember that every additional index slows down writes.
What Is a Practical Hybrid Approach?
Start with columns for stable, queryable fields and add a JSONB column for extensible metadata. When a key inside JSONB becomes important, promote it to a real column. This can be done with a migration that adds the column and backfills it from the JSONB value.
Add validation where it matters. A CHECK constraint can verify that a JSONB value is an object, and application-level schema validation can catch unexpected keys before they are stored. Generated columns can also expose a JSONB path as a typed, indexable column in supported versions.
Document which keys are allowed in the JSONB column. Without that discipline, the column turns into an undocumented second schema that is harder to evolve than the one you were trying to avoid.
Frequently Asked Questions
Is JSONB slower than regular columns?
For targeted lookups on indexed fields, columns are typically faster and give better planner statistics. JSONB can perform well with proper indexes but is less predictable for complex filters.
Can JSONB columns have foreign keys?
Not on values inside the document. If a value references another table, store it as a real column so the database can enforce the relationship.
Should I store the entire record as JSONB?
Usually not. You lose constraints, type safety, and efficient updates. Reserve JSONB for variable or rarely queried data.
How do I migrate a JSONB key to a column?
Add the column, backfill from the JSONB value in batches, update the application to write both, then switch reads and remove the key.
Summary
Choose columns for core, constrained, and frequently queried data, and JSONB for flexible attributes. Index specific paths, validate inputs, and promote hot keys to columns as the schema stabilizes. Consult the PostgreSQL documentation for operator and index details in your version.