11.10 JSON Data
Table of Contents
Why JSON Matters in PostgreSQL
Backend APIs frequently send and receive JSON. PostgreSQL has first‑class support for JSON, which lets you:
- Store flexible or semi‑structured data.
- Avoid complex migrations for small schema changes.
- Work with data similar to what your API already consumes or produces.
PostgreSQL actually has two JSON related types: json and jsonb. Understanding them and the related functions is key to using JSON effectively.
JSON vs JSONB
PostgreSQL supports:
jsontypejsonbtype (binary JSON)
They look similar at first, but behave differently internally and in queries.
Core differences
| Feature | json | jsonb |
|---|---|---|
| Storage format | Text | Binary, parsed |
| Validation on insert | Yes, must be valid JSON | Yes, must be valid JSON |
| Preserves whitespace | Yes | No |
| Preserves key order | Yes | No |
| Duplicate keys | Preserved as‑is | Last key wins |
| Indexing support | Limited | Rich support (GIN, etc.) |
| Query performance (operators) | Slower, re‑parses text | Faster, pre‑parsed |
| Recommended for new applications | Rarely | Almost always |
Rule: For backend applications, use jsonb by default unless you have a very specific reason to preserve original JSON formatting exactly.
Storing JSON Data
You can define columns of type json or jsonb in your tables.
Creating a table with JSONB
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
attributes JSONB, -- flexible product attributes
metadata JSONB -- extra info, logs, tags, etc.
);Inserting data:
INSERT INTO products (name, attributes, metadata)
VALUES (
'T‑Shirt',
'{ "color": "blue", "size": "M", "in_stock": true }',
'{
"tags": ["clothing", "summer"],
"created_by": "admin",
"dimensions": { "width": 40, "height": 60 }
}'
);PostgreSQL validates JSON on insert. If you insert invalid JSON, you get an error:
INSERT INTO products (name, attributes)
VALUES ('Broken', '{color: blue}'); -- invalid JSON, keys must be quotedThis will fail because JSON requires double quotes around keys and string values.
Using parameterized queries in code
In a Python backend using psycopg2 or asyncpg, you pass JSON as native structures:
cur.execute(
"""
INSERT INTO products (name, attributes)
VALUES (%s, %s)
RETURNING id
""",
(
"Sneakers",
{
"color": "white",
"size": 42,
"in_stock": True,
},
)
)
The driver converts the Python dict into JSONB automatically if the column is jsonb.
Basic JSONB Operations
PostgreSQL provides special operators for JSON/JSONB. Many are used constantly in backend queries.
Access operators: `->`, `->>`, `#>`, `#>>`
| Operator | Returns | Usage example |
|---|---|---|
-> | JSON / JSONB | Access field as JSON |
->> | text | Access field as text |
#> | JSON / JSONB | Nested path access as JSON |
#>> | text | Nested path access as text |
Assume:
SELECT attributes
FROM products
WHERE name = 'T‑Shirt';Returns:
{ "color": "blue", "size": "M", "in_stock": true }Example 1: Get a top level key
SELECT
attributes->'color' AS color_json, -- JSON
attributes->>'color' AS color_text -- text
FROM products;Result:
| color_json | color_text |
|---|---|
"blue" | blue |
Example 2: Nested keys
Given:
SELECT metadata FROM products WHERE name = 'T‑Shirt';{
"tags": ["clothing", "summer"],
"created_by": "admin",
"dimensions": { "width": 40, "height": 60 }
}Use path operators:
SELECT
metadata->'dimensions'->'width' AS width_json,
metadata->'dimensions'->>'width' AS width_text,
metadata#>'{dimensions,width}' AS width_json2,
metadata#>>'{dimensions,width}' AS width_text2
FROM products;
'{dimensions,width}' represents the path ["dimensions", "width"].
Querying JSONB: Containment and Existence
These operators are extremely useful for filtering records by JSON values.
Containment: `@>`
jsonb @> jsonb is true if the left JSON contains the right JSON.
For example, products whose attributes include "color": "blue":
SELECT name, attributes
FROM products
WHERE attributes @> '{"color": "blue"}';This works even if there are additional keys.
Containment for arrays:
SELECT name, metadata
FROM products
WHERE metadata->'tags' @> '["summer"]';
This returns rows where tags array contains "summer".
Existence: `?`, `?|`, `?&`
| Operator | Description | |
|---|---|---|
? | Does key (or string in array) exist? | |
| `? | ` | Does any of these keys/strings exist? |
?& | Do all of these keys/strings exist? |
Assume attributes like:
{ "color": "blue", "size": "M", "in_stock": true }Check if key exists:
SELECT name
FROM products
WHERE attributes ? 'color';Check if any of several keys exist:
SELECT name
FROM products
WHERE attributes ?| array['size', 'weight'];Check if all keys exist:
SELECT name
FROM products
WHERE attributes ?& array['color', 'size'];These also work on JSON arrays of strings.
Updating JSONB
You often need to update only part of a JSON document without replacing the whole value.
Concatenation and merge: `||`
jsonb || jsonb merges objects. Right side overwrites conflicts.
UPDATE products
SET attributes = attributes || '{"color": "red"}'
WHERE name = 'T‑Shirt';
If attributes was:
{ "color": "blue", "size": "M", "in_stock": true }After update:
{ "color": "red", "size": "M", "in_stock": true }You can also add new keys:
UPDATE products
SET attributes = attributes || '{"material": "cotton"}'
WHERE name = 'T‑Shirt';Result:
{ "color": "red", "size": "M", "in_stock": true, "material": "cotton" }Remove keys: `-` and `#-`
| Operator | Description |
|---|---|
- | Remove top level key or array element |
#- | Remove nested key or element by path |
Remove a top level key:
UPDATE products
SET attributes = attributes - 'size'
WHERE name = 'T‑Shirt';Remove multiple keys:
UPDATE products
SET attributes = attributes - array['size', 'in_stock']
WHERE name = 'T‑Shirt';Remove a nested key:
UPDATE products
SET metadata = metadata #- '{dimensions,width}'
WHERE name = 'T‑Shirt';
If dimensions was:
{ "width": 40, "height": 60 }after this, it becomes:
{ "height": 60 }JSONB Functions You Will Use Often
PostgreSQL has many JSON functions. Here are some that are very helpful in backend work.
`jsonb_build_object` and `jsonb_build_array`
These build JSONB from SQL values.
SELECT jsonb_build_object(
'id', id,
'name', name,
'attributes', attributes
) AS product_json
FROM products;Example result:
{
"id": 1,
"name": "T‑Shirt",
"attributes": { "color": "red", "material": "cotton" }
}You can send this directly to an API client.
Build an array:
SELECT jsonb_build_array(1, 'two', true, jsonb_build_object('a', 1));Result:
[1, "two", true, { "a": 1 }]`to_jsonb`
Convert SQL values and rows to JSONB.
Simple value:
SELECT to_jsonb(42); -- 42
SELECT to_jsonb('hello'); -- "hello"Whole row:
SELECT to_jsonb(products)
FROM products
WHERE id = 1;This converts the entire row (all columns) to a JSONB object.
Aggregating to JSON: `json_agg`, `jsonb_agg`
For building JSON arrays of rows:
SELECT jsonb_agg(to_jsonb(p))
FROM products AS p;Result:
[
{ "id": 1, "name": "T‑Shirt", "attributes": { ... } },
{ "id": 2, "name": "Sneakers", "attributes": { ... } }
]This is perfect when your backend needs to send a JSON array of objects.
Another common REST API pattern:
SELECT jsonb_build_object(
'total', COUNT(*),
'items', jsonb_agg(to_jsonb(p))
)
FROM products AS p;Result:
{
"total": 2,
"items": [
{ "id": 1, "name": "T‑Shirt", ... },
{ "id": 2, "name": "Sneakers", ... }
]
}Indexing JSONB for Performance
JSONB queries using @>, ?, etc can be slow without indexes.
GIN indexes
For JSONB you typically use GIN indexes.
Create a GIN index on a JSONB column:
CREATE INDEX products_attributes_gin_idx
ON products
USING GIN (attributes);This helps for:
SELECT *
FROM products
WHERE attributes @> '{"color": "blue"}';or
SELECT *
FROM products
WHERE attributes ? 'color';If you often query specific paths, you can index expressions.
For example, filter by attributes->>'size':
CREATE INDEX products_attributes_size_idx
ON products ((attributes->>'size'));Now this query can use an index:
SELECT *
FROM products
WHERE attributes->>'size' = 'M';Rule: If you query JSONB columns frequently, add appropriate GIN or expression indexes. Without them, JSON filters can become very slow on large tables.
When to Use JSONB vs Normal Columns
JSONB is powerful, but not always the right tool. Think of it as a complement to relational tables, not a full replacement.
Good use cases
- Optional or rarely used fields that vary by record.
Example:attributesfor products where each product type has different extra data. - External, loosely structured data from third party APIs, logs, or tracking events.
- Temporary or experimental fields during early development when the schema is not stable.
- Configuration blobs that are small, per record, and not heavily queried.
Poor use cases
- Core entities with fixed structure, like
users(id, email, password_hash). - Data that you frequently filter, sort, or join on, where strong relational modeling is better.
- Very large JSON documents attached to each row, which can be expensive to update.
A common hybrid approach:
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
status TEXT NOT NULL,
total_amount NUMERIC(10,2) NOT NULL,
details JSONB -- line items, extra info
);
Core fields are normal columns, the flexible remainder lives in details.
Practical Examples for Backend Work
Example 1: Filtering by JSON attribute
You have an API endpoint:
GET /products?color=blue&size=MSQL using JSONB:
SELECT *
FROM products
WHERE attributes @> format('{"color": "%s", "size": "%s"}', 'blue', 'M')::jsonb;In real backend code, use parameterized queries instead of string formatting.
Example 2: Sorting by JSON field
Sort products by nested dimensions.height:
SELECT name, metadata->'dimensions'->>'height' AS height
FROM products
ORDER BY (metadata->'dimensions'->>'height')::INTEGER;
Note the cast to INTEGER to sort numerically instead of lexicographically.
Example 3: Partial update from API
You receive a PATCH request:
{
"attributes": {
"color": "green",
"in_stock": false
}
}
You want to merge this into existing attributes:
UPDATE products
SET attributes = attributes || '{"color": "green", "in_stock": false}'
WHERE id = 1;In application code, you would build the JSON dynamically from the request body.
Summary
- Use
jsonbin PostgreSQL to store flexible, semi structured data in your backend. - Learn the key operators:
->,->>,#>,#>>for access.@>for containment.?,?|,?&for existence.||,-,#-for updates and deletions.- Use
jsonb_build_object,jsonb_agg, andto_jsonbto build JSON responses directly in SQL. - Add GIN and expression indexes for fast JSONB queries.
- Combine relational columns for core data and JSONB for flexible attributes.
With these tools, you can design PostgreSQL schemas that work naturally with JSON based web APIs while still keeping good performance and structure.
Views: 7
KAHIBARO