Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

SQL (PostgreSQL, MySQL, SQLite)  Module 15 – JSON in SQL

Querying JSON values and paths

Read values out of JSON with -> and ->>, follow paths into nested objects and arrays, and filter rows by JSON in PostgreSQL, MySQL and SQLite.

  • Intermediate
  • 30 minutes
  • Examples run with sql.js 1.14.2
  • By MySmartCoPilot
Free core, Pro depth: 11 of 30 minutes free
Free: Two arrows: JSON out, or a plain value out · Paths in the three engines · Filtering rows by what their JSON holds · 3 more sections. With a pass: 2 in-depth parts and 3 interview answers, about 19 minutes.

What you will learn

  • Extract values from JSON documents with -> and ->> in PostgreSQL, MySQL and SQLite
  • Follow paths into nested objects and arrays, including positions counted from the end
  • Filter rows by values inside JSON, with the casts that number comparisons need
  • Explain how path syntax, missing keys and JSON null differ between the three engines

Before you start

What you’ll learn · Before you start

What you will learn

  • Extract values from JSON documents with -> and ->> in PostgreSQL, MySQL and SQLite
  • Follow paths into nested objects and arrays, including positions counted from the end
  • Filter rows by values inside JSON, with the casts that number comparisons need
  • Explain how path syntax, missing keys and JSON null differ between the three engines

Before you start

On this page

Querying JSON values comes down to two jobs: reading one value out of a document, and keeping the rows whose documents hold the value you want. PostgreSQL, MySQL and SQLite use the same two arrows for the first job. -> returns the part you name as JSON, quotes and all, and ->> returns it as a plain SQL value that you can compare, add up or print. You name the part with a path: $ is the whole document, .city steps into the key city of an object, and [0] picks the first element of an array, so $.address.city reads the city inside the address. Querying JSON values in a WHERE clause is the second job: put the ->> form in the condition and compare it like a column. This lesson covers both jobs, each engine’s paths, and a number comparison that quietly returns the wrong rows.

Press Run on any example to try it in your browser on SQLite 3.49.1, the engine that sql.js 1.14.2 contains. This page runs SQLite only, so the PostgreSQL 18 and MySQL 8.4 forms it shows follow those engines’ manuals.

Two arrows: JSON out, or a plain value out

The city from a nested address, as JSON and as a plain value SQL · nested_address.sql
-- Customer profiles kept as JSON text: -> returns JSON, ->> returns a plain SQL value.
CREATE TABLE customers (id INTEGER PRIMARY KEY, profile TEXT);
INSERT INTO customers VALUES
  (1, '{"name": "Asha Kulkarni", "address": {"city": "Pune", "pin": "411001"},
        "phones": ["98200 11111", "98200 22222"]}'),
  (2, '{"name": "Ravi Menon", "address": {"city": "Kochi"}, "phones": []}');

SELECT id,
       profile -> '$.address.city'          AS city_json,
       profile ->> '$.address.city'         AS city,
       typeof(profile ->> '$.address.city') AS sql_type
FROM customers;

-- The same city through two arrows, the first and last phone, and a key that is not there.
SELECT id,
       profile -> 'address' ->> 'city' AS chained,
       profile ->> '$.phones[0]'       AS first_phone,
       profile ->> '$.phones[#-1]'     AS last_phone,
       profile ->> '$.address.state'   AS state
FROM customers;

Output

┌────┬───────────┬───────┬──────────┐
│ id │ city_json │ city  │ sql_type │
├────┼───────────┼───────┼──────────┤
│ 1  │ "Pune"    │ Pune  │ text     │
│ 2  │ "Kochi"   │ Kochi │ text     │
└────┴───────────┴───────┴──────────┘
┌────┬─────────┬─────────────┬─────────────┬───────┐
│ id │ chained │ first_phone │ last_phone  │ state │
├────┼─────────┼─────────────┼─────────────┼───────┤
│ 1  │ Pune    │ 98200 11111 │ 98200 22222 │       │
│ 2  │ Kochi   │             │             │       │
└────┴─────────┴─────────────┴─────────────┴───────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < nested_address.sql

In the first result, city_json is "Pune" with its double quotes: -> returned a piece of JSON text. city is Pune without quotes, and typeof() says it is SQL text. SQLite’s ->> turns each JSON type into its SQL counterpart: a string into TEXT, a number into INTEGER or REAL, true and false into 1 and 0, and JSON’s null into SQL NULL. Objects and arrays come back as JSON text from both arrows. Use -> when the value goes on into another JSON function, and ->> for everything you compare, sort or show.

The second query writes a path in pieces: profile -> 'address' ->> 'city' takes the address as JSON, then reads its city. On the right of an arrow, SQLite accepts a full path such as '$.address.city', a bare key such as 'address', or a whole number for an array position, where a negative number counts from the end. $.phones[#-1] is the last phone. A path that leads nowhere, such as $.address.state or the first phone of an empty list, returns NULL rather than an error.

Paths in the three engines

Two paths through a customer profile: $ to $.address to $.address.city, and $ to $.phones to the first phone, $.phones[0].$the whole profile$.addressan object$.address.city"Pune"$.phonesan array$.phones[0]"98200 11111".address.city.phones[0]

A path is a list of steps into the document

Text description of the diagram

The diagram draws two paths through the profile of customer 1. At the top is the whole document, written $.

  • On the left, the step .address enters the key address, whose value is an object, so its path is $.address. The step .city then enters the key city of that object: $.address.city holds the string "Pune".
  • On the right, the step .phones enters the key phones, whose value is an array, so its path is $.phones. The step [0] picks the array's first element, because positions count from 0: $.phones[0] holds "98200 11111".

Each step adds one part to the path: a dot and a key to enter an object, or a position in square brackets to pick an element of an array.

Paths look alike in the three engines, but small differences break a query copied from one to another. With the profile in a column doc:

  • The city, as JSON: doc -> 'address' -> 'city' in PostgreSQL; doc -> '$.address.city' in MySQL and SQLite.
  • The city, as a plain value: doc #>> '{address,city}' in PostgreSQL; doc ->> '$.address.city' in MySQL and SQLite.
  • The first phone: doc -> 'phones' ->> 0 in PostgreSQL; doc ->> '$.phones[0]' in MySQL and SQLite.
  • The last phone: doc -> 'phones' ->> -1 in PostgreSQL, doc ->> '$.phones[last]' in MySQL and doc ->> '$.phones[#-1]' in SQLite.
  • What ->> returns: always text in PostgreSQL, text without JSON’s quotes in MySQL, and TEXT, INTEGER, REAL or NULL in SQLite.
  • What may stand left of an arrow: any json or jsonb value in PostgreSQL, only a column name in MySQL, and any JSON text or JSONB in SQLite.

PostgreSQL’s arrows take one key or one position at a time, while #> and #>> take a whole path as a text array. In MySQL, -> and ->> are shorthand for JSON_EXTRACT() and JSON_UNQUOTE(JSON_EXTRACT()), so '{"a": 1}' ->> '$.a' is a syntax error there, while JSON_EXTRACT() accepts any expression.

Filtering rows by what their JSON holds

Events whose properties say the platform is android SQL · android_events.sql
-- App events whose properties are one JSON object per event.
CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, props TEXT);
INSERT INTO events VALUES
  (1, 'app_open', '{"platform": "android", "app_version": "5.2.0"}'),
  (2, 'app_open', '{"platform": "ios", "app_version": "5.2.1"}'),
  (3, 'payment',  '{"platform": "android", "method": "UPI", "amount": 1250}'),
  (4, 'payment',  '{"platform": "web", "method": "card", "amount": 499}'),
  (5, 'app_open', '{"app_version": "5.1.9"}'),
  (6, 'payment',  '{"platform": "Android", "method": "UPI", "amount": 2100}');

SELECT id, name
FROM events
WHERE props ->> 'platform' = 'android';

SELECT props ->> 'platform' AS platform, count(*) AS events
FROM events
GROUP BY platform
ORDER BY events DESC, platform;

Output

┌────┬──────────┐
│ id │   name   │
├────┼──────────┤
│ 1  │ app_open │
│ 3  │ payment  │
└────┴──────────┘
┌──────────┬────────┐
│ platform │ events │
├──────────┼────────┤
│ android  │ 2      │
│          │ 1      │
│ Android  │ 1      │
│ ios      │ 1      │
│ web      │ 1      │
└──────────┴────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < android_events.sql

The filter is an ordinary comparison with the extracted text. Event 6 is missing because Android with a capital A is a different string: a filter on JSON is as exact as a filter on a column. The second query groups by the same expression. Event 5 has no platform key, so its platform is NULL and it forms a group of its own.

Comparing numbers: the arrow decides

One filter, amount over 1000, written three ways SQL · amount_over_1000.sql
-- Four payments: two apps send the amount as a JSON number, one as a JSON string.
CREATE TABLE payments (id INTEGER PRIMARY KEY, props TEXT);
INSERT INTO payments VALUES
  (1, '{"method": "UPI", "amount": 250}'),
  (2, '{"method": "UPI", "amount": "250"}'),
  (3, '{"method": "UPI", "amount": 1500}'),
  (4, '{"method": "card", "amount": "1500"}');

-- Three ways to write "amount > 1000": 1 means the row passes the filter.
SELECT id,
       props ->> 'amount'                      AS amount,
       typeof(props ->> 'amount')              AS sql_type,
       props -> 'amount' > 1000                AS with_arrow,
       props ->> 'amount' > 1000               AS with_double_arrow,
       CAST(props ->> 'amount' AS REAL) > 1000 AS cast_first
FROM payments;

Output

┌────┬────────┬──────────┬────────────┬───────────────────┬────────────┐
│ id │ amount │ sql_type │ with_arrow │ with_double_arrow │ cast_first │
├────┼────────┼──────────┼────────────┼───────────────────┼────────────┤
│ 1  │ 250    │ integer  │ 1          │ 0                 │ 0          │
│ 2  │ 250    │ text     │ 1          │ 1                 │ 0          │
│ 3  │ 1500   │ integer  │ 1          │ 1                 │ 1          │
│ 4  │ 1500   │ text     │ 1          │ 1                 │ 1          │
└────┴────────┴──────────┴────────────┴───────────────────┴────────────┘

Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < amount_over_1000.sql

Only cast_first gets every row right: 1500 and “1500” pass, 250 and “250” do not. -> returns JSON text, and when SQLite compares values of different storage classes with no type to convert them to, every number ranks below every text value, so all four rows pass. ->> fixes the JSON numbers, but the amount sent as the string “250” still arrives as TEXT and still passes. Casting first compares numbers with numbers. PostgreSQL needs the cast too, because its ->> always returns text: (props ->> 'amount')::numeric > 1000.

Beyond the arrows

The arrows read one value at a time. Each engine also has tools for questions about whole documents:

  • json_type() in SQLite tells a key that holds JSON’s null from a key that is missing.
  • Containment, @>, asks PostgreSQL whether a document includes a given piece of JSON, such as {"vip": true}.
  • The key test ? asks PostgreSQL whether a string is a top-level key or array element.
  • JSON path filters such as $.items[*] ? (@.qty > 2) keep only the array elements that pass a test.
  • MEMBER OF and JSON_CONTAINS() ask MySQL whether an array or a document holds a value.
  • JSON_VALUE(), JSON_QUERY() and JSON_EXISTS(), from the SQL standard, read a value, read a piece of JSON or test a path; PostgreSQL has all three from version 17, MySQL only the first.
  • json_each() turns an array into rows in SQLite, so that EXISTS can ask about any element.
JSON Viewer & Tree Editor Open a large document as a tree and copy the path of any value, to use on the right of an arrow.

Your turn

Exercise · Easy · SQL

Find the UPI payments over 1,000 rupees

The table events has the columns id (INTEGER), name (TEXT) and props (TEXT), and props holds one JSON object per event.

