JSONB Deep Dive
Store semi-structured data inside PostgreSQL, query it with powerful operators, index it for blazing-fast lookups, and blend JSON with relational design for real-world SaaS products.
Why JSONB? JSON vs JSONB
What makes JSONB special — and when to use it
JSON vs JSONB — Core Difference
PostgreSQL has two JSON types. Understanding the difference is the foundation of everything in this topic.
| Feature | json | jsonb |
|---|---|---|
| Storage format | Plain text, stored as-is | Binary (decomposed & parsed) |
| Write speed | ⚡ Faster (just stores text) | Slightly slower (parses on insert) |
| Read/Query speed | 🐢 Slow (parses on every read) | ⚡ Very fast (already parsed) |
| Indexing | ❌ No GIN support | ✅ Full GIN indexing |
| Key order preserved | ✅ Yes | ❌ No (sorted internally) |
| Duplicate keys | ✅ Kept (last one wins on parse) | ❌ Last value kept |
| Use in production | Rarely | ✅ Always prefer this |
jsonb unless you specifically need to preserve key order or exact formatting. In production, jsonb is the standard.When Should You Use JSONB?
JSONB is powerful — but not a replacement for relational design. Use it when the data is truly semi-structured or variable.
Creating a Table with JSONB
JSONB Operators
-> ->> #> #>> and more
The Four Core Extraction Operators
These operators let you dig into a JSONB value to retrieve nested fields.
-> but returns TEXT. Use when comparing, filtering, or displaying values.#> but returns TEXT. Most useful for deeply nested leaf values.->) = one key. Double hash-arrow (#>) = path of many keys. Single tail (->) = JSONB result. Double tail (->>) = TEXT result.Using Operators in Real Queries
Containment and Existence Operators
These operators let you check whether a JSONB value contains another, or whether a key exists. Critical for filtering and GIN index usage.
| Operator | Meaning | Example |
|---|---|---|
@> |
Left contains right (containment) | attributes @> '{"ram":"16GB"}' |
<@ |
Left is contained by right | '{"ram":"16GB"}' <@ attributes |
? |
Key exists at top level | attributes ? 'ram' |
?| |
Any of the keys exist | attributes ?| array['ram','cpu'] |
?& |
All of the keys exist | attributes ?& array['ram','cpu'] |
@>, ?, ?|, ?& operators are all supported by GIN indexes. This makes them extremely fast on large tables. We'll set this up in Section 2.Other Useful Operators
| Operator / Function | Purpose | Example |
|---|---|---|
|| | Concatenate / merge two JSONB objects | '{"a":1}' || '{"b":2}' → {"a":1,"b":2} |
- (minus key) | Delete a key from JSONB | attributes - 'ram' |
#- | Delete at path | attributes #- '{ports,0}' |
jsonb_set() | Set/update a value at path | jsonb_set(attributes, '{ram}', '"32GB"') |
jsonb_pretty() | Format JSONB as readable string | jsonb_pretty(attributes) |
jsonb_each() | Expand object to (key, value) rows | SELECT * FROM jsonb_each(attributes) |
jsonb_array_elements() | Expand JSON array to rows | SELECT * FROM jsonb_array_elements(attributes->'ports') |
🧪 Interactive: Operator Explorer
Try JSONB Operators Simulated
Select an operator to see what it does with the sample product data below.
"ram": "16GB",
"cpu": "i7",
"storage": "512GB SSD",
"ports": ["USB-C", "HDMI", "USB-A"],
"in_stock": true,
"rating": 4.5
}
JSONB Indexing
GIN indexes — the secret to fast JSONB queries at scale
Why JSONB Needs Indexes
Without an index, filtering on a JSONB column does a full sequential scan — reads every row and inspects the JSON. On a table with 10M rows, this is catastrophically slow.
WHERE attributes ->> 'ram' = '16GB' on 1M rows = full table scan, ~2-5 seconds. With GIN index = <10ms.GIN Index — The Default Choice
GIN (Generalized Inverted Index) indexes every key-value pair inside the JSONB document. Think of it like a book index — it builds a map of "where does key X with value Y appear?"
GIN Operator Classes
PostgreSQL has two GIN operator classes for JSONB. Choose based on your use case:
| Operator Class | Supports | Index Size | Best For |
|---|---|---|---|
jsonb_ops (default) |
@> ? ?| ?& |
Larger | General JSONB queries — containment & key existence |
jsonb_path_ops |
@> only |
Smaller ✅ | When you only need @> (containment). Faster & smaller. |
Expression Indexes on JSONB (B-Tree)
If you always query a specific key inside JSONB, a regular B-Tree expression index is often faster and smaller than GIN.
Choosing the Right Index Strategy
?, or don't know which key up front@> containment. Smaller & faster for that caseattributes ->> 'key' = 'value' (arrow operator). Use @> (containment) for GIN, or create a B-Tree expression index for specific key comparisons.Verify Index is Being Used
Hybrid Relational + JSON Design
The most powerful pattern — very common in SaaS products
What Is the Hybrid Pattern?
The core idea: use relational columns for structured, known, frequently-queried data, and JSONB for flexible, variable, or extensible data. Never go full-JSON when you have structured fields.
-- Don't put everything in JSON CREATE TABLE users ( id SERIAL PRIMARY KEY, data JSONB -- name, email, role all in here! ); -- Problems: -- Can't index email properly -- Can't enforce NOT NULL on name -- Can't use foreign keys -- Poor query performance
-- Fixed fields → columns -- Variable fields → JSONB CREATE TABLE users ( id SERIAL PRIMARY KEY, email TEXT UNIQUE NOT NULL, name TEXT NOT NULL, role TEXT NOT NULL, preferences JSONB -- theme, notifications, etc. ); -- Best of both worlds!
Real-World Example: SaaS User with Preferences
Real-World Example: E-Commerce Product Catalog
The classic use case — every category has different attributes but shares a common structure.
Real-World Example: Audit Log / Event Tracking
Store events with a fixed schema + flexible payload — perfect for analytics, audit trails, and webhook logs.
Real-World Example: AI / RAG System with JSONB Metadata
A common pattern in modern AI platforms — documents with metadata stored as JSONB.
Decision Framework — Column vs JSONB
Ask these questions for each field:
▶ Do you JOIN on this field? → Use a column
▶ Do you ORDER BY or GROUP BY on it? → Use a column
▶ Is this a foreign key? → Use a column
▶ Is this field optional / varies per row? → Use JSONB
▶ Is this a bag of user settings / preferences? → Use JSONB
▶ Is this an external API payload you want to keep raw? → Use JSONB
▶ Does the structure change per category / type? → Use JSONB
🧪 Interactive: Hybrid Design Advisor
Should this field be a column or JSONB? Simulated
Advanced JSONB Patterns
Aggregation, validation, generated columns & more
Building JSON from Relational Data
PostgreSQL can build JSON on the fly from relational tables — perfect for APIs.
Generated Columns from JSONB
Extract a JSONB key into a real column automatically — best of both worlds: JSONB flexibility + column performance.
JSONB with CHECK Constraints (Basic Validation)
Querying Across Nested Arrays (Lateral Join)
When JSONB contains an array and you need to filter or join on elements inside it, use LATERAL with jsonb_array_elements.
JSONB Path Queries (jsonpath — PostgreSQL 12+)
PostgreSQL 12 added SQL/JSON path language — a powerful way to query deep into JSONB with conditions.
Common JSONB Mistakes
Pitfalls that kill performance in production
Mistake 1 — Using ->> Instead of @> for GIN Queries
-- ->> returns text, GIN can't help here -- Results in a sequential scan! SELECT * FROM products WHERE attributes->>'ram' = '16GB';
-- @> containment uses GIN index
SELECT * FROM products
WHERE attributes @> '{"ram":"16GB"}';
Mistake 2 — Putting Relational Data in JSONB
-- user_id in JSONB = can't use FK, slow JOINs
INSERT INTO orders (data) VALUES (
'{"user_id": 42, "total": 99.99}'
);
-- user_id as a proper FK column
INSERT INTO orders (user_id, total, metadata)
VALUES (42, 99.99,
'{"promo_code":"SAVE10"}');
Mistake 3 — Forgetting to Cast JSONB Numbers
-- "9" > "10" as text! Wrong result. WHERE attributes->>'count' > '10'
-- Cast to numeric first WHERE (attributes->>'count')::numeric > 10
Mistake 4 — No Index on Frequently Queried JSONB
CREATE INDEX ... USING GIN (attributes) if you query JSONB columns.Mistake 5 — Over-using JSONB (JSON for Everything)
🎯 Quick Check — Test Your Understanding
JSONB Quiz 5 Questions
Summary & Cheatsheet
Everything you need to remember from Topic 13
Operator Cheatsheet
| Operator | Returns | Use When |
|---|---|---|
-> 'key' | JSONB | Pass to another operator / chain access |
->> 'key' | TEXT | Display, compare, or cast value |
#> '{a,b}' | JSONB | Nested path access (keep as JSONB) |
#>> '{a,b}' | TEXT | Nested path access (get as text) |
@> '{...}' | BOOLEAN | Containment check — uses GIN index ✅ |
? 'key' | BOOLEAN | Key existence — uses GIN index ✅ |
?| array[...] | BOOLEAN | Any key exists — uses GIN index ✅ |
?& array[...] | BOOLEAN | All keys exist — uses GIN index ✅ |
|| | JSONB | Merge two JSONB objects |
- 'key' | JSONB | Delete a key |
Key Rules to Remember
json type in production. JSONB is faster to read and supports GIN indexing.@>) and existence (?) operators use GIN. Arrow operators need B-Tree expression index.(col->>'price')::numeric. Raw ->> returns text — numeric comparison will break.