EthicsComputerCourses
Join training +91 90532 10052

JSON & JSONB

PostgreSQL · Lesson 19 of 32 Intermediate 6 min read Updated

Some data does not fit neatly into fixed columns. Product specifications differ between a keyboard and a speaker, payment gateways send callbacks with dozens of fields, and app settings change every release. PostgreSQL can store such data as JSON (JavaScript Object Notation, the text format used by most web APIs) and query inside it with SQL, while the rest of your data stays in normal columns.

In this lesson you will store JSON in a jsonb column, read values out of it, search it, change it, turn it into rows and back, and index it.

json vs jsonb

PostgreSQL has two JSON types:

TypeStoresUse
jsonThe exact text you gave it, re-parsed on every readRarely; when you must keep key order, spacing or duplicate keys
jsonbA parsed binary form: keys sorted, duplicates removedAlmost always: faster to query and can be indexed
PostgreSQLTry it yourself
SELECT '{"b": 2, "a": 1, "a": 3}'::json  AS as_json,
       '{"b": 2, "a": 1, "a": 3}'::jsonb AS as_jsonb;
Output
as_json                  | as_jsonb
-------------------------+-----------------
{"b": 2, "a": 1, "a": 3} | {"a": 3, "b": 2}
(1 row)

With jsonb, the last duplicate key wins and keys are reordered. That is fine for data, and it is why the rest of this lesson uses jsonb. Both types reject invalid JSON with an error.

Reading values: -> and ->>

The two most important operators differ by one character:

OperatorReturnsExample
->A jsonb value (key or array index)specs -> 'battery'
->>A text valuespecs ->> 'colour'
#> / #>>Value at a path, as jsonb / textspecs #>> '{size,inches}'
PostgreSQLTry it yourself
CREATE TABLE product_specs (
  product_id int PRIMARY KEY,
  specs jsonb NOT NULL
);

INSERT INTO product_specs VALUES
  (1, '{"colour": "black", "wireless": true, "battery": {"type": "AA", "hours": 400}, "dpi": [800, 1600]}'),
  (6, '{"colour": "silver", "wireless": true, "battery": {"type": "Li-ion", "hours": 30}}'),
  (3, '{"colour": "black", "wireless": false, "size": {"inches": 27}, "ports": ["HDMI", "DP"]}');

