Storing JSON with JSONB
Sometimes a row needs to hold data whose shape is not fixed in advance: a product whose attributes differ by category, an event payload from a third party, or user preferences that grow over time. PostgreSQL lets a single column store a whole JSON document with the jsonb type, and then query inside it as if its keys were columns.
For this lesson we use a table of products where each row carries a flexible attributes document.
Creating a JSONB column
Section titled “Creating a JSONB column”The jsonb type stores JSON in a decomposed binary form. Compared with the plain json type, it strips insignificant whitespace, removes duplicate keys, does not preserve key order, and — crucially — can be indexed.
CREATE TABLE products ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL, attributes jsonb NOT NULL DEFAULT '{}');Storing a document
Section titled “Storing a document”Inserting a row is ordinary INSERT; the document is just a JSON string that PostgreSQL parses into jsonb. From a driver you pass the JSON as a parameter rather than building strings by hand.
INSERT INTO products (name, attributes) VALUES ('Trail Runner', '{"color": "teal", "sizes": [8, 9, 10], "waterproof": true}'), ('City Loafer', '{"color": "brown", "sizes": [9, 10, 11], "leather": "suede"}');await pool.query( 'INSERT INTO products (name, attributes) VALUES ($1, $2)', ['Trail Runner', { color: 'teal', sizes: [8, 9, 10], waterproof: true }],);import json
cur.execute( "INSERT INTO products (name, attributes) VALUES (%s, %s)", ("Trail Runner", json.dumps({"color": "teal", "sizes": [8, 9, 10], "waterproof": True})),)attrs := map[string]any{"color": "teal", "sizes": []int{8, 9, 10}, "waterproof": true}_, err := conn.Exec(ctx, "INSERT INTO products (name, attributes) VALUES ($1, $2)", "Trail Runner", attrs)let attrs = serde_json::json!({ "color": "teal", "sizes": [8, 9, 10], "waterproof": true });sqlx::query("INSERT INTO products (name, attributes) VALUES ($1, $2)") .bind("Trail Runner") .bind(attrs) .execute(&pool) .await?;In pgAdmin: the attributes cell shows the JSON document; double-click it to open a larger editor view of the value.
Reaching inside a document
Section titled “Reaching inside a document”A handful of operators pull values out of a jsonb value. The two you reach for most are -> and ->>.
| Operator | Returns | Example |
|---|---|---|
-> | A jsonb value (object or array) | attributes -> 'sizes' |
->> | The value as text | attributes ->> 'color' |
#> | A jsonb value at a path | attributes #> '{sizes,0}' |
#>> | Text at a path | attributes #>> '{sizes,0}' |
The difference between -> and ->> matters: -> keeps the result as jsonb (good for chaining further into nested data), while ->> gives you plain text you can compare with strings or cast to a number.
SELECT name, attributes ->> 'color' AS color, attributes -> 'sizes' AS sizes, attributes #>> '{sizes, 0}' AS smallest_sizeFROM products;const res = await pool.query( `SELECT name, attributes ->> 'color' AS color, attributes -> 'sizes' AS sizes, attributes #>> '{sizes, 0}' AS smallest_size FROM products`,);console.log(res.rows);cur.execute( """ SELECT name, attributes ->> 'color' AS color, attributes -> 'sizes' AS sizes, attributes #>> '{sizes, 0}' AS smallest_size FROM products """)rows = cur.fetchall()rows, err := conn.Query(ctx, ` SELECT name, attributes ->> 'color' AS color, attributes -> 'sizes' AS sizes, attributes #>> '{sizes, 0}' AS smallest_size FROM products`)let rows = sqlx::query( "SELECT name, \ attributes ->> 'color' AS color, \ attributes -> 'sizes' AS sizes, \ attributes #>> '{sizes, 0}' AS smallest_size \ FROM products",).fetch_all(&pool).await?; name | color | sizes | smallest_size--------------+-------+------------+--------------- Trail Runner | teal | [8, 9, 10] | 8 City Loafer | brown | [9, 10, 11]| 9(2 rows)Filtering with containment and key tests
Section titled “Filtering with containment and key tests”Two operators are built for searching. The containment operator @> asks whether the left document contains the right one, and the existence operator ? asks whether a top-level key is present.
-- products whose document contains color = tealSELECT name FROM productsWHERE attributes @> '{"color": "teal"}';
-- products that have a top-level "leather" keySELECT name FROM productsWHERE attributes ? 'leather';const teal = await pool.query( "SELECT name FROM products WHERE attributes @> $1", [{ color: 'teal' }],);const leather = await pool.query( "SELECT name FROM products WHERE attributes ? 'leather'",);cur.execute( "SELECT name FROM products WHERE attributes @> %s", (json.dumps({"color": "teal"}),),)teal = cur.fetchall()cur.execute("SELECT name FROM products WHERE attributes ? 'leather'")leather = cur.fetchall()rows, err := conn.Query(ctx, "SELECT name FROM products WHERE attributes @> $1", map[string]any{"color": "teal"})let teal = sqlx::query("SELECT name FROM products WHERE attributes @> $1") .bind(serde_json::json!({ "color": "teal" })) .fetch_all(&pool) .await?; name-------------- Trail Runner(1 row)Updating part of a document
Section titled “Updating part of a document”To change one field without rewriting the whole document, use jsonb_set. It takes the column, a path as a text array, and the new value as jsonb.
UPDATE productsSET attributes = jsonb_set(attributes, '{color}', '"midnight"')WHERE name = 'Trail Runner'RETURNING name, attributes ->> 'color' AS color; name | color--------------+---------- Trail Runner | midnight(1 row)To add a brand-new field you can also use the concatenation operator: attributes || '{"on_sale": true}' merges the right document into the left.
Indexing for containment queries
Section titled “Indexing for containment queries”Without an index, a @> query scans every row and tests each document. A GIN index changes that: it indexes the keys and values inside the jsonb, so containment and key-existence lookups become fast.
CREATE INDEX idx_products_attributes ON products USING gin (attributes);CREATE INDEXAfter this, queries using @>, ?, ?|, and ?& can use the index. If you only ever query one specific path, an expression index on that path can be smaller and faster than indexing the whole document.
Tips / gotchas
Section titled “Tips / gotchas”- Prefer
jsonboverjsonfor almost everything. Thejsontype stores the exact text you sent (preserving whitespace and order) but cannot be indexed and re-parses on every access. - Use
jsonbfor genuinely variable data, not as a way to dodge schema design. If a field is always present and always the same type, a real column gives you type checking, defaults, and constraints. - Add a GIN index only when you actually query inside the document. Indexing data you only ever read back whole is wasted space and write overhead.
- Remember
->returnsjsonband->>returnstext. Comparingattributes -> 'color' = 'teal'fails because the left side isjsonb; use->>for text comparisons.