loke.dev
Header image for The JSON_TABLE Pivot: Why Postgres 17 Is the End of the 'Schema-less' Compromise

The JSON_TABLE Pivot: Why Postgres 17 Is the End of the 'Schema-less' Compromise

Moving beyond the messy arrow-operator chains to the SQL standard’s most powerful new tool for treating JSONB as a first-class relational entity.

· 7 min read

For years, we’ve lived in a state of architectural tension: do we choose the rigid safety of relational tables or the developer-friendly flexibility of JSON? PostgreSQL 17 effectively ends this debate by introducing JSON_TABLE, a feature that allows us to stop treating JSONB as a black box and start treating it as a dynamic, relational view.

If you’ve spent any time working with Postgres, you’ve likely experienced the "JSONB Creep." It starts with one metadata column. Then comes a preferences column. Before you know it, your business logic is buried under a mountain of ->>, #>, and jsonb_to_recordset calls that look more like Perl regex than SQL.

Postgres 17 changes the game. It’s not just an incremental update; it’s a paradigm shift in how we handle the "schema-less" compromise.

The Messy Reality of the Arrow Operators

Before we dive into the new stuff, we have to acknowledge why the old way was starting to fail us. Imagine a standard e-commerce scenario where we store order data in a JSONB column because the third-party logistics provider changes their schema every other Tuesday.

Here is how we used to extract a list of items from a nested JSONB blob:

SELECT 
    id AS order_id,
    item->>'sku' AS sku,
    (item->>'quantity')::int AS qty,
    (item->>'price')::numeric AS price
FROM orders,
LATERAL jsonb_array_elements(data->'items') AS item;

It’s functional, sure. But it’s also brittle. If data->'items' is missing, the row disappears from the results unless you remember to use LEFT JOIN LATERAL. If a price is a string instead of a number, your query crashes mid-execution. If you want to handle "missing" vs "null" values differently, you’re looking at a nightmare of CASE statements.

The arrow operators were a bridge, but JSON_TABLE is the destination.

What Exactly is JSON_TABLE?

Think of JSON_TABLE as a projection engine. It’s a SQL-standard way to transform nested JSON data into a relational format (rows and columns) on the fly, within the FROM clause of your query.

It follows a specific syntax that might feel a bit wordy at first, but it provides a level of control that was previously impossible.

SELECT *
FROM orders,
JSON_TABLE(
    orders.data,
    '$.items[*]' 
    COLUMNS (
        sku text PATH '$.sku',
        quantity int PATH '$.quantity',
        price numeric PATH '$.price'
    )
) AS jt;

The beauty here is the separation of concerns. The first argument is your data source. The second is a JSONPath expression that defines what constitutes a "row." The COLUMNS clause then defines how to map that row's internal data to SQL types.

Handling the "Dirty" Data Problem

Real-world JSON is rarely clean. In my experience, the biggest headache isn't querying the data—it's handling the data that *should* be there but isn't.

Postgres 17’s JSON_TABLE gives us declarative error handling. We no longer have to wrap every single column extract in a COALESCE.

The DEFAULT and ON ERROR Clauses

Consider a scenario where some orders have a discount field, and others don't. Some might even have "N/A" written in a field that should be a number.

SELECT jt.*
FROM orders,
JSON_TABLE(
    orders.data,
    '$.items[*]'
    COLUMNS (
        sku text PATH '$.sku',
        discount numeric PATH '$.discount' 
            DEFAULT 0 ON EMPTY 
            DEFAULT -1 ON ERROR,
        item_note text PATH '$.note' 
            DEFAULT 'No note provided' ON EMPTY
    )
) AS jt;

In this example:
1. `ON EMPTY`: If the discount key is missing, it defaults to 0.
2. `ON ERROR`: If someone put "10% off" (a string) in a numeric field, it returns -1 instead of blowing up your entire analytical report.

This turns your query into a validation layer. I’ve found this incredibly useful for ETL pipelines where you’d rather flag a row with an error code than have the entire batch job fail at 3 AM.

Flattening Nested Hierarchies

