Readineer
PATTERN 14SQL Patterns / Working with Real-World Data

SQL JSON: working with nested and semi-structured data

Learn how to extract JSON fields and nested values, work with arrays, convert JSON values into SQL types, and avoid treating numeric or date-like JSON values as plain text.

10 min read11 sections2 predictions
01Foundation

When one column contains a structure

Relational tables usually store one value in each column. But data arriving from APIs, event streams, external applications, and document-oriented systems often has a nested structure. An order event might look like this:

{
  "channel": "web",
  "payment": {
    "method": "card",
    "amount": 1500
  },
  "shipping": {
    "city": "Bengaluru",
    "priority": true
  },
  "tags": ["new_customer", "promo"]
}

Instead of creating a separate relational column for every possible attribute, a database may store this structure in a JSON column. Consider the following table, where metadata is stored as PostgreSQL jsonb:

Order Events
order_idcustomermetadata
1001Asha{"channel":"web","payment":{"method":"card","amount":1500},"shipping":{"city":"Bengaluru","priority":true},"tags":["new_customer","promo"]}
1002Ben{"channel":"mobile","payment":{"method":"upi","amount":3200},"shipping":{"city":"London","priority":false},"tags":["returning"]}
1003Carla{"channel":"web","payment":{"method":"card","amount":3600},"shipping":{"city":"Dubai","priority":true},"tags":["promo","express"]}
1004Dev{"channel":"store","payment":{"method":"cash","amount":8500},"shipping":null,"tags":[]}
1005Esha{"channel":"mobile","payment":{"method":"card","amount":900},"tags":["new_customer"]}

JSON allows one column to contain objects, nested objects, arrays, strings, numbers, booleans, and null values. The challenge is that SQL still needs a way to reach those values and use them in filters, calculations, and reports.

02Extraction

Extracting a JSON field with ->

Suppose we want the channel field. PostgreSQL provides the -> operator:

Query
SELECT
    order_id,
    metadata -> 'channel' AS channel
FROM order_events
ORDER BY order_id;
Result
order_idchannel
1001"web"
1002"mobile"
1003"web"
1004"store"
1005"mobile"

Notice the quotation marks around "web". That is because metadata -> 'channel' returns a JSON value, not ordinary SQL text. PostgreSQL’s -> operator preserves the JSON or jsonb type of the extracted value.

Extracting a field as text with ->>

Most SQL operations need the scalar value rather than its JSON representation. Use ->> to return the value as text:

Query
SELECT
    order_id,
    metadata ->> 'channel' AS channel
FROM order_events
ORDER BY order_id;
Result
order_idchannel
1001web
1002mobile
1003web
1004store
1005mobile
->    returns JSON
->>   returns text

PostgreSQL documents ->> as extracting an object field or array element as SQL text. A useful rule: use -> when you want to keep working with a JSON structure, and ->> when you want the extracted scalar value as text.

Filtering on an extracted value

Once we extract text, we can use familiar SQL conditions. Return orders placed through the mobile channel:

Query
SELECT
    order_id,
    customer,
    metadata ->> 'channel' AS channel
FROM order_events
WHERE metadata ->> 'channel' = 'mobile'
ORDER BY order_id;
Result
order_idcustomerchannel
1002Benmobile
1005Eshamobile

The JSON extraction becomes part of an ordinary SQL comparison: metadata ->> 'channel' = 'mobile'.

03Nesting

Extracting nested values

JSON often contains objects inside other objects. Our payment field looks like {"method":"card","amount":1500}. To reach method, we move through the structure step by step with metadata -> 'payment' ->> 'method'. The first operation metadata -> 'payment' returns the nested JSON object, and then ->> 'method' extracts card:

Query
SELECT
    order_id,
    customer,
    metadata -> 'payment' ->> 'method' AS payment_method
FROM order_events
ORDER BY order_id;
Result
order_idcustomerpayment_method
1001Ashacard
1002Benupi
1003Carlacard
1004Devcash
1005Eshacard

Extracting nested values with a path

PostgreSQL also supports path operators. metadata #>> '{payment,method}' means metadata then payment then method, and returns the final value as text, producing the same scalar values. The related operators are:

#>    extract JSON at a path
#>>   extract text at a path