SELECT product_id,
       specs ->> 'colour' AS colour,
       specs -> 'battery' ->> 'hours' AS battery_hours,
       (specs #>> '{battery,hours}')::int * 2 AS doubled,
       specs -> 'dpi' -> 1 AS second_dpi,
       specs['size']['inches'] AS inches
FROM product_specs
ORDER BY product_id;
Output
Query OK
Query OK, 3 rows affected
product_id | colour | battery_hours | doubled | second_dpi | inches
-----------+--------+---------------+---------+------------+-------
1          | black  | 400           | 800     | 1600       | NULL
3          | black  | NULL          | NULL    | NULL       | 27
6          | silver | 30            | 60      | NULL       | NULL
(3 rows)

->> gives text, so cast it (::int, ::numeric, ::boolean) before doing maths. JSON arrays are indexed from 0, unlike PostgreSQL arrays. A missing key simply returns NULL. Since PostgreSQL 14 you can also use subscripts, specs['size']['inches'], which return jsonb.

Searching JSON

The containment operator @> asks "does the left document contain this smaller document?" and the ? operator asks "does this key exist?":

PostgreSQLTry it yourself
CREATE TABLE product_specs (product_id int, specs jsonb);
INSERT INTO product_specs VALUES
  (1, '{"colour": "black", "wireless": true, "battery": {"hours": 400}}'),
  (6, '{"colour": "silver", "wireless": true, "battery": {"hours": 30}}'),
  (3, '{"colour": "black", "wireless": false, "ports": ["HDMI", "DP"]}');

SELECT product_id FROM product_specs WHERE specs @> '{"colour": "black", "wireless": true}';

SELECT product_id FROM product_specs WHERE specs ? 'ports';

SELECT product_id FROM product_specs WHERE specs @> '{"ports": ["HDMI"]}';

SELECT product_id FROM product_specs
WHERE jsonb_path_exists(specs, '$.battery.hours ? (@ > 100)');
Output
Query OK
Query OK, 3 rows affected
product_id
----------
1
(1 row)

product_id
----------
3
(1 row)

product_id
----------
3
(1 row)

product_id
----------
1
(1 row)

Containment works at any depth and for arrays too: {"ports": ["HDMI"]} matches a document whose ports array includes "HDMI". The last query uses SQL/JSON path, a small query language inside a string: $ is the document, .battery.hours walks down, and ? (@ > 100) is a filter.

Changing JSON

jsonb values are replaced, not edited in place, but these functions and operators build the new value for you:

PostgreSQLTry it yourself
SELECT jsonb_set('{"colour": "black", "stock": 5}', '{stock}', '12') AS set_value,
       '{"colour": "black"}'::jsonb || '{"warranty_years": 2}' AS merged,
       '{"colour": "black", "temp": 1}'::jsonb - 'temp' AS removed_key,
       jsonb_build_object('id', 7, 'name', 'Bluetooth Speaker', 'tags', ARRAY['party']) AS built;
Output
set_value                        | merged                                   | removed_key         | built
---------------------------------+------------------------------------------+---------------------+----------------------------------------------------------
{"stock": 12, "colour": "black"} | {"colour": "black", "warranty_years": 2} | {"colour": "black"} | {"id": 7, "name": "Bluetooth Speaker", "tags": ["party"]}
(1 row)

jsonb_set(doc, path, new_value) replaces one value, || merges two objects (keys on the right win), and - removes a key. In an UPDATE it looks like SET specs = specs || '{"warranty_years": 2}'.

Rows to JSON and JSON to rows

APIs often need JSON built from tables. jsonb_build_object and jsonb_agg do that in SQL, and JSON_TABLE (PostgreSQL 17) goes the other way, turning a JSON array into a table:

PostgreSQLTry it yourself
SELECT jsonb_build_object(
         'category', category,
         'products', jsonb_agg(jsonb_build_object('name', product_name, 'price', price) ORDER BY price)
       ) AS api_response
FROM products
WHERE category = 'Storage'
GROUP BY category;

SELECT *
FROM JSON_TABLE(
  '[{"sku": "MS-01", "qty": 3}, {"sku": "KB-02", "qty": 1}]'::jsonb,
  '$[*]' COLUMNS (sku text PATH '$.sku', qty int PATH '$.qty')
) AS cart;
Output
api_response
----------------------------------------------------------------------------------------------------------------------------------
{"category": "Storage", "products": [{"name": "64GB Pen Drive", "price": 549.00}, {"name": "1TB External SSD", "price": 6499.00}]}
(1 row)

sku   | qty
------+----
MS-01 | 3
KB-02 | 1
(2 rows)

The first query returns one ready-made JSON document per category. The second turns a cart sent by a mobile app into rows you can join with products. Before PostgreSQL 17, the same job was done with jsonb_to_recordset or jsonb_array_elements, which you will still see in older code.

Indexing jsonb

A GIN index on a jsonb column makes @>, ? and JSON path searches fast:

PostgreSQL
CREATE INDEX product_specs_gin ON product_specs USING gin (specs);
CREATE INDEX product_specs_path ON product_specs USING gin (specs jsonb_path_ops);
CREATE INDEX product_specs_colour ON product_specs ((specs ->> 'colour'));

The default operator class supports @>, ? and more. jsonb_path_ops supports fewer operators (mainly @> and path queries) but makes a smaller, faster index. When you always filter on one key, such as WHERE specs ->> 'colour' = 'black', a plain B-tree expression index on that key (the third line) is the best choice.

Common mistakes

  • Using -> when you need text. specs -> 'colour' = 'black' compares jsonb with text and fails. Use ->>.
  • Doing maths on ->> without a cast. It returns text; cast to int or numeric first.
  • Choosing json by default. Use jsonb unless you must keep the exact original text.
  • Storing everything in one JSON column. You lose types, constraints and foreign keys. Keep core fields as columns.

Key points

  • jsonb stores parsed JSON that is fast to query and can be indexed; json keeps the exact text.
  • -> returns jsonb, ->> returns text; #>/#>> follow a path; JSON arrays start at 0.
  • @> tests containment, ? tests a key, and jsonb_path_exists runs SQL/JSON path queries.
  • jsonb_set, || and - build changed documents.
  • jsonb_build_object and jsonb_agg create JSON from rows; JSON_TABLE turns JSON into rows.
  • GIN indexes speed up containment searches; expression indexes speed up one key.

Exercise

Create a table payments (payment_id int, response jsonb) that stores gateway responses. Insert three responses with keys status ("success" or "failed"), amount (a number) and method ("upi" or "card"). Then write a query that returns the total amount of successful UPI payments.

Show solution
PostgreSQL
CREATE TABLE payments (payment_id int, response jsonb);
INSERT INTO payments VALUES
  (1, '{"status": "success", "amount": 799, "method": "upi"}'),
  (2, '{"status": "failed", "amount": 3499, "method": "upi"}'),
  (3, '{"status": "success", "amount": 1299, "method": "upi"}');

SELECT sum((response ->> 'amount')::numeric) AS upi_success_total
FROM payments
WHERE response @> '{"status": "success", "method": "upi"}';

The containment filter keeps payments 1 and 3, and the cast turns the text amount into a number, so the total is 2098. A GIN index on response would make this filter fast on millions of rows.

Quick Quiz

1. What is the difference between -> and ->>?

2. Which type should you normally choose for JSON data?

3. What does '{"a": 1, "b": 2}'::jsonb @> '{"a": 1}' return?

4. Which PostgreSQL 17 feature turns a JSON array into a table of rows?

Learn PostgreSQL with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call