Write a query that returns the id of every event named 'payment' whose props say that payment_method is 'UPI' and whose amount is greater than 1000 rupees.

  • Some app versions send the amount as a JSON number (1250) and others as a JSON string ("1250"). Both count.
  • An event without an amount, or with null as its amount, is not over 1000.
  • The order of the rows does not matter.

Starter code · upi_payments.sql

-- Return the id of every UPI payment over 1000 rupees.
SELECT id
FROM events
WHERE name = 'payment';
The sample tests · tests.yaml
# Sample tests: each one builds a small events table, runs your query on it and compares the rows it returns
# (in any order).
tests:
  - name: keeps UPI payments over 1000 and nothing else
    setup: |
      CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, props TEXT);
      INSERT INTO events VALUES
        (1, 'payment', '{"payment_method": "UPI", "amount": 1250}'),
        (2, 'payment', '{"payment_method": "UPI", "amount": 999}'),
        (3, 'payment', '{"payment_method": "card", "amount": 5000}'),
        (4, 'payment', '{"payment_method": "UPI", "amount": 1000}'),
        (5, 'payment', '{"payment_method": "UPI", "amount": 1000.5}'),
        (6, 'app_open', '{"platform": "android"}');
    expected:
      columns: [id]
      rows:
        - [1]
        - [5]
  - name: compares amounts sent as JSON strings as numbers
    setup: |
      CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, props TEXT);
      INSERT INTO events VALUES
        (1, 'payment', '{"payment_method": "UPI", "amount": "1250"}'),
        (2, 'payment', '{"payment_method": "UPI", "amount": "250"}'),
        (3, 'payment', '{"payment_method": "UPI", "amount": 250}'),
        (4, 'refund', '{"payment_method": "UPI", "amount": 5000}');
    expected:
      columns: [id]
      rows:
        - [1]
  - name: leaves out payments without an amount or a method
    setup: |
      CREATE TABLE events (id INTEGER PRIMARY KEY, name TEXT, props TEXT);
      INSERT INTO events VALUES
        (1, 'payment', '{"payment_method": "UPI"}'),
        (2, 'payment', '{"payment_method": "UPI", "amount": null}'),
        (3, 'payment', '{"amount": 4000}'),
        (4, 'payment', '{"payment_method": "UPI", "amount": 30000, "upi_app": "BHIM"}');
    expected:
      columns: [id]
      rows:
        - [4]
A hint

Read both keys with ->>. A JSON string such as "1250" comes back as TEXT, and SQLite ranks every TEXT value above every number, so turn the amount into a number with CAST(... AS REAL) before you compare it with 1000.

The sample tests run on this device, in your browser (sql.js): nothing is sent to mysmartcopilot.com. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 What does this query return in SQLite 3.49?

    Read the code, then choose one answer.

    SELECT '{"city": "Pune"}' -> '$.city'  AS a,
           '{"city": "Pune"}' ->> '$.city' AS b;
    Show the answer to question 1

    Answer: a is "Pune" with its double quotes, and b is Pune

    -> returns the part of the document as JSON text, so a string keeps its double quotes. ->> returns a plain SQL value, here the TEXT Pune, which is what you compare, show or add up.

  2. Question 2 of 5 What does this query return in SQLite 3.49?

    Read the code, then choose one answer.

    SELECT '[10, 20, 30]' ->> -1 AS x;
    Show the answer to question 2

    Answer: 30

    An integer on the right of ->> picks an array element by position. From SQLite 3.47.0 a negative number counts from the end, so -1 is the last element; it means the same as the path '$[#-1]'.

  3. Question 3 of 5 In SQLite, WHERE props -> 'amount' > 1000 keeps every row, even payments of 250. Why?

    Choose one answer.

    Show the answer to question 3

    Answer: -> returns JSON text, and SQLite ranks every TEXT value above every number

    When the two sides of a comparison have different storage classes and no affinity, SQLite decides by class: every INTEGER or REAL value is less than every TEXT value. ->> returns the number as an INTEGER, so the comparison becomes numeric.

  4. Question 4 of 5 In PostgreSQL, props is a jsonb column. Which condition keeps exactly the payments whose amount is over 1000?

    Choose one answer.

    Show the answer to question 4

    Answer: (props ->> 'amount')::numeric > 1000

    In PostgreSQL ->> always returns text. Comparing text with the number 1000 is an error (there is no text > integer operator), and comparing it with the text '1000' compares characters, so '900' comes after '1000'. Cast the text to a number first.

  5. Question 5 of 5 What does this query return in SQLite 3.49?

    Read the code, then choose one answer.

    SELECT '{"total": 560}' ->> 'coupon' AS coupon;
    Show the answer to question 5

    Answer: NULL

    A path that leads to nothing returns SQL NULL from -> and ->>, never an error, so a misspelt key also gives NULL. Test with json_type() when you need to know whether the key is there.

Filters that misfire in each engine, reproduced Pro: opens with a Pro, Premium or Ultimate pass

A JSON filter can run without an error and still return the wrong rows. This part tests one filter against four documents in three engines, keeps a missing key apart from JSON’s null, and sorts casts into those that fail loudly and those that fail quietly.

In this part:

  • One filter, four documents, three engines
  • Missing key, JSON null and SQL NULL, told apart
  • Casts that fail loudly, and casts that fail quietly

2 runnable examples (SQL) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Searching arrays and filtering with JSON paths, engine by engine Pro: opens with a Pro, Premium or Ultimate pass

Questions about arrays, such as whether any item has a quantity over 2, need more than an arrow. This part answers them with json_each() in SQLite, with containment and JSON path filters in PostgreSQL and with MEMBER OF in MySQL, and compares the three query functions of the SQL standard.

In this part:

  • Any element and every element, in SQLite
  • The same questions in PostgreSQL and MySQL
  • Keys with dots, spaces and capital letters
  • JSON_VALUE, JSON_QUERY and JSON_EXISTS, compared

1 runnable example (SQL) · about 4 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

  1. Fresher to mid · Warm-up

    What is the difference between -> and ->> when you read a value from a JSON column?

    Both operators follow the same path into the document; they differ in what they return. -> returns the part it finds as JSON: a string keeps its double quotes, and objects and arrays stay JSON, ready for the next arrow or another JSON function. ->> returns a plain SQL value: always text in PostgreSQL, text without JSON's quotes in MySQL, and TEXT, INTEGER, REAL or NULL in SQLite. A path that finds nothing gives NULL from both. Use ->> for whatever you compare, sort, group or show, and cast it before comparing numbers, because each engine compares text and numbers by its own rules. In MySQL both arrows work only on a column name; elsewhere use JSON_EXTRACT().

  2. Mid to senior

    A SQLite report counts UPI payments over 1,000 rupees with WHERE props ->> 'amount' > 1000. The day after a new app release, the count jumps: almost every payment from the new version is included, even 50-rupee ones. What happened, and how do you fix the query and the data?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    A report counts orders without a coupon with doc ->> 'coupon' IS NULL. On PostgreSQL it says 1,840 orders; after the same data moves to MySQL 8.4, it says 1,215. No rows were lost. Explain the difference, and write a check that counts the same in both engines.

    Model answer, Pro. First step and follow-ups

  4. Senior to staff

    An events service moves from PostgreSQL 18 to MySQL 8.4. Its queries use props @> '{"platform": "android"}', props ? 'coupon', props -> 'tags' ? 'express' and (props ->> 'amount')::numeric > 1000. How do you rewrite each filter, and which results can change?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • -> returns JSON text and ->> a plain SQL value: compare, sort and show the ->> form.
  • Paths start with $ in MySQL and SQLite; PostgreSQL’s arrows take one key or position at a time, and #>> takes a whole path.
  • A missing key, or a path that leads nowhere, returns NULL rather than an error.
  • Cast extracted numbers before comparing them: SQLite ranks text above numbers, and PostgreSQL’s ->> always returns text.
  • MySQL’s arrows work only on a column; use JSON_EXTRACT() on any other expression.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.