SQLMentor // learn postgresql

JSON & JSONB

PostgreSQL has first-class support for JSON data with the JSON and JSONB types, plus a rich set of operators and functions. JSONB (binary JSON) is almost always what you want — it's parsed, indexed, and enables powerful queries without writing application-side parsing code.

JSON vs JSONB

Feature JSON JSONB
Storage format Plain text Binary (parsed)
Parsing cost On every read At write time only
Indexable No Yes (GIN)
Preserves whitespace Yes No
Preserves duplicate keys Yes (keeps last) No (keeps last)
Preserves key order Yes No
Write speed Faster Slightly slower
Read/query speed Slower Faster

Always use JSONB unless you specifically need to preserve the exact original JSON text (whitespace, key order, duplicate keys).

CREATE TABLE user_profiles (
    user_id     INTEGER PRIMARY KEY,
    username    TEXT,
    preferences JSONB,  -- use JSONB
    raw_payload JSON    -- only if exact text preservation matters
);

Creating JSONB Data

-- Insert JSON directly
INSERT INTO user_profiles (user_id, username, preferences) VALUES
(1, 'alice', '{"theme": "dark", "notifications": true, "language": "en"}'),
(2, 'bob',   '{"theme": "light", "language": "fr", "tags": ["sql", "postgres"]}'),
(3, 'carol', '{"theme": "dark", "notifications": false, "score": 9.5}');

-- Build JSON using functions
INSERT INTO user_profiles (user_id, username, preferences) VALUES (
    4, 'dave',
    JSONB_BUILD_OBJECT(
        'theme', 'light',
        'language', 'de',
        'active', true
    )
);

Extracting Data — Operators

Operator Returns Example
-> JSON (preserves type) data->'key'"dark"
->> TEXT data->>'key'dark
#> JSON at path data#>'{a,b}'
#>> TEXT at path data#>>'{a,b}'
-- Get a top-level key as JSON
SELECT preferences->'theme' FROM user_profiles WHERE user_id = 1;
-- Result: "dark"   (JSON string with quotes)

-- Get as text (more useful for comparisons)
SELECT preferences->>'theme' FROM user_profiles WHERE user_id = 1;
-- Result: dark   (plain text, no quotes)

-- Navigate nested objects
SELECT data->>'city'
FROM (VALUES ('{"address": {"city": "Paris", "zip": "75001"}}'::jsonb)) AS t(data)
     -- #>> is more convenient for paths:
SELECT data#>>'{address,city}' AS city FROM ... ;
-- Result: Paris

-- Access array elements (0-based)
SELECT preferences->'tags'->0 FROM user_profiles WHERE user_id = 2;
-- Result: "sql"

SELECT preferences->'tags'->>1 FROM user_profiles WHERE user_id = 2;
-- Result: postgres

Filtering with JSONB Operators

Operator Meaning
@> Left contains right
<@ Left is contained in right
? Key exists
`? `
?& All of these keys exist
-- Find users with dark theme
SELECT username FROM user_profiles
WHERE preferences @> '{"theme": "dark"}';
-- alice, carol

-- Find users with notifications enabled
SELECT username FROM user_profiles
WHERE preferences @> '{"notifications": true}';
-- alice

-- Find users who have a 'language' key
SELECT username FROM user_profiles
WHERE preferences ? 'language';
-- alice, bob, carol, dave

-- Find users who have either 'tags' or 'score'
SELECT username FROM user_profiles
WHERE preferences ?| ARRAY['tags', 'score'];
-- bob (has tags), carol (has score)

-- Find users who have BOTH 'theme' and 'language'
SELECT username FROM user_profiles
WHERE preferences ?& ARRAY['theme', 'language'];
-- alice, bob, carol, dave
The @> (containment) operator is the most useful and the one that benefits most from a GIN index. Write your WHERE conditions to use @> when possible.

Modifying JSONB

jsonb_set — Update a Field

-- Update a specific key (non-destructive — keeps other keys)
UPDATE user_profiles
SET preferences = JSONB_SET(preferences, '{theme}', '"auto"')
WHERE user_id = 1;

-- Add a new nested key
UPDATE user_profiles
SET preferences = JSONB_SET(preferences, '{address,city}', '"London"', true)
-- true = create the path if it doesn't exist
WHERE user_id = 3;

-- Update an array element
UPDATE user_profiles
SET preferences = JSONB_SET(preferences, '{tags,0}', '"database"')
WHERE user_id = 2;

jsonb_insert — Insert Into Array or Object

