Skip to content
Postgres JSONB Operators and Functions: A Reference

Click to use (opens in a new tab)

Postgres JSONB Operators and Functions: A Reference

August 19, 2026 by Chat2DBChat2DB Team

Postgres has two arrow operators that differ by one character, three ways to ask whether a key exists, and a containment operator that behaves differently from all of them. Getting these wrong produces queries that work on your test data and fall over in production — or worse, queries that return the right answer while silently ignoring every index you built.

This is a working reference, organised by what you are trying to do rather than by operator symbol.

The test data

CREATE TABLE products (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name  text NOT NULL,
  attrs jsonb NOT NULL DEFAULT '{}'
);
 
INSERT INTO products (name, attrs) VALUES
('Laptop Pro 14',  '{"brand":"Acme","price":1899.00,"specs":{"ram_gb":16,"cpu":"M3"},"tags":["laptop","portable"],"in_stock":true}'),
('Desk Lamp',      '{"brand":"Lumen","price":49.99,"specs":{"watts":9},"tags":["lighting","desk"],"in_stock":true}'),
('Mechanical Kbd', '{"brand":"Acme","price":129.00,"specs":{"switches":"brown","keys":87},"tags":["keyboard","desk"],"in_stock":false}'),
('Monitor 27',     '{"brand":"ViewCo","price":399.00,"specs":{"panel":"IPS","hz":144},"tags":["display"],"discount":null}');

Extracting values: the arrow operators

The single most common source of confusion. Both operators do the same lookup; they differ only in return type.

SELECT
  attrs -> 'brand'   AS as_jsonb,   -- "Acme"  (jsonb, quoted)
  attrs ->> 'brand'  AS as_text     -- Acme    (text, unquoted)
FROM products
WHERE name = 'Laptop Pro 14';

-> returns jsonb. ->> returns text. The rule that follows from this: use -> to keep navigating, use ->> when you have arrived.

-- Navigate two levels, then extract as text
SELECT name, attrs -> 'specs' ->> 'cpu' AS cpu
FROM products
WHERE attrs -> 'specs' ? 'cpu';

Both operators also take integers to index into arrays, with negative values counting from the end:

SELECT
  attrs -> 'tags' -> 0    AS first_tag_jsonb,   -- "laptop"
  attrs -> 'tags' ->> 0   AS first_tag_text,    -- laptop
  attrs -> 'tags' ->> -1  AS last_tag           -- portable
FROM products
WHERE name = 'Laptop Pro 14';

Path operators for deep access

Chaining arrows gets unwieldy. #> and #>> take a text array path and follow it in one step:

SELECT
  attrs #> '{specs,ram_gb}'   AS as_jsonb,   -- 16
  attrs #>> '{specs,ram_gb}'  AS as_text     -- 16
FROM products
WHERE name = 'Laptop Pro 14';

Array indices work in paths too, as strings:

SELECT attrs #>> '{tags,0}' AS first_tag FROM products;

Missing paths return NULL rather than raising an error, which is usually what you want but does mean typos fail silently.

Casting extracted values

->> always returns text, so comparisons need a cast — and the cast direction matters for performance:

-- Works, but cannot use a plain index on the column
SELECT name FROM products
WHERE (attrs ->> 'price')::numeric > 100;
 
-- Casting the literal instead keeps things comparable as jsonb
SELECT name FROM products
WHERE attrs -> 'price' > '100'::jsonb;

Be careful with the second form: jsonb comparison follows jsonb's own ordering rules, not numeric ordering, across mixed types. For numeric comparisons the explicit ::numeric cast is clearer and safer; make it indexable with an expression index, covered below.

Testing for existence

Three operators, all using ?, all requiring text operands rather than jsonb:

-- Does this top-level key exist?
SELECT name FROM products WHERE attrs ? 'discount';
 
-- Do ANY of these keys exist?
SELECT name FROM products WHERE attrs ?| array['discount','warranty'];
 
-- Do ALL of these keys exist?
SELECT name FROM products WHERE attrs ?& array['brand','price'];

Two traps here.

? only looks at the top level. attrs ? 'ram_gb' returns false for every row, because ram_gb lives inside specs. Navigate first:

SELECT name FROM products WHERE attrs -> 'specs' ? 'ram_gb';

A key with a JSON null value still exists. The Monitor row has "discount": null, so attrs ? 'discount' is true for it. To distinguish a missing key from an explicit null:

SELECT
  name,
  attrs ? 'discount'                       AS key_exists,
  attrs -> 'discount' IS NULL              AS sql_null,        -- key missing
  attrs -> 'discount' = 'null'::jsonb      AS json_null        -- key present, null value