PostgreSQL supports object keys and array indexes within these paths. For shallow structures, chained operators are often easy to read (metadata -> 'payment' ->> 'method'). For deeper structures, a path can be cleaner (metadata #>> '{payment,method}').

04The featured failure

The JSON number that is being compared as text

Consider the payment amount {"amount":1500}. Suppose the requirement is return orders with payment amounts greater than 1,000. Someone writes the query below. It looks reasonable, but #>> returns text, so values such as 1500, 3200, 3600, 8500, 900 are being treated as character strings rather than numbers.

PAUSE & PREDICTUnder this text comparison, does Esha's 900 amount qualify?
Query
SELECT
    order_id,
    customer,
    metadata #>> '{payment,amount}' AS payment_amount
FROM order_events
WHERE metadata #>> '{payment,amount}' > '1000'
ORDER BY order_id;
Your prediction

The fix: extract, then CAST

The business rule is numeric, so convert the extracted value to a numeric type:

Query
SELECT
    order_id,
    customer,
    CAST(
        metadata #>> '{payment,amount}'
        AS DECIMAL(10, 2)
    ) AS payment_amount
FROM order_events
WHERE CAST(
        metadata #>> '{payment,amount}'
        AS DECIMAL(10, 2)
      ) > 1000
ORDER BY payment_amount;
Result
order_idcustomerpayment_amount
1001Asha1500
1002Ben3200
1003Carla3600
1004Dev8500

Esha’s 900 is correctly excluded.

Key idea
Extracting a JSON scalar as text does not give it numeric, date, or Boolean SQL semantics. If the business rule is numeric, convert it to a numeric SQL type before comparing or calculating.
05Types and absence

JSON values still have types

Inside JSON, these values are different:

2       JSON number
"2"     JSON string
true    JSON boolean
null    JSON null

That distinction matters. If your application stores numeric information as "amount": "1500" instead of "amount": 1500, the JSON itself already contains a string rather than a number. Consistent JSON schemas make downstream SQL easier to reason about. PostgreSQL provides functions such as jsonb_typeof to inspect the top-level JSON type of a value:

Query
SELECT
    order_id,
    jsonb_typeof(metadata -> 'payment' -> 'amount') AS amount_type
FROM order_events;

For our sample data, the result is number.

Missing keys and nested values

Look at Esha’s JSON: there is no shipping field. Now run metadata #>> '{shipping,city}'. For Esha, the result is NULL. PostgreSQL’s JSON extraction operators return SQL NULL rather than raising an error when the requested structure or key is not present. That means familiar NULL logic becomes relevant again. To find rows without an available shipping city:

Query
SELECT
    order_id,
    customer
FROM order_events
WHERE metadata #>> '{shipping,city}' IS NULL;

Missing key and JSON null need care

Dev’s JSON contains "shipping": null. Esha’s JSON does not contain the shipping key at all. These represent different structures in the JSON document: for Dev, shipping exists with JSON null; for Esha, the shipping key is absent. Depending on how you extract and interpret them, both can eventually appear as SQL NULL.

If the distinction matters to the business, test for key existence or inspect the JSON structure rather than assuming all SQL NULL results mean the same thing. With PostgreSQL jsonb, the ? operator can test whether a top-level key exists:

Query
SELECT
    order_id,
    metadata ? 'shipping' AS has_shipping_key
FROM order_events;

This can distinguish “the key is absent” from “the key exists but contains a null-like value.”

06Arrays

Working with JSON arrays

JSON can also contain arrays. Our tags field might contain ["new_customer","promo"] or ["promo","express"]. To extract the array itself, we use -> rather than ->> because we want to preserve the array as JSON:

Query
SELECT
    order_id,
    metadata -> 'tags' AS tags
FROM order_events;
Result
order_idtags
1001["new_customer","promo"]
1002["returning"]
1003["promo","express"]
1004[]
1005["new_customer"]

Extracting one array element

PostgreSQL JSON arrays use zero-based indexes for the extraction operator, so metadata -> 'tags' ->> 0 returns the first tag:

Query
SELECT
    order_id,
    metadata -> 'tags' ->> 0 AS first_tag
FROM order_events
ORDER BY order_id;
Result
order_idfirst_tag
1001new_customer
1002returning
1003promo
1004NULL
1005new_customer

Order 1004 has an empty array, so there is no element at index 0.

Counting array elements

PostgreSQL provides jsonb_array_length(...) to return the number of elements in a top-level JSON array:

Query
SELECT
    order_id,
    jsonb_array_length(metadata -> 'tags') AS tag_count
FROM order_events
ORDER BY order_id;
Result
order_idtag_count
10012
10021
10032
10040
10051

This is useful when the number of array members matters without needing to inspect each one individually.

Expanding an array into rows

Sometimes the requirement is return one row for every order-tag combination. For order 1001 with ["new_customer","promo"], we want two rows. PostgreSQL provides jsonb_array_elements_text(...) to expand a JSON array into a set of text values:

Query
SELECT
    o.order_id,
    o.customer,
    tag
FROM order_events AS o
CROSS JOIN LATERAL
    jsonb_array_elements_text(o.metadata -> 'tags') AS tag
ORDER BY
    o.order_id,
    tag;
Result
order_idcustomertag
1001Ashanew_customer
1001Ashapromo
1002Benreturning
1003Carlaexpress
1003Carlapromo
1005Eshanew_customer

Order 1004 has no tags, so there is no array element to expand.

Gotcha — expanding arrays changes the result grain
Before expanding tags, one row represents one order. After expanding, one row represents one order-tag combination. Asha’s order 1001 appears twice because it has two tags, and Carla’s 1003 does too. This is not accidental duplication; it is the same grain change we saw with one-to-many joins. Before expanding, ask whether one output row should represent an order or one element inside the order, because COUNT(*), SUM(...), and AVG(...) on the expanded rows can be affected.

Filtering JSON arrays

Suppose we want orders whose tags contain promo. PostgreSQL jsonb provides containment and existence operators. For a top-level array, one possible test is metadata -> 'tags' ? 'promo':

Query
SELECT
    order_id,
    customer,
    metadata -> 'tags' AS tags
FROM order_events
WHERE metadata -> 'tags' ? 'promo'
ORDER BY order_id;
Result
order_idcustomertags
1001Asha["new_customer","promo"]
1003Carla["promo","express"]

The exact JSON query syntax varies considerably across databases, so array-search expressions should follow the database you are using. The broader concept remains: decide whether you need one array element, the entire array, or a test for whether an element exists.

07Casting and schema

Casting nested JSON values

JSON frequently contains values that need to participate in ordinary SQL calculations. Extracting metadata #>> '{payment,amount}' returns text such as 3200. For aggregation, cast it:

Query
SELECT
    SUM(
        CAST(
            metadata #>> '{payment,amount}'
            AS DECIMAL(10, 2)
        )
    ) AS total_payment_amount
FROM order_events;

The same principle applies to other data types, and the correct target type comes from what the value represents:

Numeric     CAST(metadata ->> 'quantity'          AS INTEGER)
Decimal     CAST(metadata #>> '{payment,amount}'  AS DECIMAL(10, 2))
Date        CAST(metadata ->> 'order_date'        AS DATE)
Timestamp   CAST(metadata ->> 'created_at'        AS TIMESTAMP)

Invalid JSON values can still break CAST

Suppose most documents contain "amount": 1500 but one contains "amount": "unknown". The extraction metadata #>> '{payment,amount}' can produce unknown, and casting it to DECIMAL(10, 2) can fail because unknown is not a valid decimal representation. JSON gives data flexibility, but it does not guarantee that every document follows the same schema. This is one of the main production challenges with semi-structured data.

Before casting JSON values, ask:

Is the key always present?
Is the JSON type consistent?
Can the source contain empty strings?
Can it contain sentinel values such as "unknown"?
What should happen when the value is invalid?

If the data contract says payment.amount must always be numeric, then invalid documents should ideally be rejected or corrected during ingestion rather than handled differently in every downstream query.

JSON flexibility does not remove the need for a schema

One advantage of JSON is that different documents can contain different fields, which is useful for optional or evolving attributes. But a JSON column should not automatically become a place where every field is stored without rules. If downstream queries repeatedly depend on payment.amount, customer_id, ordered_at, or status, those fields may be better represented as ordinary typed relational columns. JSON is particularly useful for optional attributes, evolving event payloads, external API responses, sparse metadata, nested structures, and arrays. Frequently queried core business fields often benefit from explicit relational columns with well-defined SQL types.

08Recognition cues

How to spot JSON problems

Look carefully at JSON logic when:

An API payload is stored directly in a database.
One column contains nested objects.
A value exists several levels inside a document.
A JSON number is being compared or summed.
An expected key is missing for some rows.
JSON arrays need to become individual result rows.
Row counts increase after expanding an array.
A field sometimes contains different JSON types.
A cast fails only for certain documents.
SQL NULL, missing keys, and JSON null need to be distinguished.

When something behaves unexpectedly, inspect both what the JSON structure is and what SQL type the extraction expression returns.

09A practical mental model

Ask five questions when working with JSON

1 · What part of the JSON do I need?

A top-level field (metadata -> 'channel') or a nested field (metadata -> 'payment' -> 'amount').

2 · JSON or a scalar text value?

Keep the structure with ->; extract text with ->> or #>>.

3 · What does the value represent?

If payment.amount is money, CAST(... AS DECIMAL(10, 2)).

4 · Is the value inside an array?

One element (-> 'tags' ->> 0) or every element (jsonb_array_elements_text), which changes the grain.

5 · What if the structure is missing or inconsistent?

Decide how to handle a missing key, JSON null, empty array, wrong type, or invalid cast.

Key idea
Do not let those decisions happen accidentally.
10Check your understanding

One more prediction

Consider a document whose payment.amount is 900. Requirement: return rows where payment.amount is numerically greater than 1,000. Which condition best expresses the requirement in PostgreSQL?

ONE MORE PREDICTIONWhich condition best expresses the requirement?
Query
-- Option A
WHERE metadata #>> '{payment,amount}' > '1000'

-- Option B
WHERE CAST(
    metadata #>> '{payment,amount}'
    AS DECIMAL(10, 2)
) > 1000

-- Option C
WHERE metadata -> 'payment' -> 'amount' = '1000'

-- Option D
WHERE metadata ->> 'payment' > '1000'
Your prediction
11Summary

Summary

JSON allows relational tables to store nested and semi-structured information. In PostgreSQL, use metadata -> 'channel' when you want the field as JSON and metadata ->> 'channel' when you want it as text. For nested values, metadata -> 'payment' ->> 'method' or metadata #>> '{payment,method}' can reach deeper into the document. For arrays, metadata -> 'tags' ->> 0 extracts one element while jsonb_array_elements_text(metadata -> 'tags') can expand all elements into rows.

Key idea
Extracting a value that looks numeric does not necessarily make it a numeric SQL value. metadata #>> '{payment,amount}' returns text; if the business rule is numeric, write CAST(metadata #>> '{payment,amount}' AS DECIMAL(10, 2)). JSON flexibility also introduces data-quality questions, because different documents may have missing keys, null values, empty arrays, different types, and invalid values. Define the expected JSON structure, extract the value intentionally, convert it to the correct SQL type, and understand how expanding nested structures changes the grain of your result. Reliable JSON querying comes from knowing exactly which parts are allowed to vary and which parts should remain predictable.

Next, we will move into analytical SQL and learn how window functions calculate across related rows without collapsing them into one row per group.

Go deeper

Ask Colearn about this pattern

Not sure why a JSON number compares oddly, or how a missing key differs from JSON null? Ask about the concept, or paste a simplified version of your document and query.

Ask Colearn

3 of 3 free questions left

Instant answers, grounded in the same verified material the diagnostic grades against.

Why does my JSON amount filter return the wrong rows?

#>> and ->> return text, so metadata #>> '{payment,amount}' > '1000' compares character sequences, not numbers. '900' sorts after '1000' as text, so a 900 amount can wrongly qualify.

If the rule is numeric, extract then cast: CAST(metadata #>> '{payment,amount}' AS DECIMAL(10,2)) > 1000.

from the unit →
What is the difference between a missing key and JSON null?

A missing key means the field is absent from the document; JSON null means the key exists with a null value. Both can extract to SQL NULL, so they look the same after extraction.

With jsonb, the ? operator tests whether a top-level key exists, which distinguishes 'key absent' from 'key present but null-like'.

from the unit →
Why did my row count jump after expanding a JSON array?

Expanding an array with jsonb_array_elements_text changes the grain: one row per order becomes one row per order-tag. An order with two tags now appears twice.

This is the same grain change as a one-to-many join. Decide whether an output row should represent an order or one element before you SUM, COUNT, or AVG.

from the unit →
How do I find orders whose tags contain 'promo'?

PostgreSQL jsonb provides containment and existence operators. For a top-level array, metadata -> 'tags' ? 'promo' tests whether the element exists.

Array-search syntax varies a lot across databases. Decide whether you need one element, the whole array, or an existence test, then use your database's operator.

from the unit →

That’s the free taste

Two ways to go deeper.

Don’t paste credentials, personal data, or confidential production records.

Related patterns

P15Pattern · 10 minWindow functions & rankingMove into analytical SQL: calculate across rows, and rank them, without collapsing them.P13Pattern · 9 minData types & CASTThe same extract-then-CAST rule that keeps a JSON number from comparing like text.P08Pattern · 8 minEXISTS & anti-joinsWhy expanding an array changes the grain, the same way a one-to-many join does.
Up next · Pattern 15
Window functions and ranking

Next, we move into analytical SQL and learn how window functions calculate across related rows without collapsing them into one row per group.

Continue to P15 →Browse all patterns