Nested arrays are the final boss of JSONB. In the past, if you had an order that contained items, and each item contained a list of sub-components or add-ons, you’d need multiple LATERAL joins. This leads to a Cartesian product mess that is difficult to debug.

JSON_TABLE handles this with NESTED PATHS.

SELECT jt.*
FROM product_bundles,
JSON_TABLE(
    product_bundles.info,
    '$.bundles[*]'
    COLUMNS (
        bundle_name text PATH '$.name',
        NESTED PATH '$.products[*]' COLUMNS (
            product_name text PATH '$.name',
            product_id int PATH '$.id'
        )
    )
) AS jt;

This generates a clean, flattened result set. If a bundle has three products, you get three rows. The bundle_name is repeated for each product in that bundle. It’s exactly how a SQL join behaves, but it’s happening entirely inside the JSON parsing engine.

The Death of the "Schema-less" Compromise

For a long time, the advice was: "Use relational columns for anything you need to query frequently, and JSONB for the 'extra' stuff."

But the "extra" stuff has a habit of becoming the "important" stuff.

Previously, moving a field from JSONB to a real column required a migration: adding the column, backfilling data, updating application logic, and finally dropping the JSON key. With JSON_TABLE, that pressure is significantly reduced. You can treat JSONB with the same rigor as a table.

FOR ORDINALITY: Tracking Order

One small but mighty feature of JSON_TABLE is the FOR ORDINALITY column. When parsing JSON arrays, the order often matters (e.g., items in a queue, steps in a process).

SELECT jt.*
FROM logs,
JSON_TABLE(
    logs.event_data,
    '$.steps[*]'
    COLUMNS (
        step_id FOR ORDINALITY,
        step_name text PATH '$.name',
        status text PATH '$.status'
    )
) AS jt;

This automatically generates an incrementing integer (1, 2, 3...) for each element in the array. Trying to do this with jsonb_array_elements usually involved a messy WITH ORDINALITY clause on the function call, which felt bolted on. Here, it’s a first-class citizen of the column definition.

Performance: Is it Faster?

Let’s be clear: JSON_TABLE isn't a magic wand that makes JSON faster than native integer columns. A native column will almost always win on raw speed and indexing efficiency.

However, JSON_TABLE is often faster than the equivalent "Arrow + Lateral Join" approach because the Postgres query planner can better understand the intent of the query. By defining types and paths upfront in the COLUMNS clause, the engine can optimize the extraction process rather than repeatedly invoking functional operators.

Furthermore, you can still use GIN indexes on the underlying JSONB column.

CREATE INDEX idx_orders_data ON orders USING GIN (data);

When you combine a GIN index for filtering (using the @> operator in your WHERE clause) with JSON_TABLE for projection (in your FROM clause), you get the best of both worlds: high-speed lookups and clean, structured output.

Why You Should Care Now

We are seeing a convergence in the database world. Document stores are adding SQL-like features, and relational databases are perfecting document handling. Postgres 17 essentially says: "The compromise is over."

You no longer have to choose between the "easy" development of a document store and the "powerful" querying of a relational database. You can keep your data in a flexible format while it’s in flux, and use JSON_TABLE to provide a strict, typed interface to your reporting tools, BI dashboards, or legacy services.

A Gotcha to Watch For

While JSON_TABLE is powerful, it is strictly for reading and transforming data. It doesn't help with updating specific values inside a JSONB blob (you'll still use jsonb_set for that).

Also, remember that Postgres 17 is the requirement here. If you’re still on 15 or 16, you’ll see syntax errors. But this feature alone is, in my opinion, a compelling enough reason to plan an upgrade.

The Final Pivot

The "JSON_TABLE Pivot" is about moving from "JSON as a blob" to "JSON as a source." It allows us to build systems that are resilient to change without sacrificing the clarity of SQL.

If you've been avoiding JSONB because you hated the syntax of arrow operators, or if you've been over-engineering complex schema migrations every time a requirement changed, it's time to take another look. Postgres 17 doesn't just make JSON easier to work with; it makes it a first-class relational entity.

Stop fighting the schema-less compromise. Define your paths, set your defaults, and let Postgres handle the heavy lifting. Your future self—the one trying to debug an array-of-arrays at 2 PM on a Friday—will thank you.