sqlpostgresqljsonjsonb

JSONB_EACH in PostgreSQL: Expand a JSON Object into Key/Value Rows

Use jsonb_each to expand a JSON object into (key, value) rows, iterate over dynamic keys, filter and aggregate entries, and know when to reach for the _text variant.

3 min readReferencesql · postgresql · json · jsonb · set-returning

jsonb_each is a set-returning function: it takes a single JSON object and expands it into one row per (key, value) pair. It is the inverse of JSONB_OBJECT_AGG: where that folded rows into a map, this turns a map back into rows you can filter, join, and aggregate with ordinary SQL.

Syntax and a basic example

The function takes one jsonb argument and returns two columns: key of type text and value of type jsonb. The natural place to call it is in the FROM clause rather than the SELECT list, so each object entry becomes its own result row.

SELECT key, value
FROM jsonb_each('{"theme": "dark", "lang": "en"}'::jsonb);

The result is two rows: ('theme', "dark") and ('lang', "en"). A few behaviors worth keeping in mind:

  • value stays jsonb, so string values come back quoted: "dark", not dark.
  • Row order is not guaranteed: an object is a map, not a list.
  • If the argument is an array or scalar rather than an object, it raises an error. Expand an array with jsonb_array_elements instead.
  • On a NULL input the function returns zero rows, not one row of NULL.

Expanding a JSON column into rows

Most often the object lives in a column, not a literal. Suppose users has a prefs jsonb field holding an arbitrary set of settings. To expand it into rows, put the function in FROM after a comma (this is an implicit LATERAL join):

SELECT u.id, u.email, e.key, e.value
FROM users AS u, jsonb_each(u.prefs) AS e
WHERE u.id = 1;

Each pair in prefs becomes its own row tied to the user. The real power is that the keys can be dynamic: you do not need to know their names in advance, the way you would with prefs->>'theme'. This is ideal for iterating over an unknown shape or counting which keys appear at all.

Gotcha: if prefs is ever NULL, the implicit join silently drops that user from the result. To keep the user row, use an explicit LEFT JOIN LATERAL ... ON true and feed it COALESCE(prefs, '{}'::jsonb).

Filtering and aggregating entries

Once object entries are rows, the whole ordinary SQL toolkit applies. Count how many keys each settings object has:

SELECT u.id, count(*) AS pref_count
FROM users AS u, jsonb_each(u.prefs) AS e
GROUP BY u.id;

Or find which keys are most common across the table, a classic way to survey "wild" JSON that was written without a schema:

SELECT e.key, count(*) AS used_by
FROM users AS u, jsonb_each(u.prefs) AS e
GROUP BY e.key
ORDER BY used_by DESC;

You can filter by key or by value. Find users with at least one setting equal to the string "on":

SELECT DISTINCT u.id, u.email
FROM users AS u, jsonb_each(u.prefs) AS e
WHERE e.value = '"on"'::jsonb;

Note the comparison: because value is jsonb, the literal must be valid JSON too, hence the quotes inside '"on"'.

The _text variant: when you do not want JSON values

If the values are really just text, the quotes around them only get in the way. That is what jsonb_each_text is for: it returns the same pair, but value is text, with no wrapping quotes.

-- value comes back as plain text: dark, not "dark"
SELECT key, value
FROM jsonb_each_text('{"theme": "dark", "lang": "en"}'::jsonb);

The difference matters when you need to compare values or cast them to a number:

-- read a numeric flag straight from a dynamic key
SELECT u.id, (e.value)::int AS limit_value
FROM users AS u, jsonb_each_text(u.prefs) AS e
WHERE e.key = 'rate_limit';

With jsonb_each the same thing would need a second cast to strip the type. Mind the gotcha: jsonb_each_text also stringifies nested objects and arrays, so they come back as a serialized JSON string, and a JSON null becomes a SQL NULL.

Notes on other databases

Expanding a JSON object into rows is squarely a PostgreSQL strength. MySQL has no direct jsonb_each equivalent: you typically pull keys with JSON_KEYS and then expand that array with JSON_TABLE, which is wordier. In ClickHouse, JSON objects are often stored as a Map type, and you iterate with mapKeys and mapValues plus arrayJoin rather than a dedicated function. When you need to turn a map back into rows and work with it in plain SQL, jsonb_each and jsonb_each_text in PostgreSQL stay the most direct tool.

Practice on real tasks

Solve tasks in the SQL trainer with instant grading and hints.

Open trainer