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.
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
-- 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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
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' ->> 0in PostgreSQL;doc ->> '$.phones[0]'in MySQL and SQLite. - The last phone:
doc -> 'phones' ->> -1in PostgreSQL,doc ->> '$.phones[last]'in MySQL anddoc ->> '$.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
jsonorjsonbvalue 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
-- 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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
-- 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
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
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 OFandJSON_CONTAINS()ask MySQL whether an array or a document holds a value.JSON_VALUE(),JSON_QUERY()andJSON_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 thatEXISTScan ask about any element.
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
nullas 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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
-
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 useJSON_EXTRACT(). -
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?
-
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.
-
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?
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
- PostgreSQL 18: JSON Functions and Operators (The PostgreSQL Global Development Group)
- PostgreSQL 18: JSON Types (containment, existence and jsonpath) (The PostgreSQL Global Development Group)
- PostgreSQL 18: System Information Functions (pg_input_is_valid) (The PostgreSQL Global Development Group)
- PostgreSQL 16 Release Notes (The PostgreSQL Global Development Group)
- PostgreSQL 17 Release Notes (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: Functions That Search JSON Values (Oracle Corporation)
- MySQL 8.4 Reference Manual: The JSON Data Type (paths, comparison and ordering) (Oracle Corporation)
- MySQL 8.4 Reference Manual: Functions That Return JSON Value Attributes (Oracle Corporation)
- JSON Functions And Operators (SQLite)
- Datatypes In SQLite (comparison expressions) (SQLite)
- ISO/IEC 9075-2:2023 Information technology, Database languages SQL, Part 2: Foundation (International Organization for Standardization (ISO))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress