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.

Code on a dark screen
Yes, this is what my terminal looks like at 2am. ( Unsplash )

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'; -- ERROR
Server technology
Data flows through this somewhere. Probably. ( Unsplash )

3. 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}';
Server rack
Rows of something. Sounds like my query results before I added that index. ( Unsplash )

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.