sqlpostgresqljsonjsonb

JSON_AGG and JSONB_AGG in PostgreSQL: Build a JSON Array for an API Response

Fold rows into a JSON array with JSON_AGG and JSONB_AGG, order the aggregate, build a nested API response in one query, and skip NULLs cleanly.

3 min readReferencesql · postgresql · json · jsonb · aggregation · mysql

JSON_AGG and JSONB_AGG are aggregate functions that fold a group's rows into a single JSON array. They turn "many rows per key" into "one row holding JSON," and they are the most direct way to return a nested API response straight from the database, without assembling JSON in application code.

JSON_AGG versus JSONB_AGG

Both gather values into a JSON array, but they store it differently:

  • JSON_AGG returns the json type: text kept "as is," preserving key order and duplicates.
  • JSONB_AGG returns jsonb: a binary form where duplicate keys collapse (the last one wins), whitespace is normalized, and field access is faster.
  • For most API responses, reach for JSONB_AGG: it is more compact and easier to filter or index further down the pipeline.
SELECT JSONB_AGG(t ORDER BY t.id) AS tasks
FROM tasks t;

By passing the whole row t into the aggregate, you get an array of objects whose keys are the column names. This is the quickest way to "ship a table as JSON."

ORDER BY inside the aggregate

Element order in the array is not guaranteed without an ORDER BY right inside the call. Never rely on table row order; set the sort explicitly:

SELECT
    u.id,
    u.email,
    JSONB_AGG(o.amount ORDER BY o.created_at DESC) AS amounts
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;

The ORDER BY lives inside the aggregate's parentheses, not in the outer query. An outer ORDER BY sorts the result rows, but not the elements inside each JSON array, which is a common source of confusion.

One object per row with jsonb_build_object

A bare array of scalars is rarely what you want; usually you build an array of objects. jsonb_build_object constructs a JSON object from key-value pairs, and JSONB_AGG folds those objects into an array:

SELECT
    u.id,
    u.email,
    JSONB_AGG(
        jsonb_build_object(
            'order_id', o.id,
            'amount', o.amount,
            'status', o.status
        )
        ORDER BY o.created_at DESC
    ) AS orders
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.email;

This gives you full control over the field names in the response: they need not match the column names. That is handy when the database schema and the API contract diverge.

A nested API response in one query

These building blocks compose into a full "user with a list of orders" document in a single pass, with no N+1 queries and no application-side assembly:

SELECT jsonb_build_object(
    'user_id', u.id,
    'email', u.email,
    'country', u.country,
    'orders', JSONB_AGG(
        jsonb_build_object('id', o.id, 'amount', o.amount)
        ORDER BY o.created_at DESC
    )
) AS payload
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = 42
GROUP BY u.id, u.email, u.country;

For an employee hierarchy the same trick collects each manager together with their reports:

SELECT
    m.name AS manager,
    JSONB_AGG(
        jsonb_build_object('name', e.name, 'salary', e.salary)
        ORDER BY e.salary DESC
    ) AS reports
FROM employees m
JOIN employees e ON e.manager_id = m.id
GROUP BY m.id, m.name;

FILTER to skip NULLs

JSONB_AGG includes NULL values in the array: a row with no match from a LEFT JOIN produces a null element. To avoid that, add a FILTER:

SELECT
    u.id,
    JSONB_AGG(o.id) FILTER (WHERE o.id IS NOT NULL) AS order_ids
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;

Gotcha: if a user has no orders, JSONB_AGG(...) FILTER (...) returns NULL, not an empty array []. Many API clients dislike null where they expect an array. Wrap it in COALESCE(JSONB_AGG(...) FILTER (...), '[]'::jsonb) so you always emit an array.

MySQL and ClickHouse

In MySQL 8 the equivalent is JSON_ARRAYAGG, and JSON_OBJECT builds the per-row object:

-- MySQL 8: array of objects, one per order
SELECT
    user_id,
    JSON_ARRAYAGG(JSON_OBJECT('id', id, 'amount', amount)) AS orders
FROM orders
GROUP BY user_id;

What to keep in mind about MySQL:

  • JSON_ARRAYAGG has no built-in ORDER BY; impose an order through a pre-sorted subquery.
  • There is no json versus jsonb split: the type is always JSON, already stored in a binary normalized form.

ClickHouse has no direct equivalent; you assemble with groupArray and serialize, or use its JSON-string functions. When you need a predictable nested JSON with sorting and filtering, PostgreSQL with JSONB_AGG ... ORDER BY ... FILTER stays the most direct tool.

Practice on real tasks

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

Open trainer