I stored JSON in PostgreSQL for two years before I realized I was doing it wrong. I mean, I had a column called metadata typed as TEXT. I was stringifying before insert and parsing after select. It worked. It was also slow, unqueryable, and frankly embarrassing. JSONB fixes all of that and most people still treat it like a blob they dump things into.
Here are the 5 JSONB things I use on actual production tables.
1. Use JSONB, Not JSON
The JSON type stores text. JSONB stores a binary parsed representation. The difference: JSONB supports indexing, JSON does not. That is the entire point. If you need to query inside the column, JSONB. If you are literally never searching inside it, I guess JSON is fine. I have never encountered that situation.
CREATE TABLE events (
id SERIAL PRIMARY KEY,
payload JSONB NOT NULL DEFAULT '{}'
);
-- This works on JSONB, not on JSON
CREATE INDEX idx_payload_type ON events ((payload->>'type'));2. Arrow Operators: -> vs ->>
I mixed these up constantly at first. -> returns JSONB. ->> returns text. That is the whole distinction. Use -> when you want to keep chaining into nested keys. Use ->> when you want a value you can compare with =, LIKE, or cast to a number.
-- Get nested value ( returns JSONB )
SELECT payload->'user'->'name' FROM events;
-- Get text for comparison ( returns text )
SELECT * FROM events WHERE payload->>'type' = 'click';
-- This returns NULL because ->> gives text, not JSONB
SELECT payload->>'user'->>'name'; -- ERROR3. GIN Indexes for Contains Queries
The @> operator checks if a JSONB value contains another. Super useful when you store tags or categories as arrays inside a JSONB field. Without a GIN index, this crawls. With one, it is fast.
-- Add tags to events
ALTER TABLE events ADD COLUMN tags JSONB DEFAULT '[]';
-- GIN index for containment queries
CREATE INDEX idx_tags_gin ON events USING GIN (tags);
-- Find events that have both "devops" and "docker" tags
SELECT * FROM events
WHERE tags @> '["devops", "docker"]'::jsonb;
-- Find events that have ANY of these tags ( ?| operator )
SELECT * FROM events
WHERE tags ?| array['devops', 'python'];4. jsonb_set and - for Mutations
You do not need to read-modify-write in your application code. PostgreSQL can mutate JSONB in-place. jsonb_set updates a key. The - operator removes a key. I use this in migration scripts constantly.
-- Update a nested field
UPDATE events
SET payload = jsonb_set(payload, '{user,email}', '"new@test.com"')
WHERE id = 42;
-- Remove a key entirely
UPDATE events
SET payload = payload - 'deprecated_field'
WHERE payload ? 'deprecated_field';
-- Remove a nested key ( use path with - at end )
UPDATE events
SET payload = payload #- '{user,old_field}';5. jsonb_path_query for Nested Searches
PostgreSQL 12 added SQL/JSON path expressions. If you have ever fought with nested arrays inside JSONB, this is the answer. You write a path expression similar to XPath, and it returns matching values. It replaced about 40 lines of ugly subquery+unnest chains in my code.
-- Find all event names where nested user role is admin
SELECT * FROM jsonb_path_query(
payload,
'$.users[*] ? (@.role == "admin").name'
);
-- Use it in a WHERE clause
SELECT id, payload
FROM events
WHERE jsonb_path_exists(
payload,
'$.users[*] ? (@.role == "admin")'
);
-- With a variable ( safer, no SQL injection in path )
SELECT * FROM events
WHERE jsonb_path_exists(
payload,
'$.users[*] ? (@.role == $role)',
jsonb_build_object('role', 'admin')
);JSONB is one of those features that sounds niche until you actually use it. Then you wonder how you built anything without it. I still see people serializing JSON to text columns and it makes me wince every time. Stop doing that. Use JSONB. Add a GIN index. Your future self will thank you when that query goes from 8 seconds to 12ms.