JSON and JSONB — Working With Semi-Structured Data18 min read
Module P-5·18 min read
JSONB vs JSON, containment operators, GIN indexes, and when JSONB is the right tool vs. a schema design shortcut.
P-5 — JSON and JSONB: Working With Semi-Structured Data
Who this module is for: You have normalised relational schemas down. Sometimes, though, data does not fit neatly into fixed columns — product attributes vary by category, user preferences are open-ended, API responses have unpredictable shapes. PostgreSQL's JSONB gives you a relational database that also handles document-style data. This module covers when to reach for it, how to query it efficiently, and when using it is a mistake that will cost you later.
JSON vs JSONB — Choose JSONB
PostgreSQL has two JSON types:
JSON — stores the raw text of the JSON document, preserving whitespace, duplicate keys, and key ordering. Validation only on insert; no indexing support. Slower to process because it re-parses on every operation.
JSONB — stores JSON in a decomposed binary format. Duplicate keys are removed (last value wins), key ordering is not preserved, but: supports indexing, supports all operators, and processes faster. Use JSONB for everything.
sql
Inserting JSONB
sql
Querying JSONB — The Operator Reference
Extracting values
sql
Filtering with JSONB operators
sql
Modifying JSONB
sql
Building JSON from Relational Data
sql
GIN Indexes — Making JSONB Queries Fast
Without an index, every JSONB query scans every row. With a GIN index, containment (@>) and key existence (?, ?|, ?&) queries are fast.
sql
GIN index with specific path (index only one field — more efficient):
sql
JSONB Query Path Language (jsonpath)
PostgreSQL 12+ includes jsonpath — a mini-language for complex JSONB queries:
sql
When JSONB Is the Right Tool
Use JSONB when:
The schema is genuinely variable — product attributes differ by category (electronics have voltage, clothing has size/material), and you cannot enumerate all attributes upfront
Semi-structured external data — storing API responses, webhook payloads, or log data where the shape is not fully under your control
Infrequently queried sub-attributes — metadata that you store for display but rarely filter on
Rapid prototyping — the schema is not yet stable; JSONB buys time to define it
sql
When JSONB Is the Wrong Tool
Do NOT use JSONB when:
The data is structured and you query individual fields — if you always WHERE attributes->>'status' = 'active', that should be a proper TEXT NOT NULL column with a B-tree index
You need referential integrity — JSONB values cannot be foreign keys; you cannot REFERENCES a value inside a JSONB document
You need constraints on sub-fields — you cannot add CHECK (attributes->>'price' > 0) in a meaningful way; use a real column
The data is always the same shape — if every row has {"first_name": "...", "last_name": "..."}, that is two columns, not a JSONB object
sql
Practical Exercise: Product Catalog with Variable Attributes
sql
Summary
Concept
Key Takeaway
JSON vs JSONB
Always use JSONB — indexable, faster processing
->
Extract as JSONB (preserves type for nested access)
->>
Extract as TEXT (use for comparisons)
@>
Containment check — main operator for JSONB filtering
? / `?
/?&`
`
jsonb_set()
Update a nested value without replacing entire document
GIN index
Required for fast @> and ? queries on large tables
jsonb_build_object()
Construct JSONB in queries
jsonb_agg()
Aggregate rows into a JSON array
Good use cases
Variable attributes, external API data, open-ended metadata
Bad use cases
Structured data you always query, data needing FK constraints or CHECK constraints
Module P-6 covers access control — how to set up roles with the right permissions, and how Row-Level Security policies make data isolation automatic at the database level.
A development team decides to store their entire users table as a single JSONB column to "avoid schema migrations in the future". As a Senior Principal Software Engineer, why is this an architectural anti-pattern for a core relational entity like a user?
You are querying a products table with a JSONB column named attributes. You need to find all products where the battery_hours attribute is strictly greater than 20. Which of the following queries is the syntactically correct and optimal way to achieve this?
A highly trafficked e-commerce application frequently runs this exact query to find products in a specific color: SELECT * FROM products WHERE attributes @> '{"color": "black"}';. A developer wants to speed this up and runs: CREATE INDEX ON products ((attributes ->> 'color'));. After deployment, query performance does not improve. Why?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
-- JSONB is almost always the right choiceCREATETABLE products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, attributes JSONB -- variable product attributes);
-- -> returns JSONB (useful for nested objects/arrays)SELECT attributes ->'color'FROM products;-- Returns: "silver" (as JSONB with quotes)-- "blue"-- "black"-- null (Monitor has no 'color' key)-- ->> returns TEXT (useful for comparisons and display)SELECT attributes ->>'color'FROM products;-- Returns: silver (as plain text, no quotes)-- blue-- black-- (null)-- Navigate nested pathsSELECT attributes ->'specs'->'cpu'FROM products;-- nested JSONBSELECT attributes #> '{specs, cpu}' FROM products; -- path array syntaxSELECT attributes #>> '{specs, cpu}' FROM products; -- as TEXT-- Array element by index (0-based)SELECT attributes ->'ports'->0FROM products;-- first portSELECT attributes ->'tags'->>1FROM products;-- second tag as text
-- @> containment: does the JSONB contain this sub-document?SELECT name FROM products
WHERE attributes @>'{"color": "black"}';-- Returns: Headphones (has color=black)SELECT name FROM products
WHERE attributes @>'{"wireless": true}';-- Returns: Headphones-- Filter by array elementSELECT name FROM products
WHERE attributes @>'{"ports": ["HDMI"]}';-- Returns: Laptop (its ports array contains "HDMI")-- ? key existsSELECT name FROM products WHERE attributes ? 'wireless';-- Returns products that have a 'wireless' key-- ?| any key existsSELECT name FROM products WHERE attributes ?| ARRAY['wireless','hdr'];-- Returns products that have 'wireless' OR 'hdr'-- ?& all keys existSELECT name FROM products WHERE attributes ?& ARRAY['color','ram_gb'];-- Returns products that have BOTH 'color' AND 'ram_gb'-- Compare extracted value as textSELECT name FROM products
WHERE(attributes ->>'battery_hours')::INTEGER>20;-- Cast to INTEGER for numeric comparison
-- Add or update a keyUPDATE products
SET attributes = attributes ||'{"in_stock": true}'WHERE name ='Laptop';-- || merges two JSONB objects; right side wins on key conflicts-- Set a nested valueUPDATE products
SET attributes = jsonb_set(attributes,'{specs, ram_gb}','32')WHERE name ='Laptop';-- jsonb_set(target, path_array, new_value)-- Remove a keyUPDATE products
SET attributes = attributes -'color'WHERE name ='T-Shirt';-- - operator removes a key-- Remove a nested keyUPDATE products
SET attributes = attributes #- '{specs, old_field}'WHERE name ='Laptop';-- Add an element to an arrayUPDATE products
SET attributes = jsonb_set( attributes,'{ports}',(attributes ->'ports')||'"Thunderbolt"')WHERE name ='Laptop';
-- Build a JSON object from columnsSELECT jsonb_build_object('id', id,'name', name,'price', price
)FROM products;-- Build a JSON array of objects (aggregation)SELECT jsonb_agg( jsonb_build_object('id', id,'name', name))AS products_json
FROM products
WHERE in_stock =true;-- row_to_json: convert an entire row to JSONSELECT row_to_json(products.*)FROM products;-- Build nested JSON with joinsSELECT c.name AS customer, jsonb_build_object('total_orders',COUNT(o.id),'total_spent',SUM(o.total),'recent_orders', jsonb_agg( jsonb_build_object('id', o.id,'total', o.total)ORDERBY o.created_at DESC) FILTER (WHERE o.id ISNOTNULL))AS stats
FROM customers c
LEFTJOIN orders o ON c.id = o.customer_id
GROUPBY c.id, c.name;
-- GIN index on the entire JSONB column (jsonb_ops — default)CREATEINDEX idx_products_attributes ON products USING GIN (attributes);-- Now these queries use the index:SELECT*FROM products WHERE attributes @>'{"color": "black"}';SELECT*FROM products WHERE attributes ? 'wireless';-- GIN index with jsonb_path_ops (smaller, only supports @> operator)CREATEINDEX idx_products_attributes_path
ON products USING GIN (attributes jsonb_path_ops);-- Smaller index, faster @> queries, but does not support ? operators
-- If you always query by color, index just that pathCREATEINDEX idx_products_color
ON products ((attributes ->>'color'));-- Regular B-tree index on the extracted text value-- Efficient for:SELECT*FROM products WHERE attributes ->>'color'='black';-- Does NOT help with @> containment
-- Find products where any port is 'HDMI'SELECT name FROM products
WHERE attributes @? '$.ports[*] ? (@ == "HDMI")';-- Find products with battery > 20 hoursSELECT name FROM products
WHERE attributes @? '$.battery_hours ? (@ > 20)';-- Extract matching elementsSELECT jsonb_path_query(attributes,'$.ports[*]')AS port
FROM products
WHERE name ='Laptop';-- Returns each port as a separate row
-- Good use of JSONB: product metadata with variable structureCREATETABLE products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, price NUMERIC(10,2)NOTNULL, category TEXTNOTNULL, metadata JSONB -- category-specific attributes);-- Electronics: {"voltage": 110, "wattage": 65, "warranty_years": 2}-- Clothing: {"size": "M", "material": "cotton", "care": "machine wash"}-- Books: {"isbn": "978-...", "pages": 320, "publisher": "O'Reilly"}
-- ❌ BAD: using JSONB to avoid thinking about schemaCREATETABLE users ( id BIGSERIAL PRIMARYKEY,data JSONB -- {"email": "...", "name": "...", "role": "...", "created_at": "..."});-- No NOT NULL constraints, no UNIQUE on email, no type safety-- Every query must extract from JSONB instead of using columns-- ✅ GOOD: use proper columns for structured dataCREATETABLE users ( id BIGSERIAL PRIMARYKEY, email TEXTNOTNULLUNIQUE, name TEXTNOTNULL, role TEXTNOTNULLDEFAULT'user', created_at TIMESTAMPTZ NOTNULLDEFAULTNOW(), preferences JSONB -- only truly variable user preferences);
CREATETABLE catalog_products ( id BIGSERIAL PRIMARYKEY, name TEXTNOTNULL, category TEXTNOTNULL, base_price NUMERIC(10,2)NOTNULL, attributes JSONB NOTNULLDEFAULT'{}');CREATEINDEX idx_catalog_attributes ON catalog_products USING GIN (attributes);INSERTINTO catalog_products (name, category, base_price, attributes)VALUES('iPhone 15','phones',999.00,'{"storage_gb": 128, "color": "black", "5g": true, "os": "iOS 17"}'),('Galaxy S24','phones',899.00,'{"storage_gb": 256, "color": "white", "5g": true, "os": "Android 14"}'),('MacBook Air','laptops',1299.00,'{"ram_gb": 16, "storage_gb": 512, "chip": "M3", "color": "silver"}'),('Blue Jeans','clothing',79.00,'{"size": "32x32", "color": "blue", "material": "denim", "fit": "slim"}');-- Q1: All 5G phonesSELECT name, base_price
FROM catalog_products
WHERE category ='phones'AND attributes @>'{"5g": true}';-- Q2: Products available in blackSELECT name, category
FROM catalog_products
WHERE attributes @>'{"color": "black"}';-- Q3: Laptops with 16GB RAMSELECT name, base_price
FROM catalog_products
WHERE category ='laptops'AND(attributes ->>'ram_gb')::INTEGER>=16;-- Q4: All unique colors across all productsSELECTDISTINCT attributes ->>'color'AS color
FROM catalog_products
WHERE attributes ? 'color'ORDERBY color;-- Q5: Products grouped by OSSELECT attributes ->>'os'AS os,COUNT(*)AS count, jsonb_agg(name)AS products
FROM catalog_products
WHERE attributes ? 'os'GROUPBY attributes ->>'os';