Skip to content
EL
Contents

JSON and semi-structured data in PostgreSQL

JSON in a column

ConceptCore4 min read15 min hands-on#sql#data-modeling

The idea in 30 seconds

PostgreSQL can store a JSON document in a column and query inside it. Use jsonb, which stores a parsed form that is fast to query and can be indexed. Learn the handful of operators that pull values out and test what a document contains, and the one trap that catches everyone: a missing key and an explicit JSON null look the same through ->>.

Helps to know first: SQL types and the type system, B-tree indexes

On this page

Two JSON types: use jsonb

PostgreSQL has two JSON types. Feed both the same text and they keep different things:

SELECT '{"b": 1,   "a": 2, "a": 3}'::json  AS as_json,
       '{"b": 1,   "a": 2, "a": 3}'::jsonb AS as_jsonb;
          as_json           |     as_jsonb
----------------------------+------------------
 {"b": 1,   "a": 2, "a": 3} | {"a": 3, "b": 1}

json keeps the text exactly as written, spaces and duplicate keys included, and parses it again every time you query it. jsonb parses once and stores a structured binary form: no extra whitespace, keys reordered, and only the last of any duplicate key. It is faster to query and it can be indexed. Unless you need the exact original text back, use jsonb.

Add some documents

Products in shop have different specifications: a keyboard has a layout, a monitor has a size and ports. Rather than a column for every possible attribute, give them a document:

ALTER TABLE products ADD COLUMN attributes jsonb NOT NULL DEFAULT '{}';

UPDATE products SET attributes = '{"wireless": false, "layout": "TKL", "switches": "brown"}' WHERE id = 1;
UPDATE products SET attributes = '{"wireless": true, "dpi": 1600, "colors": ["black", "white"]}' WHERE id = 2;
UPDATE products SET attributes = '{"size_in": 27, "resolution": "2560x1440", "ports": ["HDMI", "DisplayPort", "USB-C"]}' WHERE id = 3;
UPDATE products SET attributes = '{"wireless": true, "anc": true, "battery_h": 30}' WHERE id = 6;
UPDATE products SET attributes = '{"wireless": null}' WHERE id = 7;

Getting values out

OperatorReturnsExample
->the value as jsonbattributes -> 'dpi'
->>the value as textattributes ->> 'dpi'
@>does the document contain this?attributes @> '{"wireless": true}'
?does this top-level key exist?attributes ? 'ports'
SELECT name, attributes -> 'dpi' AS arrow, attributes ->> 'dpi' AS double_arrow,
       pg_typeof(attributes -> 'dpi') AS t1, pg_typeof(attributes ->> 'dpi') AS t2
FROM products WHERE id = 2;
      name      | arrow | double_arrow |  t1   |  t2
----------------+-------+--------------+-------+------
 Wireless mouse | 1600  | 1600         | jsonb | text

They print the same, but -> gives you JSON you can keep navigating, and ->> gives you text you can compare and cast. Containment asks “does the document include this piece”:

SELECT name FROM products WHERE attributes @> '{"wireless": true}' ORDER BY id;
            name
-----------------------------
 Wireless mouse
 Noise-cancelling headphones

Arrays expand into rows with jsonb_array_elements_text, joined laterally:

SELECT p.name, port FROM products p, jsonb_array_elements_text(p.attributes -> 'ports') AS port;
      name       |    port
-----------------+-------------
 27-inch monitor | HDMI
 27-inch monitor | DisplayPort
 27-inch monitor | USB-C

Missing is not the same as null

PredictTwo kinds of nothing

The laptop stand (id 5) has no wireless key at all. The webcam (id 7) has "wireless": null. What does attributes ->> 'wireless' return for each?

Changing one value

jsonb_set returns a new document with one path replaced:

UPDATE products SET attributes = jsonb_set(attributes, '{dpi}', '3200') WHERE id = 2;
SELECT attributes FROM products WHERE id = 2;
                          attributes
---------------------------------------------------------------
 {"dpi": 3200, "colors": ["black", "white"], "wireless": true}

It reads like an edit to one key, but it builds and stores a whole new document. For small documents that does not matter. The review on the last page of this topic measures when it does.

Check yourself

  1. Why should most applications use jsonb rather than json?

    Show answer

    jsonb is parsed once and stored in a binary form, so queries do not reparse the text, and it supports indexes. json only makes sense when you must keep the exact original text, including whitespace, key order and duplicate keys.

  2. What is the difference between attributes -> 'dpi' and attributes ->> 'dpi'?

    Show answer

    -> returns the value as jsonb, which you can keep navigating into. ->> returns it as text, ready to compare or cast.

  3. How do you find products where the wireless key is present but null?

    Show answer

    WHERE attributes ? 'wireless' AND attributes ->> 'wireless' IS NULL. The ? separates “present but null” from “missing”, which ->> alone cannot.

Finished reading?

Saved in this browser only.

Sources

Where each claim on this page comes from.

  1. 1.
    PostgreSQL documentation: 8.14 JSON Types
    Primary· §8.14 JSON Types

    json stores an exact copy of the input text and reparses it; jsonb stores a decomposed binary form that is faster to process and supports indexing, does not keep whitespace, key order or duplicate keys (the last value wins); most applications should prefer jsonb.

  2. 2.
    PostgreSQL documentation: 9.16 JSON Functions and Operators
    Primary· §9.16 JSON Functions and Operators

    The ->, ->>, @> and ? operators, jsonb_set and jsonb_array_elements_text.

Go further

What to read next, and why it is worth the time.

  1. 1.
    PostgreSQL documentation: 8.14 JSON Types
    Primary· §8.14.3 jsonb Containment and Existence

    Precise rules for what @> considers "contained", including arrays and nesting, with examples.

Applies to PostgreSQL 18. Draft: not yet fact-checked end to end.