-- Insert at index 1 of an array (shifts elements right)
UPDATE user_profiles
SET preferences = JSONB_INSERT(preferences, '{tags,1}', '"beginner"')
WHERE user_id = 2;
-- tags was: ["sql", "postgres"]
-- tags now: ["sql", "beginner", "postgres"]

Concatenation with ||

-- Merge/add fields (rightmost wins on key conflict)
UPDATE user_profiles
SET preferences = preferences || '{"notifications": true, "newsletter": false}'
WHERE user_id = 2;

-- Remove a key with - operator
UPDATE user_profiles
SET preferences = preferences - 'newsletter'
WHERE user_id = 2;

-- Remove multiple keys
UPDATE user_profiles
SET preferences = preferences - ARRAY['newsletter', 'raw_data']
WHERE user_id = 3;

-- Remove by path (nested)
UPDATE user_profiles
SET preferences = preferences #- '{address,zip}'
WHERE user_id = 3;

Indexing JSONB

GIN Index for Containment Queries

-- Full GIN index — supports @>, ?, ?|, ?&
CREATE INDEX user_prefs_gin ON user_profiles USING GIN (preferences);

-- Smaller index — only supports @> (faster build, smaller size)
CREATE INDEX user_prefs_gin_path ON user_profiles USING GIN (preferences jsonb_path_ops);

B-tree Index on a JSONB Expression

-- Index on a specific JSON field for equality queries
CREATE INDEX user_prefs_theme ON user_profiles ((preferences->>'theme'));

-- Now this uses the index:
SELECT * FROM user_profiles WHERE preferences->>'theme' = 'dark';

JSON Path Queries

PostgreSQL 12+ introduced jsonb_path_query for XPath-style path expressions:

-- Find items where price > 20 in a JSON array
SELECT jsonb_path_query(
    '[{"name":"Widget","price":15},{"name":"Gadget","price":49.99}]',
    '$[*] ? (@.price > 20)'
);
-- Returns: {"name": "Gadget", "price": 49.99}

-- Use in WHERE
SELECT * FROM products_json
WHERE preferences @@ '$.score > 9'::jsonpath;
-- @@ is the jsonpath match operator

Aggregating to JSON

-- Aggregate rows into a JSON array
SELECT JSON_AGG(
    JSON_BUILD_OBJECT('id', id, 'name', username)
    ORDER BY username
) AS users
FROM user_profiles;
-- [{"id":1,"name":"alice"},{"id":2,"name":"bob"},...]

-- Build a JSON object with key → value from pairs
SELECT JSONB_OBJECT_AGG(username, preferences->>'theme') AS theme_map
FROM user_profiles;
-- {"alice":"dark","bob":"light","carol":"dark","dave":"light"}

Expanding JSON

-- Expand JSONB object into key-value rows
SELECT key, value
FROM user_profiles, JSONB_EACH(preferences)
WHERE user_id = 1;

-- key          | value
-- -------------|------
-- theme        | "dark"
-- notifications| true
-- language     | "en"

-- Text version
SELECT key, value
FROM user_profiles, JSONB_EACH_TEXT(preferences)
WHERE user_id = 1;

-- Expand a JSONB array into rows
SELECT value FROM JSONB_ARRAY_ELEMENTS('[1, 2, 3, 4, 5]'::jsonb);
-- 1, 2, 3, 4, 5 (each as a separate row)

SELECT value->>'name' AS tag
FROM user_profiles, JSONB_ARRAY_ELEMENTS(preferences->'tags') AS value
WHERE user_id = 2;

Common Patterns

Flexible Metadata Column

CREATE TABLE events (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    event_type TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT NOW(),
    metadata   JSONB
);

-- Store different shapes per event type
INSERT INTO events (event_type, metadata) VALUES
('page_view',  '{"url": "/home", "user_agent": "Chrome", "duration_ms": 450}'),
('purchase',   '{"product_id": 42, "amount": 29.99, "currency": "USD"}'),
('error',      '{"code": 500, "message": "Internal error", "stack": "..."}');

-- Query specific event types efficiently
CREATE INDEX events_type_idx ON events (event_type);
CREATE INDEX events_metadata_gin ON events USING GIN (metadata);

SELECT metadata->>'url', metadata->>'duration_ms'
FROM events
WHERE event_type = 'page_view'
  AND (metadata->>'duration_ms')::INTEGER > 1000;
JSONB is powerful but not a replacement for relational design. Use proper columns for frequently queried fields (put them in indexes). Use JSONB for flexible, infrequently queried attributes. Querying data->>'field' = 'value' without an expression index is always a full table scan.