Skip to content
datahacks.dev
JSON

JSON_EXTRACT and the Arrow Operators in MySQL Explained

How MySQL JSON paths work, what JSON_EXTRACT returns for each value type, why -> and ->> are not the same, and the path rules that cause errors.

7 min read
Summarize in AI
Flat illustration of a tree of nested objects beside the document it came from, with the JSON logo.

Every read from a MySQL JSON column goes through a path. You write $.specs.dims.w and get a number back, or $.tags[0] and get a string with quotes on it, and somewhere between the two a lot of confusion begins. This article takes the path language and the three extraction forms apart: what the syntax allows, what comes back for each kind of value, how the arrow operators relate to the functions, and the errors that appear when you write a path that looks reasonable.

The three forms

SQL
json_extract(doc, '$.brand')                         -- returns JSON
doc -> '$.brand'                                      -- same as json_extract
doc ->> '$.brand'                                     -- json_unquote(json_extract(doc, '$.brand'))

-> and ->> are only syntactic sugar, and the sugar has one rule: the left side must be a column. cast('{"a":1}' as json) -> '$.a' is a syntax error near ->, and so is json_extract(@d, '$.a.b') -> '$[0]'. In those places use json_extract and json_unquote. In a query on a table, the operators read better, and the optimizer treats them identically to the functions.

The difference between -> and ->> is the JSON string quote. On row 1 of the product table, attrs -> '$.brand' returned "Globex" and attrs ->> '$.brand' returned Globex. Concatenation shows the effect plainly: concat(json_extract(attrs, '$.brand'), '!') is "Globex"!, and concat(attrs ->> '$.brand', '!') is Globex!.

The path language

A path starts with $, which is the document, and then applies steps. All of these ran on the same test document:

PathMeaningResult
$.a.b[0]key, key, element 010
$.a.b[2].cinto the object at index 2"deep"
$.a.b[last]last element{"c": "deep"}
$.a.b[last-1]the one before the last20
$.a.b[0 to 1]a range[10, 20]
$.a.b[*]every element[10, 20, {"c": "deep"}]
$.a.b[*].cc of every element that has one["deep"]
$.arr[1][0]nested arrays3
$.arr[*][1]second item of each inner array[2, 4]
$**.ckey c at any depth["deep"]
$."a b"key with a space (quoted)3
$."x.y"key with a dot (quoted)4

Two details that cost people time. First, keys are case sensitive and quoting is part of the path: the test document had both Key and key, and $.Key returned 1 while $.key returned 2. A key with a space, a dot or other punctuation needs double quotes inside the path string, as in '$."a b"'; without them the path is an error or reads as something else, because $.x.y means key x and then key y, not the key x.y. Second, wildcards return arrays: $.a.b[*].c returned ["deep"] even though one value matched. A path that can match more than one place returns an array, and that array is what your code receives, so unwrap it deliberately. $**.c finds all c keys at any depth and also returns an array.

When you give json_extract several paths, it returns the matches in an array in the order of the paths: json_extract(@d, '$.t', '$.num') gave ["text", 7].

What is not allowed

  • A negative index. $.a.b[-1] raised ERROR 3143: Invalid JSON path expression. The error is around character position 6. Use [last] and [last-1].

  • **A path without the leading $.** json_extract('{"a": 1}', 'a') fails with the same error. Always begin with $.

  • Expressions inside the brackets beyond last, last-N, a number, * and a to b. There is no filter syntax here; for conditions on elements, use JSON_TABLE (JSON_TABLE in MySQL) or JSON_SEARCH.

An index past the end, $.a.b[10], is not an error. It returns SQL NULL, the same as a missing key.

What each type looks like on the way out

json_extract returns a JSON value, and the display of that value is what trips people. Using one value of each type:

Text
path     json_extract   json_type   json_unquote(json_extract(...))
$.t      "text"         STRING      text
$.num    7              INTEGER     7
$.f      1.5            DOUBLE      1.5
$.bool   false          BOOLEAN     false
$.n      null           NULL        null
$.zzz    NULL (SQL)     NULL        NULL
$.a.b    [10, 20, ...]  ARRAY       [10, 20, {"c": "deep"}]