FROM products;

This distinction bites hardest in WHERE clauses. attrs ->> 'discount' IS NULL is true both when the key is absent and when its value is JSON null — usually not what you meant.

When applied to a jsonb array, ? tests for a matching string element rather than a key:

SELECT name FROM products WHERE attrs -> 'tags' ? 'desk';

Containment: the operator that uses indexes best

@> asks "does the left side contain the right side?" It is the workhorse of JSONB querying because a GIN index accelerates it directly.

-- Rows where brand is Acme
SELECT name FROM products WHERE attrs @> '{"brand":"Acme"}';
 
-- Multiple conditions in one containment check
SELECT name FROM products WHERE attrs @> '{"brand":"Acme","in_stock":true}';
 
-- Nested containment works naturally
SELECT name FROM products WHERE attrs @> '{"specs":{"cpu":"M3"}}';
 
-- Array containment: does tags include "desk"?
SELECT name FROM products WHERE attrs @> '{"tags":["desk"]}';

Containment semantics are structural, not fuzzy. '{"a":1}'::jsonb @> '{"a":1,"b":2}' is false — the right side has more than the left contains. The reverse, '{"a":1,"b":2}'::jsonb @> '{"a":1}', is true.

One asymmetry worth memorising: at the top level, a jsonb array contains a bare scalar, but nested arrays do not:

SELECT '["a","b"]'::jsonb @> '"a"'::jsonb;      -- true
SELECT '[["a","b"]]'::jsonb @> '["a"]'::jsonb;  -- false

<@ is containment reversed: a <@ b is b @> a.

Indexing, and which operators benefit

This is where the operator you chose starts to matter for performance.

CREATE INDEX products_attrs_gin ON products USING GIN (attrs);

The default GIN opclass (jsonb_ops) supports @>, ?, ?|, ?&, @? and @@. It does not support -> or ->> comparisons — a query written with ->> will ignore this index entirely and sequential-scan.

-- Uses the GIN index
EXPLAIN ANALYZE SELECT * FROM products WHERE attrs @> '{"brand":"Acme"}';
 
-- Does NOT use it — sequential scan
EXPLAIN ANALYZE SELECT * FROM products WHERE attrs ->> 'brand' = 'Acme';

That single difference explains most "my JSONB index does nothing" reports. Rewrite equality lookups as containment.

The jsonb_path_ops opclass builds a smaller, faster index but supports only @> and the path operators:

CREATE INDEX products_attrs_path_gin
  ON products USING GIN (attrs jsonb_path_ops);

Choose it when containment is all you query with, which is common.

For a single hot field, an expression B-tree index beats GIN and supports ranges and sorting:

CREATE INDEX products_price_idx
  ON products (((attrs ->> 'price')::numeric));
 
-- Now this uses the index
EXPLAIN ANALYZE
SELECT name FROM products
WHERE (attrs ->> 'price')::numeric BETWEEN 100 AND 500
ORDER BY (attrs ->> 'price')::numeric;

The expression in the query must match the index expression exactly, parentheses included.

JSONPath: @? and @@

For conditions containment cannot express — inequalities, in particular — Postgres 12 added SQL/JSON path support.

-- Does any matching path exist?
SELECT name FROM products
WHERE attrs @? '$.specs.ram_gb ? (@ > 8)';
 
-- Does the path predicate evaluate to true?
SELECT name FROM products
WHERE attrs @@ '$.price > 300';
 
-- Filter inside arrays
SELECT name FROM products
WHERE attrs @? '$.tags[*] ? (@ == "desk")';

Both are GIN-indexable with the default opclass. jsonb_path_query returns the matched values rather than a boolean:

SELECT name, jsonb_path_query(attrs, '$.specs.*') AS spec_value
FROM products;
 
-- With a filter
SELECT name, jsonb_path_query_array(attrs, '$.tags[*] ? (@ starts with "d")')
FROM products;

Modifying JSONB

JSONB values are immutable; every "modification" produces a new value you then assign.

-- Set or replace a nested value
UPDATE products
SET attrs = jsonb_set(attrs, '{specs,ram_gb}', '32')
WHERE name = 'Laptop Pro 14';
 
-- Fourth argument controls whether missing keys are created (default true)
UPDATE products
SET attrs = jsonb_set(attrs, '{warranty_years}', '2', true)
WHERE attrs ->> 'brand' = 'Acme';

jsonb_set replaces null inputs with null output — if attrs were ever NULL the whole column becomes NULL. Guard with coalesce(attrs, '{}'::jsonb).

jsonb_set_lax (Postgres 13+) handles JSON nulls explicitly:

UPDATE products
SET attrs = jsonb_set_lax(attrs, '{discount}', NULL, true, 'delete_key')
WHERE name = 'Monitor 27';

Merging with || is shallow — nested objects are replaced wholesale, not merged:

SELECT '{"a":{"x":1,"y":2}}'::jsonb || '{"a":{"x":9}}'::jsonb;
-- {"a": {"x": 9}}   -- y is gone

Deleting uses - for keys and array elements, #- for paths:

-- Delete one key
UPDATE products SET attrs = attrs - 'discount'
WHERE name = 'Monitor 27';
 
-- Delete several keys at once
UPDATE products SET attrs = attrs - array['in_stock','discount']
WHERE name = 'Desk Lamp';
 
-- Delete at a nested path
UPDATE products SET attrs = attrs #- '{specs,watts}'
WHERE name = 'Desk Lamp';
 
-- Drop the first element of an array
UPDATE products SET attrs = jsonb_set(attrs, '{tags}', (attrs -> 'tags') - 0)
WHERE name = 'Mechanical Kbd';

Expanding and aggregating

Turn JSONB into rows:

-- Object to key/value rows
SELECT p.name, kv.key, kv.value
FROM products p, jsonb_each(p.attrs -> 'specs') AS kv;
 
-- Values as text
SELECT p.name, kv.key, kv.value
FROM products p, jsonb_each_text(p.attrs -> 'specs') AS kv;
 
-- Array to rows
SELECT p.name, tag
FROM products p, jsonb_array_elements_text(p.attrs -> 'tags') AS tag;
 
-- Just the keys
SELECT DISTINCT k FROM products, jsonb_object_keys(attrs) AS k;

jsonb_array_elements on a non-array raises an error, so guard mixed data with jsonb_typeof:

SELECT p.name, tag
FROM products p,
LATERAL jsonb_array_elements_text(
  CASE WHEN jsonb_typeof(p.attrs -> 'tags') = 'array'
       THEN p.attrs -> 'tags'
       ELSE '[]'::jsonb END
) AS tag;

Build JSONB from rows:

SELECT jsonb_agg(jsonb_build_object('name', name, 'price', attrs -> 'price'))
FROM products;
 
SELECT jsonb_object_agg(name, attrs -> 'price') FROM products;
 
SELECT jsonb_pretty(jsonb_build_object(
  'count', count(*),
  'brands', jsonb_agg(DISTINCT attrs ->> 'brand')
))
FROM products;

to_jsonb converts a whole row, and jsonb_populate_record goes the other way into a typed record — useful at API boundaries.

Quick reference

OperatorReturnsPurposeGIN-indexable
->jsonbGet key or array elementNo
->>textGet key or element as textNo
#>jsonbGet by pathNo
#>>textGet by path as textNo
?booleanTop-level key or array string existsYes
?|booleanAny of these keys existYes
?&booleanAll of these keys existYes
@>booleanContainsYes
<@booleanIs contained byYes
@?booleanJSONPath match existsYes
@@booleanJSONPath predicate is trueYes
||jsonbConcatenate / shallow mergen/a
-jsonbDelete key or elementn/a
#-jsonbDelete at pathn/a

Practical guidance

Write filters as @> wherever possible. It is the operator GIN indexes best, and it expresses multi-key conditions in one clause.

Use ->> for projection, not for filtering. Selecting a value as text in the SELECT list is free; filtering on it costs you the index.

Add expression indexes for hot scalar fields, especially anything you sort by or range-scan.

Do not put everything in JSONB. Columns you filter on in most queries belong as real columns with real types and real constraints. JSONB is for the genuinely variable tail of your schema. Postgres 17 added generated columns over JSONB, which lets you promote a hot field without changing writers:

ALTER TABLE products
  ADD COLUMN price numeric
  GENERATED ALWAYS AS ((attrs ->> 'price')::numeric) STORED;
 
CREATE INDEX ON products (price);

When you are exploring an unfamiliar JSONB column, Chat2DB (opens in a new tab) renders nested values as an expandable tree instead of one long line, and can generate the containment and JSONPath queries above from a description of what you are looking for.

Summary

-> keeps you in jsonb, ->> gives you text, and #> / #>> do the same by path. ? tests top-level keys only and treats JSON null as present. @> is the containment operator and the one your GIN index actually accelerates, so prefer it for filtering. @? and @@ cover the inequality cases containment cannot express, and are indexable too.

For modification, remember that jsonb_set needs a null guard and || merges only one level deep. And when a JSONB field becomes central to your queries, promote it to a generated column rather than indexing around it forever.