PostgreSQL's JSON support enables flexible data modeling. Here's how to query and manipulate JSON data effectively.
JSON vs JSONB#
1-- JSON: Stores exact copy, preserves whitespace and order
2-- JSONB: Binary format, faster queries, supports indexing
3
4CREATE TABLE users (
5 id SERIAL PRIMARY KEY,
6 profile JSON, -- Use for exact preservation
7 settings JSONB -- Use for querying (recommended)
8);
9
10-- JSONB is generally preferred
11CREATE TABLE products (
12 id SERIAL PRIMARY KEY,
13 name VARCHAR(255),
14 attributes JSONB DEFAULT '{}'::jsonb,
15 metadata JSONB
16);
17
18-- Insert JSON data
19INSERT INTO products (name, attributes, metadata)
20VALUES (
21 'Laptop',
22 '{"brand": "Apple", "specs": {"ram": 16, "storage": 512}}',
23 '{"tags": ["electronics", "computer"], "rating": 4.5}'
24);Basic JSON Queries#
1-- Access JSON field (returns JSON)
2SELECT attributes->'brand' FROM products;
3-- Result: "Apple" (with quotes)
4
5-- Access JSON field as text
6SELECT attributes->>'brand' FROM products;
7-- Result: Apple (without quotes)
8
9-- Nested access
10SELECT attributes->'specs'->'ram' FROM products;
11SELECT attributes->'specs'->>'ram' FROM products;
12
13-- Path access
14SELECT attributes #> '{specs, ram}' FROM products;
15SELECT attributes #>> '{specs, ram}' FROM products;
16
17-- Filter by JSON value
18SELECT * FROM products
19WHERE attributes->>'brand' = 'Apple';
20
21-- Filter by nested value
22SELECT * FROM products
23WHERE (attributes->'specs'->>'ram')::int >= 16;
24
25-- Check if key exists
26SELECT * FROM products
27WHERE attributes ? 'brand';
28
29-- Check if any key exists
30SELECT * FROM products
31WHERE attributes ?| array['brand', 'model'];
32
33-- Check if all keys exist
34SELECT * FROM products
35WHERE attributes ?& array['brand', 'specs'];Array Operations#
1-- Access array element (0-indexed)
2SELECT metadata->'tags'->0 FROM products;
3-- Result: "electronics"
4
5-- Check if array contains value
6SELECT * FROM products
7WHERE metadata->'tags' ? 'electronics';
8
9-- Array contains all values
10SELECT * FROM products
11WHERE metadata->'tags' ?& array['electronics', 'computer'];
12
13-- Expand array to rows
14SELECT id, jsonb_array_elements_text(metadata->'tags') as tag
15FROM products;
16
17-- Array length
18SELECT jsonb_array_length(metadata->'tags') FROM products;
19
20-- Filter by array contents
21SELECT * FROM products
22WHERE metadata @> '{"tags": ["electronics"]}';Containment and Existence#
1-- Contains (left contains right)
2SELECT * FROM products
3WHERE attributes @> '{"brand": "Apple"}';
4
5-- Contained by (left is contained by right)
6SELECT * FROM products
7WHERE '{"brand": "Apple", "specs": {"ram": 16}}'::jsonb @> attributes;
8
9-- Complex containment
10SELECT * FROM products
11WHERE attributes @> '{"specs": {"ram": 16}}';
12
13-- Top-level key existence
14SELECT * FROM products
15WHERE attributes ? 'brand';
16
17-- Nested key check (use path)
18SELECT * FROM products
19WHERE attributes #> '{specs}' ? 'ram';Modifying JSON#
1-- Set/update value
2UPDATE products
3SET attributes = jsonb_set(attributes, '{brand}', '"Dell"')
4WHERE id = 1;
5
6-- Set nested value
7UPDATE products
8SET attributes = jsonb_set(attributes, '{specs, ram}', '32')
9WHERE id = 1;
10
11-- Create nested path if missing
12UPDATE products
13SET attributes = jsonb_set(
14 attributes,
15 '{specs, gpu}',
16 '"NVIDIA RTX 4090"',
17 true -- create_if_missing
18)
19WHERE id = 1;
20
21-- Remove key
22UPDATE products
23SET attributes = attributes - 'brand'
24WHERE id = 1;
25
26-- Remove nested key
27UPDATE products
28SET attributes = attributes #- '{specs, ram}'
29WHERE id = 1;
30
31-- Remove array element by index
32UPDATE products
33SET metadata = metadata #- '{tags, 0}'
34WHERE id = 1;
35
36-- Concatenate/merge JSON
37UPDATE products
38SET attributes = attributes || '{"color": "silver", "weight": 1.4}'::jsonb
39WHERE id = 1;
40
41-- Insert into array
42UPDATE products
43SET metadata = jsonb_set(
44 metadata,
45 '{tags}',
46 metadata->'tags' || '"portable"'::jsonb
47)
48WHERE id = 1;Aggregation and Transformation#
1-- Build JSON object from columns
2SELECT jsonb_build_object(
3 'id', id,
4 'name', name,
5 'brand', attributes->>'brand'
6) as product_json
7FROM products;
8
9-- Build JSON array
10SELECT jsonb_agg(name) as product_names
11FROM products;
12
13-- Aggregate to object
14SELECT jsonb_object_agg(name, attributes)
15FROM products;
16
17-- Transform rows to JSON
18SELECT row_to_json(products) FROM products;
19
20-- Pretty print
21SELECT jsonb_pretty(attributes) FROM products;
22
23-- Get all keys
24SELECT DISTINCT jsonb_object_keys(attributes)
25FROM products;
26
27-- Expand object to rows
28SELECT id, key, value
29FROM products, jsonb_each(attributes);
30
31-- Expand with text values
32SELECT id, key, value
33FROM products, jsonb_each_text(attributes);Indexing JSON#
1-- GIN index for containment queries (@>, ?, ?|, ?&)
2CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
3
4-- GIN index with jsonb_path_ops (smaller, @> only)
5CREATE INDEX idx_products_attributes_path ON products
6USING GIN (attributes jsonb_path_ops);
7
8-- Expression index for specific path
9CREATE INDEX idx_products_brand ON products
10USING BTREE ((attributes->>'brand'));
11
12-- Partial index
13CREATE INDEX idx_active_products ON products
14USING GIN (attributes)
15WHERE (metadata->>'active')::boolean = true;
16
17-- Query using GIN index
18EXPLAIN ANALYZE
19SELECT * FROM products
20WHERE attributes @> '{"brand": "Apple"}';
21
22-- Query using expression index
23EXPLAIN ANALYZE
24SELECT * FROM products
25WHERE attributes->>'brand' = 'Apple';JSON Path Queries (PostgreSQL 12+)#
1-- JSONPath syntax
2SELECT * FROM products
3WHERE attributes @? '$.specs.ram ? (@ >= 16)';
4
5-- Extract with JSONPath
6SELECT jsonb_path_query(attributes, '$.specs.*')
7FROM products;
8
9-- Check existence
10SELECT jsonb_path_exists(attributes, '$.specs.ram')
11FROM products;
12
13-- Query with filter
14SELECT jsonb_path_query_array(
15 metadata,
16 '$.tags[*] ? (@ like_regex "^elec")'
17)
18FROM products;
19
20-- Complex path queries
21SELECT * FROM products
22WHERE attributes @? '$.specs ? (@.ram >= 16 && @.storage >= 256)';Practical Examples#
1-- E-commerce product filtering
2SELECT * FROM products
3WHERE attributes @> '{"brand": "Apple"}'
4 AND (attributes->'specs'->>'ram')::int >= 16
5 AND metadata->'tags' ? 'laptop';
6
7-- User preferences with defaults
8SELECT
9 id,
10 COALESCE(settings->>'theme', 'light') as theme,
11 COALESCE((settings->>'notifications')::boolean, true) as notifications
12FROM users;
13
14-- Aggregate product stats by brand
15SELECT
16 attributes->>'brand' as brand,
17 COUNT(*) as count,
18 AVG((attributes->'specs'->>'ram')::numeric) as avg_ram
19FROM products
20WHERE attributes ? 'brand'
21GROUP BY attributes->>'brand';
22
23-- Search in nested arrays
24SELECT * FROM products
25WHERE EXISTS (
26 SELECT 1
27 FROM jsonb_array_elements_text(metadata->'tags') as tag
28 WHERE tag ILIKE '%computer%'
29);
30
31-- Build API response
32SELECT jsonb_build_object(
33 'products', jsonb_agg(
34 jsonb_build_object(
35 'id', id,
36 'name', name,
37 'brand', attributes->>'brand',
38 'tags', metadata->'tags'
39 )
40 ),
41 'total', COUNT(*)
42) as response
43FROM products;Best Practices#
Schema Design:
✓ Use JSONB over JSON
✓ Index frequently queried paths
✓ Consider partial indexes
✓ Don't over-normalize into JSON
Performance:
✓ Use containment operators with GIN
✓ Expression indexes for equality
✓ Avoid full-table JSON parsing
✓ Monitor query plans
Data Integrity:
✓ Use CHECK constraints
✓ Validate JSON structure
✓ Handle NULL vs missing keys
✓ Consider generated columns
Conclusion#
PostgreSQL's JSON support enables flexible schemas while maintaining query performance. Use JSONB with GIN indexes for most cases, expression indexes for specific paths, and JSONPath for complex queries. Balance flexibility with structure for maintainable data models.