Strings come back with quotes. Numbers, booleans and null do not need any. json_unquote removes the quotes and un-escapes a JSON string (json_unquote('"a\\"b"') returned a"b, and é became é), while on a non-string value it returns the JSON text unchanged. In particular json_unquote of a JSON null returned the four-character string null, not SQL NULL, which is why where doc ->> '$.x' is null does not find keys set to null. json_type prints NULL for both a JSON null and a missing path; check json_extract(...) is null for the SQL NULL.

Comparing the results

Because the value is still JSON, MySQL compares it with JSON rules when the other side is a JSON value, and converts when it is a string or number literal:

Text
json_extract(@d, '$.num') = 7        1
json_extract(@d, '$.num') = '7'      0
json_extract(@d, '$.num') + 1        8
json_extract(@d, '$.t') = '"t"'      0   (the value is "text", so 't' and '"t"' never match)

A JSON number equals a numeric literal, but not the string '7'. A JSON string "text" is not equal to the SQL string text until you unquote. That is the main reason to prefer ->> when comparing text: attrs ->> '$.brand' = 'Acme' works the way it reads. For numbers, either leave the value as JSON and compare it with a number (attrs -> '$.price' > 450 counted 20,061 rows), or extract text and cast (cast(attrs ->> '$.price' as decimal(8,2)) > 450, also 20,061). Comparing the text with a string (attrs ->> '$.price' > '450') counted 43,661 and is wrong. The hub guide, using the JSON column type in MySQL, has the whole set.

Using a path from a variable or parameter

The path is just a string, so it can come from a user variable, a concat or a prepared-statement parameter:

SQL
set @p = '$.a.b[2].c';
select json_extract(@d, @p);                     -- "deep"

prepare st from 'select json_extract(?, ?)';
set @doc = '{"k": 5}', @path = '$.k';
execute st using @doc, @path;                    -- 5

Both worked. That is useful for generic code, and it carries the usual risk: a path built from user input is a path chosen by the user, and a bad one raises error 3143. Whitelist the paths, or validate them against a pattern before use. The operators -> and ->> need a literal path string (they do not accept a variable or expression on the right), so a dynamic path means json_extract.

Extraction inside generated columns and indexes

The same expressions are what you put in a generated column. attrs ->> '$.sku' in a generated always as (...) clause is stored or computed per row, and an index on that column makes the lookup fast. For types, json_value(attrs, '$.price' returning decimal(8,2)) (8.0.21 and later) extracts and casts in one step, with null on error or error on error. See indexing a MySQL JSON field with a generated column for the details.

Reading many paths at once

Calling json_extract ten times on the same column is ten decodes of the document. MySQL's binary format makes each lookup cheap, but if you need ten fields, JSON_TABLE reads them in one pass and gives typed columns:

SQL
select jt.* from products p,
  json_table(p.attrs, '$' columns (
    sku varchar(20) path '$.sku',
    brand varchar(20) path '$.brand',
    price decimal(8,2) path '$.price')) jt
where p.id = 1;

For one or two fields the arrows are fine. In the guide's benchmark MySQL's penalty over typed columns was a factor of 1.5 for a group-by on two extracted fields, so extraction is not where you lose.

When a path expression is not enough

A path addresses a place; it does not search. These are cases where json_extract is the wrong tool.

You need to filter elements of an array by a property. Paths cannot say "the item whose sku is B2". JSON_TABLE with a where, or JSON_SEARCH for string values, can (searching values inside a MySQL JSON array).

The key is not known in advance. json_keys(doc) lists the keys, and JSON_TABLE with a path wildcard iterates values, but turning key names into rows needs JSON_TABLE over json_keys and a second lookup.

You need the whole structure transformed. JSON_MERGE_PATCH, JSON_OBJECT and JSON_ARRAYAGG build new documents, covered in building JSON output with JSON_OBJECT and JSON_ARRAYAGG.

The path must be unit tested. A typo silently returns SQL NULL. Check with json_contains_path(doc, 'one', '$.path') in a test, or assert row counts.

The syntax is defined in the MySQL manual, Searching and modifying JSON values, and the path grammar is shown in the JSON path syntax section. A browser tool for trying a path against a document is the JSONPath tester on this site; MySQL's path syntax is a subset with its own last and to forms, so confirm the final expression in the mysql client.

Taggedhow-to
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.