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.

MySQL & MariaDB in Depth  Module 3 – Schema design, indexes and server-side objects

JSON, generated columns and full-text search

Store JSON in MySQL 8.4 and read it by path, index its fields with generated columns and multi-valued indexes, and search text with InnoDB full-text.

  • Intermediate
  • 20 minutes
  • Examples run with MySQL 8.4.11 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Store and query JSON documents in MySQL and index the fields queries filter on
  • Compare virtual and stored generated columns and their DDL costs
  • Build an InnoDB full-text index with boolean mode and the ngram parser
  • Describe JSON behaviour that differs on MySQL and MariaDB

Before you start

What you’ll learn · Before you start

What you will learn

  • Store and query JSON documents in MySQL and index the fields queries filter on
  • Compare virtual and stored generated columns and their DDL costs
  • Build an InnoDB full-text index with boolean mode and the ngram parser
  • Describe JSON behaviour that differs on MySQL and MariaDB

Before you start

On this page

An orders table keeps each delivery as one JSON document, and a search for pin code 560001 reads all 2,000 of those documents, because no index points inside a document. Searching 300 product descriptions for the word “wireless” with LIKE reads every row too, and it cannot say which rows match best. MySQL 8.4 has a tool for each problem, and each has a price on writes that you should know before using it.

You will read and reshape JSON with paths and JSON_TABLE, put an index behind one JSON field and compare a virtual with a stored column, index a whole array, see what a JSON update writes to the The file in which the server records every change to the data, in order. Replicas read it to repeat those changes, and it is used to recover to a point in time., and build a full-text index that ranks its results, with the ngram parser for text that has no spaces.

JSON columns store a binary document you read with paths

The orders table has two columns, id and delivery, and delivery is a JSON column that holds a city, a pin code, an express flag and an array of items. The JSON type refuses invalid JSON and converts each value to a binary format in which a key or an array position is reached by a direct jump, with no scan over the values ahead of it. This run loads 20 orders from fixed literals and reads them three ways:

Reading a JSON column with ->, ->> and JSON_EXTRACT

json_read_session.py

"""Reading a JSON column: ->, ->> and JSON_EXTRACT on 20 orders, and what the binary storage keeps.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads orders.sql quietly, then runs json_read.sql. The documents are
fixed literals, so every run prints the same rows.
"""

from mysql_session import Server

with Server() as my:
    my.up()
    my.mysql("orders.sql", flags=("--skip-comments",))
    my.mysql("json_read.sql", flags=("-t", "-vv", "--skip-comments"))

Output

$ mysql --skip-comments < orders.sql

$ mysql -t -vv --skip-comments < json_read.sql
--------------
SELECT id, delivery->>'$.pin_code' AS pin_text, delivery->'$.pin_code' AS pin_json, delivery->'$.items' AS items FROM orders LIMIT 3
--------------

+----+----------+----------+-----------------------------------------------------------------------------------+
| id | pin_text | pin_json | items                                                                             |
+----+----------+----------+-----------------------------------------------------------------------------------+
|  1 | 560001   | "560001" | [{"qty": 2, "sku": "A100"}, {"qty": 1, "sku": "B200"}]                            |
|  2 | 560002   | "560002" | [{"qty": 4, "sku": "C300"}]                                                       |
|  3 | 400001   | "400001" | [{"qty": 1, "sku": "A100"}, {"qty": 3, "sku": "D400"}, {"qty": 1, "sku": "E500"}] |
+----+----------+----------+-----------------------------------------------------------------------------------+
3 rows in set

--------------
SELECT JSON_EXTRACT(delivery, '$.pin_code') AS pin, JSON_TYPE(delivery->'$.pin_code') AS json_type, JSON_TYPE(delivery->'$.express') AS express_type, delivery->>'$.landmark' AS landmark FROM orders WHERE id IN (1, 3)
--------------

+----------+-----------+--------------+------------------+
| pin      | json_type | express_type | landmark         |
+----------+-----------+--------------+------------------+
| "560001" | STRING    | BOOLEAN      | NULL             |
| "400001" | STRING    | BOOLEAN      | Gateway of India |
+----------+-----------+--------------+------------------+
2 rows in set

--------------
SELECT COUNT(*) AS in_560001 FROM orders WHERE delivery->>'$.pin_code' = '560001'
--------------

+-----------+
| in_560001 |
+-----------+
|         4 |
+-----------+
1 row in set

--------------
SELECT CAST('{"zip": 1, "city": "Pune", "zip": 2}' AS JSON) AS normalised
--------------

+----------------------------+
| normalised                 |
+----------------------------+
| {"zip": 2, "city": "Pune"} |
+----------------------------+
1 row in set

--------------
SELECT id, JSON_STORAGE_SIZE(delivery) AS binary_bytes, LENGTH(CAST(delivery AS CHAR)) AS text_bytes FROM orders WHERE id <= 3
--------------

+----+--------------+------------+
| id | binary_bytes | text_bytes |
+----+--------------+------------+
|  1 |          142 |        125 |
|  2 |          110 |         99 |
|  3 |          203 |        181 |
+----+--------------+------------+
3 rows in set

Bye

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 json_read_session.py

json_read.sql

USE shop;
-- -> returns JSON (strings keep their quotes), ->> returns a plain utf8mb4 string; JSON_EXTRACT is -> in function form.
SELECT id, delivery->>'$.pin_code' AS pin_text, delivery->'$.pin_code' AS pin_json, delivery->'$.items' AS items FROM orders LIMIT 3;
SELECT JSON_EXTRACT(delivery, '$.pin_code') AS pin, JSON_TYPE(delivery->'$.pin_code') AS json_type, JSON_TYPE(delivery->'$.express') AS express_type, delivery->>'$.landmark' AS landmark FROM orders WHERE id IN (1, 3);
SELECT COUNT(*) AS in_560001 FROM orders WHERE delivery->>'$.pin_code' = '560001';
-- The stored form is binary and normalised: keys are sorted and a repeated key keeps only one value.
SELECT CAST('{"zip": 1, "city": "Pune", "zip": 2}' AS JSON) AS normalised;
SELECT id, JSON_STORAGE_SIZE(delivery) AS binary_bytes, LENGTH(CAST(delivery AS CHAR)) AS text_bytes FROM orders WHERE id <= 3;

orders.sql

-- The 20 orders every JSON session starts from: one JSON document of delivery details per order.
CREATE DATABASE shop;
USE shop;
CREATE TABLE orders (id BIGINT PRIMARY KEY, delivery JSON);
INSERT INTO orders VALUES
  (1, '{"pin_code": "560001", "city": "Bengaluru", "express": true, "items": [{"sku": "A100", "qty": 2}, {"sku": "B200", "qty": 1}]}'),
  (2, '{"pin_code": "560002", "city": "Bengaluru", "express": false, "items": [{"sku": "C300", "qty": 4}]}'),
  (3, '{"pin_code": "400001", "city": "Mumbai", "express": true, "landmark": "Gateway of India", "items": [{"sku": "A100", "qty": 1}, {"sku": "D400", "qty": 3}, {"sku": "E500", "qty": 1}]}'),
  (4, '{"pin_code": "110001", "city": "New Delhi", "express": false, "items": [{"sku": "B200", "qty": 5}]}'),
  (5, '{"pin_code": "600001", "city": "Chennai", "express": true, "items": [{"sku": "F600", "qty": 2}, {"sku": "A100", "qty": 2}]}'),
  (6, '{"pin_code": "700001", "city": "Kolkata", "express": false, "landmark": "Esplanade", "items": [{"sku": "C300", "qty": 1}]}'),
  (7, '{"pin_code": "560001", "city": "Bengaluru", "express": false, "items": [{"sku": "D400", "qty": 2}, {"sku": "E500", "qty": 2}]}'),
  (8, '{"pin_code": "411001", "city": "Pune", "express": true, "items": [{"sku": "A100", "qty": 3}]}'),
  (9, '{"pin_code": "500001", "city": "Hyderabad", "express": false, "items": [{"sku": "B200", "qty": 2}, {"sku": "F600", "qty": 1}]}'),
  (10, '{"pin_code": "380001", "city": "Ahmedabad", "express": false, "items": [{"sku": "E500", "qty": 4}]}'),
  (11, '{"pin_code": "560001", "city": "Bengaluru", "express": true, "items": [{"sku": "C300", "qty": 2}, {"sku": "A100", "qty": 1}, {"sku": "B200", "qty": 1}]}'),
  (12, '{"pin_code": "400001", "city": "Mumbai", "express": false, "items": [{"sku": "F600", "qty": 6}]}'),
  (13, '{"pin_code": "110001", "city": "New Delhi", "express": true, "landmark": "India Gate", "items": [{"sku": "D400", "qty": 1}, {"sku": "A100", "qty": 1}]}'),
  (14, '{"pin_code": "600001", "city": "Chennai", "express": false, "items": [{"sku": "E500", "qty": 3}]}'),
  (15, '{"pin_code": "302001", "city": "Jaipur", "express": false, "items": [{"sku": "B200", "qty": 1}, {"sku": "C300", "qty": 1}]}'),
  (16, '{"pin_code": "560002", "city": "Bengaluru", "express": true, "items": [{"sku": "A100", "qty": 5}]}'),
  (17, '{"pin_code": "700001", "city": "Kolkata", "express": true, "items": [{"sku": "D400", "qty": 2}, {"sku": "F600", "qty": 2}]}'),
  (18, '{"pin_code": "411001", "city": "Pune", "express": false, "items": [{"sku": "C300", "qty": 3}]}'),
  (19, '{"pin_code": "500001", "city": "Hyderabad", "express": true, "items": [{"sku": "E500", "qty": 1}, {"sku": "B200", "qty": 2}]}'),
  (20, '{"pin_code": "560001", "city": "Bengaluru", "express": false, "items": [{"sku": "F600", "qty": 1}]}');

mysql_session.py

"""Throwaway MySQL 8.4.11 servers for the examples of this lesson.

Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).

Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
learn:verify's run sandbox cannot read the home folder, where mise keeps MySQL, so these sessions are recorded with
LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway server: initialise, start; stopped and deleted when the with block ends.

    options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
    basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
    masks: (pattern, replacement) pairs, as for mask().
    """

    def __init__(self, options=(), redo=None, basedir=None, masks=()):
        self.options = [str(o) for o in options]
        self.redo = redo
        self.basedir = Path(basedir).resolve() if basedir else PINNED
        self.bin = self.basedir / "bin"
        self.masks = []
        for pattern, replacement in masks:
            self.mask(pattern, replacement)
        self.proc = None

    def mask(self, pattern, replacement):
        """From now on every printed line shows text matching the regular expression as the replacement: for a value
        that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
        self.masks.append((re.compile(pattern), replacement))

    # ---- the folder ------------------------------------------------------------------------------------------------
    def __enter__(self):
        if not (self.bin / "mysqld").exists():
            sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
                     else f"MySQL {MYSQL_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
        for sub in ("tmp", "files"):
            (self.base / sub).mkdir(mode=0o700)
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        self.client_cnf = self.base / "client.cnf"
        self._write_client_cnf(password=None)
        self.env = {key: value for key, value in os.environ.items()
                    if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
        self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
                         "MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
        for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
            for f in Path(".").glob(pattern):
                shutil.copy(f, self.base / f.name)
        return self

    def __exit__(self, *exc):
        if self.proc and self.proc.poll() is None:
            self.stop(quiet=True)
        shutil.rmtree(self.base, ignore_errors=True)

    def _write_client_cnf(self, password):
        lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
        if password:
            lines.append(f"password={password}")
        self.client_cnf.write_text("\n".join(lines) + "\n")
        self.client_cnf.chmod(0o600)

    def server_options(self):
        b = self.base
        return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
                f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
                f"--secure-file-priv={b / 'files'}",
                *([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]

    # ---- the server ------------------------------------------------------------------------------------------------
    def initialize(self, show=False):
        """mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
        args = ["mysqld", *self.server_options(), "--initialize-insecure"]
        code, out = self._call(args)
        if show:
            _show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
        if code != 0:
            print(self.log())
            sys.exit(f"mysqld --initialize-insecure ended with status {code}")

    def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
        """Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
        start that must fail, and returns them."""
        log = self.base / "mysqld.err"
        before = log.read_text() if log.exists() else ""
        opts = self.server_options()
        if defaults_file:
            opts[0] = f"--defaults-file={self.base / defaults_file}"
        args = [str(self.bin / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
                "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
        if show:
            shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
                     "--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *extra]
            print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
            print()
        with open(self.base / "mysqld.out", "a") as out:
            self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
        for _ in range(600):
            if self.proc.poll() is not None:
                break
            if self._ping():
                break
            time.sleep(0.1)
        if self.proc.poll() is not None:
            new = (log.read_text() if log.exists() else "")[len(before):]
            lines = [l for l in self.clean(LOG_PREFIX.sub("", new)).splitlines() if "[ERROR]" in l]
            if expect_fail:
                print("\n".join(lines))
                print(f"(mysqld ended with status {self.proc.returncode})")
                print()
                self.proc = None
                return lines
            print("\n".join(lines))
            sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
        if expect_fail:
            sys.exit("mysqld started, but the session expected it to fail")
        if not self._ping():
            sys.exit("mysqld did not answer within 60 seconds")
        return []

    def _ping(self):
        code, _ = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
        return code == 0

    def up(self, extra=(), defaults_file=None, show=False):
        """Initialise, start (with start()'s options), and give root the run's password, quietly."""
        self.initialize()
        self.start(extra=extra, defaults_file=defaults_file, show=show)
        return self.login()

    def login(self):
        """Gives root the run's password (never printed) on a server started from a new data directory, and makes
        every later client of the session use it."""
        self.silent(f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
        self._write_client_cnf(self.password)
        return self

    def stop(self, quiet=False):
        """A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
        code, out = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "shutdown"])
        if code != 0 and not quiet:
            sys.exit(f"mysqladmin shutdown failed: {out}")
        try:
            self.proc.wait(timeout=120)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
            self.proc.wait(timeout=60)
        self.proc = None

    def restart(self, extra=(), defaults_file=None, show=False):
        self.stop()
        self.start(extra=extra, defaults_file=defaults_file, show=show)

    # ---- running things --------------------------------------------------------------------------------------------
    def _call(self, args, stdin=None, merge=True):
        """Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
        wrote to standard output (its warnings on standard error are dropped)."""
        args = [str(a) for a in args]
        done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
                              stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
                              text=True, cwd=self.base, env=self.env, input=stdin)
        return done.returncode, self.clean(done.stdout)

    def output(self, *args, connect=False, status=0):
        """Runs one of MySQL's programs without printing anything and returns its standard output, for example the
        SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run, merge=False)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The substitutions of the docstring. A table of the client whose cells change is drawn again with the
        new column widths, so its borders still line up."""
        lines, out, i = text.split("\n"), [], 0
        while i < len(lines):
            if lines[i].startswith("+-"):
                j = i
                while j < len(lines) and lines[j][:1] in ("+", "|"):
                    j += 1
                block = lines[i:j]
                done = [self._clean(l) for l in block]
                out.extend(done if done == block else _redraw(block, self._clean))
                i = j
            else:
                out.append(self._clean(lines[i]))
                i += 1
        return "\n".join(out)

    def _clean(self, text):
        text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
        text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
        text = re.sub(r" \(\d+\.\d\d sec\)", "", text)
        text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
        text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
        text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
        text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", "<port>", text)
        for pattern, replacement in self.masks:
            text = pattern.sub(replacement, text)
        return text

    def log(self, pattern=None):
        """The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
        text = self.clean(LOG_PREFIX.sub("", (self.base / "mysqld.err").read_text()))
        if pattern:
            text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
        return text

    def show_log(self, pattern, note=None):
        """Prints the error-log lines that match a pattern, as grep would."""
        print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f"   # {note}" if note else ""))
        print(self.log(pattern))
        print()

    def query(self, sql, db=None):
        """One value from one query, unprinted (the session uses it to wait or to decide)."""
        args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
        code, out = self._call(args, stdin=sql)
        if code != 0:
            sys.exit(f"the query failed: {out}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only for the run's password)."""
        self.query(sql)

    def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
        """Runs a client script from standard input and prints the command and its output. `errors` is how many
        ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
        with status 0, without --force it stops at the first error with status 1. The default flags print each
        statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
        force = errors > 0 if force is None else force
        shown = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
        args = ["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]]
        code, out = self._call(args, stdin=(self.base / script).read_text())
        if note:
            print(f"-- {note}")
        print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
        if out.strip():
            print(out.rstrip("\n"))
        print()
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        expected_status = (1 if errors and not force else 0) if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
        return out

    def shell(self, *args, status=0, shown=None, connect=False):
        """Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
        (mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
        args = [str(a) for a in args]
        run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
        code, out = self._call(run)
        _show(shown or args, out)
        if code != status:
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
        """Runs one statement with mysql -e and prints the command and its output."""
        shown = ["mysql", *flags, *([db] if db else []), "-e", statement]
        code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]])
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
        if found != errors or (code != 0) != (errors > 0):
            sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
        return out

    def write_file(self, path, text, show=True):
        """Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
        (self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
        if show:
            self.show_file(path)

    def show_file(self, path):
        print(f"$ cat {path}")
        print(self.clean((self.base / path).read_text()).rstrip("\n"))
        print()

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
        print(textwrap.fill("  ".join(names), width=78, break_on_hyphens=False))
        print()
        return names


def _redraw(block, clean):
    """Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
    after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
    right-aligned (an empty cell says nothing about its column)."""
    cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
    rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
    header = next(k for k, r in enumerate(rows) if r is not None)
    ncol = len(cuts) - 1
    right = [any(r is not None and k != header and r[c].strip() and r[c].startswith("  ") for k, r in enumerate(rows))
             for c in range(ncol)]
    cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
    widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
    drawn = []
    for k, r in enumerate(cells):
        if r is None:
            drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
        else:
            drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
                                        for c, (t, w) in enumerate(zip(r, widths))) + "|")
    return drawn


def _arg(a):
    """A command-line argument as a reader would type it: quotes only where the shell needs them."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()

The two operators are shorthand. delivery->'$.pin_code' is JSON_EXTRACT(delivery, '$.pin_code'), and it returns a JSON value: the pin_json column shows "560001" with its quotes, and JSON_TYPE calls it a STRING. ->> also removes the quotes, so pin_text is the plain text 560001, which is what a select list, a CONCAT or a generated column should hold. A comparison such as delivery->'$.pin_code' = '560001' still works, because MySQL converts the text side to JSON before it compares; the quotes matter wherever the value is shown or stored. Order 1 has no $.landmark, and a missing path gives SQL NULL, not an error.

Form What it returns Use it for
-> A JSON value; strings keep their quotes Handing a value to another JSON function
->> Plain utf8mb4 text with the quotes removed Select lists, CONCAT, generated columns, and WHERE tests that must match one
JSON_EXTRACT(doc, path) The same as ->, in function form The same cases as ->, and the form MariaDB understands

Using -> where text is needed

Showing or storing the result of -> as if it were text keeps the JSON quotes: CONCAT('pin ', delivery->'$.pin_code') gives pin "560001". Use ->> for text, and -> only to hand a value on to another JSON function.

A stored document is not your text. The CAST(... AS JSON) query of the run feeds {"zip": 1, "city": "Pune", "zip": 2} through the JSON type and gets {"zip": 2, "city": "Pune"}: a repeated key keeps its last value and the keys are reordered, zip now before city. The manual warns that this key order may change between releases, so never depend on it. The binary form is also not smaller for small documents: order 1 takes 142 bytes in the column and 125 as text. What you buy is lookup by key, and the size of a document is limited by max_allowed_packet.

On MariaDB

MariaDB stores JSON as text: the type is an alias for LONGTEXT with a CHECK (json_valid(...)) constraint, the text is kept as written, and values compare as strings, not as JSON. Its compatibility page lists MySQL’s -> and ->> operators as unsupported, so a query that uses them needs JSON_EXTRACT before it moves.

Checkpoint

Check: JSON columns and path operators

  1. Question 1 of 2 In the run, delivery->'$.pin_code' shows "560001" with quotes, and delivery->>'$.pin_code' shows 560001. What explains the difference?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: -> returns a JSON value, and ->> returns the same value as plain text with the quotes removed

    -> is the operator form of JSON_EXTRACT and returns JSON. ->> also unquotes the result, which makes it the right choice wherever the value is shown or stored as text, as in a select list or a generated column. The 4 orders of pin code 560001 are found with WHERE delivery->>'$.pin_code' = '560001'.

  2. Question 2 of 2 A report written for MySQL 8.4 filters with WHERE delivery->>'$.city' = 'Pune'. The team plans to move it to MariaDB. What do they have to change first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Rewrite the path expression with JSON_EXTRACT, because MariaDB does not support the -> and ->> operators

    The type name moves across, and the column keeps its validation, but the operators do not. Replace each -> or ->> with JSON_EXTRACT (or another JSON function) and test the comparisons, which are string comparisons on MariaDB.

Arrays become rows and a schema check guards the shape

Each order keeps its line items inside the document, so a question such as “how many units of each item did we ship?” cannot use GROUP BY on the column. JSON_TABLE fixes that by turning a path into rows: for every element the path selects, each column of its COLUMNS clause cuts a value out of that element. Step through it on order 3:

Interactive · step by step One order's items array becomes three rows Step through the JSON_TABLE query of the lesson on order 3 and watch one cell turn into rows.
One order's items array becomes three rows, step 4 of 4. Showing orders row 3, Path $.items[*] (3 elements found), JSON_TABLE jt, Result rows. In focus: Result rows.orders row 3delivery holds 3 itemsPath $.items[*]3 elements foundJSON_TABLE jtsku and qty columnsResult rowsone row per element3 rows

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. One document in one cell. Order 3 keeps its delivery details as one JSON value. Its items array holds three objects: A100 times 1, D400 times 3 and E500 times 1. SQL sees a single cell, not three line items.
  2. The path picks the elements. JSON_TABLE(o.delivery, '$.items[]' ...) evaluates the path against the document. The [] selects every element of the array, so the path matches three times.
  3. Columns read each element. For every element, each column cuts out a value with its own path: sku takes '$.sku' as VARCHAR(32), qty takes '$.qty' as INT. A column of FOR ORDINALITY numbers the elements 1, 2, 3.
  4. Rows to join and group. The result is three rows, (3, 1, A100, 1), (3, 2, D400, 3) and (3, 3, E500, 1), each next to the order's id. From here GROUP BY, SUM and joins work as on any table, which is how the lesson totals 15 units of A100.

The run below applies it to the 20 orders. The first query lists the items of orders 1 to 3, six rows for three orders. The second joins every order to its items and groups them: A100 was ordered on 7 lines for 15 units. The rest of the run adds a CHECK constraint built on JSON_SCHEMA_VALID, with a schema that requires a six-digit string for pin_code and a non-empty items array, and then inserts three orders:

JSON_TABLE over the items, then a JSON schema in a CHECK constraint

json_table_session.py

"""JSON_TABLE over the items of 20 orders, then a JSON_SCHEMA_VALID check constraint that refuses two bad documents.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads orders.sql quietly, then runs json_table.sql. Two of its
INSERT statements break the constraint, so the session expects exactly two errors.
"""

from mysql_session import Server

with Server() as my:
    my.up()
    my.mysql("orders.sql", flags=("--skip-comments",))
    my.mysql("json_table.sql", errors=2, flags=("-t", "-vv", "--skip-comments"))

Output

$ mysql --skip-comments < orders.sql

$ mysql -t -vv --skip-comments --force < json_table.sql
--------------
SELECT o.id, jt.line, jt.sku, jt.qty
  FROM orders o,
       JSON_TABLE(o.delivery, '$.items[*]' COLUMNS (line FOR ORDINALITY, sku VARCHAR(32) PATH '$.sku', qty INT PATH '$.qty')) AS jt
 WHERE o.id <= 3
--------------

+----+------+------+------+
| id | line | sku  | qty  |
+----+------+------+------+
|  1 |    1 | A100 |    2 |
|  1 |    2 | B200 |    1 |
|  2 |    1 | C300 |    4 |
|  3 |    1 | A100 |    1 |
|  3 |    2 | D400 |    3 |
|  3 |    3 | E500 |    1 |
+----+------+------+------+
6 rows in set

--------------
SELECT jt.sku, SUM(jt.qty) AS units, COUNT(*) AS order_lines
  FROM orders o,
       JSON_TABLE(o.delivery, '$.items[*]' COLUMNS (sku VARCHAR(32) PATH '$.sku', qty INT PATH '$.qty')) AS jt
 GROUP BY jt.sku ORDER BY units DESC
--------------

+------+-------+-------------+
| sku  | units | order_lines |
+------+-------+-------------+
| A100 |    15 |           7 |
| B200 |    12 |           6 |
| F600 |    12 |           5 |
| C300 |    11 |           5 |
| E500 |    11 |           5 |
| D400 |     8 |           4 |
+------+-------+-------------+
6 rows in set

--------------
ALTER TABLE orders ADD CONSTRAINT delivery_shape CHECK (JSON_SCHEMA_VALID(
  '{"type": "object", "required": ["pin_code", "items"],
    "properties": {"pin_code": {"type": "string", "pattern": "^[0-9]{6}$"},
                   "items": {"type": "array", "minItems": 1}}}', delivery))
--------------

Query OK, 20 rows affected
Records: 20  Duplicates: 0  Warnings: 0

--------------
INSERT INTO orders VALUES (21, '{"pin_code": "411001", "city": "Pune", "items": [{"sku": "A100", "qty": 1}]}')
--------------

Query OK, 1 row affected

--------------
INSERT INTO orders VALUES (22, '{"pin_code": 411001, "city": "Pune", "items": [{"sku": "A100", "qty": 1}]}')
--------------

ERROR 3819 (HY000) at line 18: Check constraint 'delivery_shape' is violated.
--------------
INSERT INTO orders VALUES (23, '{"pin_code": "411001", "city": "Pune", "items": []}')
--------------

ERROR 3819 (HY000) at line 19: Check constraint 'delivery_shape' is violated.
--------------
SELECT COUNT(*) AS orders_now FROM orders
--------------

+------------+
| orders_now |
+------------+
|         21 |
+------------+
1 row in set

Bye

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 json_table_session.py

json_table.sql

USE shop;
-- JSON_TABLE turns the $.items array of each order into rows, one per element.
SELECT o.id, jt.line, jt.sku, jt.qty
  FROM orders o,
       JSON_TABLE(o.delivery, '$.items[*]' COLUMNS (line FOR ORDINALITY, sku VARCHAR(32) PATH '$.sku', qty INT PATH '$.qty')) AS jt
 WHERE o.id <= 3;
-- Once the array is rows, ordinary SQL works on it.
SELECT jt.sku, SUM(jt.qty) AS units, COUNT(*) AS order_lines
  FROM orders o,
       JSON_TABLE(o.delivery, '$.items[*]' COLUMNS (sku VARCHAR(32) PATH '$.sku', qty INT PATH '$.qty')) AS jt
 GROUP BY jt.sku ORDER BY units DESC;
-- A schema in a CHECK constraint keeps malformed documents out. All 20 stored orders already satisfy it.
ALTER TABLE orders ADD CONSTRAINT delivery_shape CHECK (JSON_SCHEMA_VALID(
  '{"type": "object", "required": ["pin_code", "items"],
    "properties": {"pin_code": {"type": "string", "pattern": "^[0-9]{6}$"},
                   "items": {"type": "array", "minItems": 1}}}', delivery));
INSERT INTO orders VALUES (21, '{"pin_code": "411001", "city": "Pune", "items": [{"sku": "A100", "qty": 1}]}');
INSERT INTO orders VALUES (22, '{"pin_code": 411001, "city": "Pune", "items": [{"sku": "A100", "qty": 1}]}');
INSERT INTO orders VALUES (23, '{"pin_code": "411001", "city": "Pune", "items": []}');
SELECT COUNT(*) AS orders_now FROM orders;

orders.sql

-- The 20 orders every JSON session starts from: one JSON document of delivery details per order.
CREATE DATABASE shop;
USE shop;
CREATE TABLE orders (id BIGINT PRIMARY KEY, delivery JSON);
INSERT INTO orders VALUES
  (1, '{"pin_code": "560001", "city": "Bengaluru", "express": true, "items": [{"sku": "A100", "qty": 2}, {"sku": "B200", "qty": 1}]}'),
  (2, '{"pin_code": "560002", "city": "Bengaluru", "express": false, "items": [{"sku": "C300", "qty": 4}]}'),
  (3, '{"pin_code": "400001", "city": "Mumbai", "express": true, "landmark": "Gateway of India", "items": [{"sku": "A100", "qty": 1}, {"sku": "D400", "qty": 3}, {"sku": "E500", "qty": 1}]}'),
  (4, '{"pin_code": "110001", "city": "New Delhi", "express": false, "items": [{"sku": "B200", "qty": 5}]}'),
  (5, '{"pin_code": "600001", "city": "Chennai", "express": true, "items": [{"sku": "F600", "qty": 2}, {"sku": "A100", "qty": 2}]}'),
  (6, '{"pin_code": "700001", "city": "Kolkata", "express": false, "landmark": "Esplanade", "items": [{"sku": "C300", "qty": 1}]}'),
  (7, '{"pin_code": "560001", "city": "Bengaluru", "express": false, "items": [{"sku": "D400", "qty": 2}, {"sku": "E500", "qty": 2}]}'),
  (8, '{"pin_code": "411001", "city": "Pune", "express": true, "items": [{"sku": "A100", "qty": 3}]}'),
  (9, '{"pin_code": "500001", "city": "Hyderabad", "express": false, "items": [{"sku": "B200", "qty": 2}, {"sku": "F600", "qty": 1}]}'),
  (10, '{"pin_code": "380001", "city": "Ahmedabad", "express": false, "items": [{"sku": "E500", "qty": 4}]}'),
  (11, '{"pin_code": "560001", "city": "Bengaluru", "express": true, "items": [{"sku": "C300", "qty": 2}, {"sku": "A100", "qty": 1}, {"sku": "B200", "qty": 1}]}'),
  (12, '{"pin_code": "400001", "city": "Mumbai", "express": false, "items": [{"sku": "F600", "qty": 6}]}'),
  (13, '{"pin_code": "110001", "city": "New Delhi", "express": true, "landmark": "India Gate", "items": [{"sku": "D400", "qty": 1}, {"sku": "A100", "qty": 1}]}'),
  (14, '{"pin_code": "600001", "city": "Chennai", "express": false, "items": [{"sku": "E500", "qty": 3}]}'),
  (15, '{"pin_code": "302001", "city": "Jaipur", "express": false, "items": [{"sku": "B200", "qty": 1}, {"sku": "C300", "qty": 1}]}'),
  (16, '{"pin_code": "560002", "city": "Bengaluru", "express": true, "items": [{"sku": "A100", "qty": 5}]}'),
  (17, '{"pin_code": "700001", "city": "Kolkata", "express": true, "items": [{"sku": "D400", "qty": 2}, {"sku": "F600", "qty": 2}]}'),
  (18, '{"pin_code": "411001", "city": "Pune", "express": false, "items": [{"sku": "C300", "qty": 3}]}'),
  (19, '{"pin_code": "500001", "city": "Hyderabad", "express": true, "items": [{"sku": "E500", "qty": 1}, {"sku": "B200", "qty": 2}]}'),
  (20, '{"pin_code": "560001", "city": "Bengaluru", "express": false, "items": [{"sku": "F600", "qty": 1}]}');

Adding the constraint reported Records: 20 because the server checks the orders already stored; an order that broke the schema would have made the ALTER TABLE fail with the same error and left the table unchanged. Order 21 is valid and is stored. Order 22 fails with error 3819 because its pin_code is the JSON number 411001, not the string "411001", and order 23 fails because its items array is empty. The schema is written inline in the constraint because a CHECK cannot refer to a variable.

Documents from rows: duality views

JSON_TABLE goes from a document to rows. The opposite direction, rows served as documents, is what JSON duality views do in MySQL 26.7: stored queries that produce JSON documents. The 8.4 server of this lesson has no such view, so nothing here is recorded; the 26.7 manual in the references describes them.

Checkpoint

Check: JSON_TABLE and schema checks

  1. Question 1 of 2 Orders 1, 2 and 3 hold 2, 1 and 3 items. How many rows does the first JSON_TABLE query of the run return for these three orders?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 6 rows

    JSON_TABLE returns one row for each element the path '$.items[*]' selects: 2 + 1 + 3 = 6, which is the 6 rows in set of the run.

  2. Question 2 of 2 Order 22 is {"pin_code": 411001, ...} and its insert fails with error 3819, although the six digits are right. Why does the constraint refuse it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The schema asks for "type": "string" and 411001 without quotes is a JSON number

    The schema requires pin_code to be a string of six digits. A number fails the type test, and the pattern keyword would not have caught it, because a pattern applies only to strings. A value that looks right to a person can still break the shape.

Generated columns put an index behind a JSON field

A JSON column cannot be indexed as it is. The manual’s way in is a generated column: A column whose value is an expression of other columns. A VIRTUAL one is computed when the row is read and takes no row space; a STORED one is computed on insert and update and kept in the row., a column whose value is an expression of other columns, with a secondary index: Any InnoDB index other than the clustered one. Each of its entries holds the indexed columns plus the primary key of the row, which it uses to search the clustered index when a query needs the rest of the row. More in “InnoDB clustering: tables stored by primary key” on it. The index then does for one JSON field what the previous lesson’s indexes did for a plain column; Secondary, composite and covering indexes covers how such an index is read. The figure follows one order from the insert to an index lookup:

Interactive · stage by stage From a JSON document to an index entry Step through one order from its insert to the lookup that uses the index on pin_code.
From a JSON document to an index entry, step 5 of 5. Showing Binary JSON document, Path lookup, VIRTUAL expression (560001 as text), Index entry (one per order), Query by pin_code (250 of 2000 entries). In focus: Query by pin_code.Binary JSON documentkeys sorted for lookupPath lookupkey pin_code, no text parseVIRTUAL expression560001 as textIndex entryone per orderQuery by pin_code250 of 2000 entries

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The document is stored. An order is inserted. Its delivery value is converted to MySQL's binary JSON format and written into the row. Nothing about pin_code is stored separately.
  2. A key is found directly. To get pin_code, the server goes to that key in the binary document. The manual says the format lets it look up nested values by key without reading the values before or after.
  3. The expression runs. pin_code is declared VIRTUAL AS (delivery->>'$.pin_code'). The ->> operator unquotes the JSON string, so the value is the text 560001. The column is not stored in the row; it is computed when needed.
  4. The index keeps the result. Because idx_pin is on pin_code, InnoDB evaluates the expression on every insert and update and writes (560001, order id) into the index. That is the write cost of the index; the row itself stays the same size.
  5. A lookup skips the documents. WHERE pin_code = '560001' reads the matching entries of idx_pin: the recorded plan expects 250 rows of the 2,000. The other 1,750 documents are never opened, which is the whole point.

The next run uses 2,000 orders, 250 for each of eight pin codes. The first plan has no index: type ALL, 2000 rows. Then a VIRTUAL column pin_code is added with ALGORITHM=INSTANT, which only changes the table definition, and indexed. Both the query on pin_code and the query that spells out delivery->>'$.pin_code' now use idx_pin and expect 250 rows. The last two statements add a STORED column:

A virtual column with an index, and what a stored column costs

generated_session.py

"""VIRTUAL and STORED generated columns over a JSON document: an index on a virtual column, and the cost of a stored one.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads orders_2000.sql (2,000 orders made by arithmetic, no RAND()), then
runs generated.sql. One ALTER TABLE asks for the INPLACE algorithm on a STORED column and is refused, so the session
expects one error.
"""

from mysql_session import Server

with Server() as my:
    my.up()
    my.mysql("orders_2000.sql", flags=("--skip-comments",))
    my.mysql("generated.sql", errors=1, flags=("-t", "-vv", "--skip-comments"))

Output

$ mysql --skip-comments < orders_2000.sql
Table	Op	Msg_type	Msg_text
shop.orders	analyze	status	OK

$ mysql -t -vv --skip-comments --force < generated.sql
--------------
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001'
--------------

+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table  | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 2000 |   100.00 | Using where |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning

--------------
ALTER TABLE orders ADD COLUMN pin_code VARCHAR(10) AS (delivery->>'$.pin_code') VIRTUAL, ALGORITHM=INSTANT
--------------

Query OK, 0 rows affected
Records: 0  Duplicates: 0  Warnings: 0

--------------
ALTER TABLE orders ADD INDEX idx_pin (pin_code)
--------------

Query OK, 0 rows affected
Records: 0  Duplicates: 0  Warnings: 0

--------------
EXPLAIN SELECT id FROM orders WHERE pin_code = '560001'
--------------

+----+-------------+--------+------------+------+---------------+---------+---------+-------+------+----------+-------------+
| id | select_type | table  | partitions | type | possible_keys | key     | key_len | ref   | rows | filtered | Extra       |
+----+-------------+--------+------------+------+---------------+---------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | orders | NULL       | ref  | idx_pin       | idx_pin | 43      | const |  250 |   100.00 | Using index |
+----+-------------+--------+------------+------+---------------+---------+---------+-------+------+----------+-------------+
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001'
--------------

+----+-------------+--------+------------+------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table  | partitions | type | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
+----+-------------+--------+------------+------+---------------+---------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | orders | NULL       | ref  | idx_pin       | idx_pin | 43      | const |  250 |   100.00 | NULL  |
+----+-------------+--------+------------+------+---------------+---------+---------+-------+------+----------+-------+
1 row in set, 1 warning

--------------
ALTER TABLE orders ADD COLUMN city_name VARCHAR(40) AS (delivery->>'$.city') STORED, ALGORITHM=INPLACE
--------------

ERROR 1845 (0A000) at line 10: ALGORITHM=INPLACE is not supported for this operation. Try ALGORITHM=COPY.
--------------
ALTER TABLE orders ADD COLUMN city_name VARCHAR(40) AS (delivery->>'$.city') STORED
--------------

Query OK, 2000 rows affected
Records: 2000  Duplicates: 0  Warnings: 0

--------------
SELECT COLUMN_NAME, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'orders' ORDER BY ORDINAL_POSITION
--------------

+-------------+-------------------+
| COLUMN_NAME | EXTRA             |
+-------------+-------------------+
| id          |                   |
| delivery    |                   |
| pin_code    | VIRTUAL GENERATED |
| city_name   | STORED GENERATED  |
+-------------+-------------------+
4 rows in set

Bye

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 generated_session.py

generated.sql

USE shop;
-- Without an index on the pin code, every lookup reads all 2,000 documents.
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001';
-- A VIRTUAL generated column holds no data in the row: adding it only changes the table definition.
ALTER TABLE orders ADD COLUMN pin_code VARCHAR(10) AS (delivery->>'$.pin_code') VIRTUAL, ALGORITHM=INSTANT;
ALTER TABLE orders ADD INDEX idx_pin (pin_code);
EXPLAIN SELECT id FROM orders WHERE pin_code = '560001';
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001';
-- A STORED column writes its value into every row, so the server has to rebuild the table.
ALTER TABLE orders ADD COLUMN city_name VARCHAR(40) AS (delivery->>'$.city') STORED, ALGORITHM=INPLACE;
ALTER TABLE orders ADD COLUMN city_name VARCHAR(40) AS (delivery->>'$.city') STORED;
SELECT COLUMN_NAME, EXTRA FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = 'shop' AND TABLE_NAME = 'orders' ORDER BY ORDINAL_POSITION;

orders_2000.sql

-- 2,000 orders made by arithmetic (no RAND()): the same documents on every run. Eight pin codes, 250 orders each.
CREATE DATABASE shop;
USE shop;
CREATE TABLE orders (id BIGINT PRIMARY KEY, delivery JSON);
SET SESSION cte_max_recursion_depth = 2000;
INSERT INTO orders
WITH RECURSIVE n (i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000)
SELECT i, JSON_OBJECT(
         'pin_code', ELT(1 + (i * 7) % 8, '560001', '560002', '400001', '110001', '600001', '700001', '411001', '500001'),
         'city', ELT(1 + (i * 7) % 8, 'Bengaluru', 'Bengaluru', 'Mumbai', 'New Delhi', 'Chennai', 'Kolkata', 'Pune', 'Hyderabad'),
         'items', JSON_ARRAY(JSON_OBJECT('sku', CONCAT('A', 100 + i % 5), 'qty', 1 + i % 4)))
  FROM n;
-- The optimizer plans from index statistics; refresh them so the plans below do not depend on timing.
ANALYZE TABLE orders;

Asking for ALGORITHM=INPLACE on the STORED column fails with error 1845 and the hint to use COPY; without the clause the server copies the table, and the client reports 2000 rows affected. The online DDL table in the manual agrees: adding a VIRTUAL column is instant, and adding a STORED one rebuilds the table and does not allow concurrent changes.

VIRTUAL (the default) STORED
Value in the row Not stored; computed when the row is read Computed on insert and update, kept in the row
Adding the column Instant, metadata only Rebuilds the table (algorithm COPY)
Index on it Allowed; the value is kept only in the index Allowed; the value is also in the clustered index
Cost of an index Extra work on every insert and update of the row The same, plus a larger table and more memory

Expecting a stored column to index itself

A STORED column takes space in every row but is not indexed unless you add an index. The manual says an index on a VIRTUAL column may be preferable to a stored column, because the stored value makes the table larger.

A functional index gets the same result without a visible column. The catch is the collation: A set of rules for comparing and sorting strings. It decides whether "A" equals "a", whether "é" equals "e", and in what order strings sort. More in “Character sets, collations and time zones”, which the next run shows: ->> returns text in utf8mb4_bin, while CAST(... AS CHAR(10)) gives the server default utf8mb4_0900_ai_ci, so the optimizer does not treat the two expressions as one.

A functional index on a JSON field and the collation that decides

functional_session.py

"""A functional index on a JSON field: when the optimizer uses it, and the collation that decides.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads orders_2000.sql, then runs functional.sql.
"""

from mysql_session import Server

with Server() as my:
    my.up()
    my.mysql("orders_2000.sql", flags=("--skip-comments",))
    my.mysql("functional.sql", flags=("-t", "-vv", "--skip-comments"))

Output

$ mysql --skip-comments < orders_2000.sql
Table	Op	Msg_type	Msg_text
shop.orders	analyze	status	OK

$ mysql -t -vv --skip-comments < functional.sql
--------------
CREATE INDEX idx_pin_fn ON orders ((CAST(delivery->>'$.pin_code' AS CHAR(10))))
--------------

Query OK, 0 rows affected
Records: 0  Duplicates: 0  Warnings: 0

--------------
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001'
--------------

+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table  | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 2000 |   100.00 | Using where |
+----+-------------+--------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM orders WHERE CAST(delivery->>'$.pin_code' AS CHAR(10)) = '560001'
--------------

+----+-------------+--------+------------+------+---------------+------------+---------+-------+------+----------+-------+
| id | select_type | table  | partitions | type | possible_keys | key        | key_len | ref   | rows | filtered | Extra |
+----+-------------+--------+------------+------+---------------+------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | orders | NULL       | ref  | idx_pin_fn    | idx_pin_fn | 43      | const |  250 |   100.00 | NULL  |
+----+-------------+--------+------------+------+---------------+------------+---------+-------+------+----------+-------+
1 row in set, 1 warning

--------------
DROP INDEX idx_pin_fn ON orders
--------------

Query OK, 0 rows affected
Records: 0  Duplicates: 0  Warnings: 0

--------------
CREATE INDEX idx_pin_fn ON orders ((CAST(delivery->>'$.pin_code' AS CHAR(10)) COLLATE utf8mb4_bin))
--------------

Query OK, 0 rows affected
Records: 0  Duplicates: 0  Warnings: 0

--------------
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001'
--------------

+----+-------------+--------+------------+------+---------------+------------+---------+-------+------+----------+-------+
| id | select_type | table  | partitions | type | possible_keys | key        | key_len | ref   | rows | filtered | Extra |
+----+-------------+--------+------------+------+---------------+------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | orders | NULL       | ref  | idx_pin_fn    | idx_pin_fn | 43      | const |  250 |   100.00 | NULL  |
+----+-------------+--------+------------+------+---------------+------------+---------+-------+------+----------+-------+
1 row in set, 1 warning

Bye

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 functional_session.py

functional.sql

USE shop;
-- A functional index stores the value of an expression. ->> returns LONGTEXT, which cannot be indexed whole, so cast it.
CREATE INDEX idx_pin_fn ON orders ((CAST(delivery->>'$.pin_code' AS CHAR(10))));
-- The index is on a CAST string with the connection's collation, but ->> yields a utf8mb4_bin string: no match.
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001';
-- Writing the indexed expression in the query matches it exactly.
EXPLAIN SELECT id FROM orders WHERE CAST(delivery->>'$.pin_code' AS CHAR(10)) = '560001';
-- Or give the index the collation that ->> produces, and the plain query matches.
DROP INDEX idx_pin_fn ON orders;
CREATE INDEX idx_pin_fn ON orders ((CAST(delivery->>'$.pin_code' AS CHAR(10)) COLLATE utf8mb4_bin));
EXPLAIN SELECT id FROM orders WHERE delivery->>'$.pin_code' = '560001';

orders_2000.sql

-- 2,000 orders made by arithmetic (no RAND()): the same documents on every run. Eight pin codes, 250 orders each.
CREATE DATABASE shop;
USE shop;
CREATE TABLE orders (id BIGINT PRIMARY KEY, delivery JSON);
SET SESSION cte_max_recursion_depth = 2000;
INSERT INTO orders
WITH RECURSIVE n (i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 2000)
SELECT i, JSON_OBJECT(
         'pin_code', ELT(1 + (i * 7) % 8, '560001', '560002', '400001', '110001', '600001', '700001', '411001', '500001'),
         'city', ELT(1 + (i * 7) % 8, 'Bengaluru', 'Bengaluru', 'Mumbai', 'New Delhi', 'Chennai', 'Kolkata', 'Pune', 'Hyderabad'),
         'items', JSON_ARRAY(JSON_OBJECT('sku', CONCAT('A', 100 + i % 5), 'qty', 1 + i % 4)))
  FROM n;
-- The optimizer plans from index statistics; refresh them so the plans below do not depend on timing.
ANALYZE TABLE orders;

The index on the plain CAST is used only by the query that repeats the CAST; the plain ->> query stays at type ALL. Recreating the index with COLLATE utf8mb4_bin makes the plain query use it, with ref and 250 rows. A function that is in the index but spelled differently in the query is a missed index, so always check the plan. The two fixes are not the same query, either: the manual points out that ->> compares case-sensitively, while the CAST form follows the default collation and ignores case, so the two can return different rows.

Key idea

Index a JSON field through a column or an expression that the query repeats exactly, collation included, and prefer a VIRTUAL column with an index to a STORED one unless the table is small or nearly every query reads the value.

Checkpoint

Check: Generated columns and indexes on JSON

  1. Question 1 of 3 You need an index for WHERE pin_code = '560001' on the JSON column delivery and do not want the pin code stored in every row. What do you create?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: A VIRTUAL generated column on delivery->>'$.pin_code', and an ordinary index on that column

    Index a JSON field through a generated column or a functional key part. A VIRTUAL column avoids a copy of the value in every row, and adding it is instant.

  2. Question 2 of 3 ALTER TABLE ... ADD COLUMN city_name ... STORED, ALGORITHM=INPLACE fails with error 1845 in the run. What does the statement need instead?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: The default algorithm, which copies the whole table (2000 rows affected in the run)

    The VIRTUAL column was added without touching rows. The STORED one needs its value computed and written for each of the 2,000 rows, so the server copies the table.

  3. Question 3 of 3 The index is on CAST(delivery->>'$.pin_code' AS CHAR(10)). The query WHERE delivery->>'$.pin_code' = '560001' shows type ALL. What is the cause?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: The CAST expression has the collation utf8mb4_0900_ai_ci and ->> yields utf8mb4_bin, so the expressions differ

    The comparison must see the same expression and collation as the index. Either repeat the indexed expression in the query, or give the index the collation that ->> produces.

A multi-valued index finds rows by one array element

A tags array is different from a pin code: one row holds several values, and a query asks whether one of them is 'promo'. The multi-valued index: An InnoDB secondary index on the values of a JSON array, made with CAST(... AS type ARRAY): one index entry per element. MEMBER OF, JSON_CONTAINS and JSON_OVERLAPS can use it. gives such a row one index entry for every element. InnoDB builds it from a cast that ends in ARRAY, and the optimizer uses it for three functions: MEMBER OF, JSON_CONTAINS and JSON_OVERLAPS. Step through product 1 of the next run:

Interactive · step by step One product, four index entries Step through how the tags array of product 1 is indexed and how a search for promo finds it.
One product, four index entries, step 4 of 4. Showing items row 1, idx_tags entries, 'promo' MEMBER OF, Row by id. In focus: Row by id.items row 1one tags arrayidx_tags entriesone entry per tag'promo' MEMBER OFone index lookupRow by idre-checked by WHERE63 ids

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. One array, four tags. Product 1 has the tags fragile, promo, winter and festive in a single JSON array. An ordinary index would need one value per row. This row has four.
  2. Four entries, one row. The multi-valued index writes one entry for every tag, and all four point at product 1. In the lesson's table, 1,000 products with 2 to 4 tags each produce 3,007 entries.
  3. A search probes one tag. 'promo' MEMBER OF (tags) looks up the single value promo. The recorded plan is a ref access on idx_tags with an estimate of 63 rows, instead of reading all 1,000 documents.
  4. Entries lead back to rows. Each entry holds the product's id, so the server fetches those rows and the WHERE clause checks each document again (Using where). The count is 63 products, the same as the estimate.

The session makes 1,000 products with random.Random(42), each with 2 to 4 tags out of a pool of 40, and creates the index after the first plan.

A multi-valued index on a JSON array of tags

mvi_session.py

"""A multi-valued index on a JSON array of tags: 1,000 products, three ways to ask, and the plan of each.

The session makes the data itself. random.Random(42) gives every product 2 to 4 tags from a pool of forty, and the
INSERT statements go to items_load.sql, which a throwaway MySQL 8.4.11 server (mysql_session.py) runs quietly. Then
mvi.sql creates the index and shows the plans. The tags never change from run to run.
"""

import json
import random

from mysql_session import Server

POOL = ["sale", "promo", "new", "eco", "gift", "bulk", "import", "fragile", "vegan", "handmade",
        "cotton", "steel", "wood", "glass", "organic", "festive", "summer", "winter", "kids", "office",
        "travel", "kitchen", "garden", "sports", "pet", "premium", "budget", "refurbished", "combo", "limited",
        "seasonal", "local", "halal", "recyclable", "monsoon", "diwali", "wedding", "school", "outdoor", "compact"]

rng = random.Random(42)
rows = [(i, json.dumps(rng.sample(POOL, rng.randint(2, 4)))) for i in range(1, 1001)]
load = ["CREATE DATABASE shop;", "USE shop;", "CREATE TABLE items (id BIGINT PRIMARY KEY, tags JSON);"]
load += [f"INSERT INTO items VALUES {', '.join(f'({i}, {chr(39)}{tags}{chr(39)})' for i, tags in rows[k:k + 100])};"
         for k in range(0, 1000, 100)]
load.append("ANALYZE TABLE items;")

with Server() as my:
    my.up()
    my.write_file("items_load.sql", "\n".join(load) + "\n", show=False)
    my.mysql("items_load.sql", flags=("--skip-comments",))
    my.mysql("mvi.sql", flags=("-t", "-v", "--skip-comments"))

Output

$ mysql --skip-comments < items_load.sql
Table	Op	Msg_type	Msg_text
shop.items	analyze	status	OK

$ mysql -t -v --skip-comments < mvi.sql
--------------
EXPLAIN SELECT * FROM items WHERE 'promo' MEMBER OF (tags)
--------------

+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | items | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 1000 |   100.00 | Using where |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+-------------+
--------------
CREATE INDEX idx_tags ON items ((CAST(tags AS CHAR(16) ARRAY)))
--------------

--------------
EXPLAIN SELECT * FROM items WHERE 'promo' MEMBER OF (tags)
--------------

+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key      | key_len | ref   | rows | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+
|  1 | SIMPLE      | items | NULL       | ref  | idx_tags      | idx_tags | 67      | const |   63 |   100.00 | Using where |
+----+-------------+-------+------------+------+---------------+----------+---------+-------+------+----------+-------------+
--------------
EXPLAIN SELECT * FROM items WHERE JSON_CONTAINS(tags, '["promo", "sale"]')
--------------

+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys | key      | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | items | NULL       | range | idx_tags      | idx_tags | 67      | NULL |  125 |   100.00 | Using where |
+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
--------------
EXPLAIN SELECT * FROM items WHERE JSON_OVERLAPS(tags, '["promo", "sale"]')
--------------

+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
| id | select_type | table | partitions | type  | possible_keys | key      | key_len | ref  | rows | filtered | Extra       |
+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | items | NULL       | range | idx_tags      | idx_tags | 67      | NULL |  125 |   100.00 | Using where |
+----+-------------+-------+------------+-------+---------------+----------+---------+------+------+----------+-------------+
--------------
SELECT SUM(JSON_LENGTH(tags)) AS tag_entries,
       SUM('promo' MEMBER OF (tags)) AS has_promo,
       SUM(JSON_CONTAINS(tags, '["promo", "sale"]')) AS has_both,
       SUM(JSON_OVERLAPS(tags, '["promo", "sale"]')) AS has_either
  FROM items
--------------

+-------------+-----------+----------+------------+
| tag_entries | has_promo | has_both | has_either |
+-------------+-----------+----------+------------+
|        3007 |        63 |        3 |        122 |
+-------------+-----------+----------+------------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mvi_session.py

mvi.sql

USE shop;
-- Without an index, every product's tag array is read.
EXPLAIN SELECT * FROM items WHERE 'promo' MEMBER OF (tags);
-- One index entry for every tag of every product.
CREATE INDEX idx_tags ON items ((CAST(tags AS CHAR(16) ARRAY)));
EXPLAIN SELECT * FROM items WHERE 'promo' MEMBER OF (tags);
EXPLAIN SELECT * FROM items WHERE JSON_CONTAINS(tags, '["promo", "sale"]');
EXPLAIN SELECT * FROM items WHERE JSON_OVERLAPS(tags, '["promo", "sale"]');
-- The three functions answer different questions on the same index.
SELECT SUM(JSON_LENGTH(tags)) AS tag_entries,
       SUM('promo' MEMBER OF (tags)) AS has_promo,
       SUM(JSON_CONTAINS(tags, '["promo", "sale"]')) AS has_both,
       SUM(JSON_OVERLAPS(tags, '["promo", "sale"]')) AS has_either
  FROM items;

Before the index, the MEMBER OF plan is ALL over 1000 rows. After it, 'promo' MEMBER OF (tags) is a ref on idx_tags with 63 rows, and the count agrees: 63 products have the tag. JSON_CONTAINS and JSON_OVERLAPS with two tags become range accesses, which is the name EXPLAIN gives to probing several separate values: one lookup for each of the two tags, estimated at 125 rows together, with Using where to check each document again. The functions answer different questions on the same index: 3 products carry both promo and sale (JSON_CONTAINS), and 122 carry at least one (JSON_OVERLAPS).

Index on a scalar field Multi-valued index on an array
Entries per row One One per array element; none for an empty array
Built from A generated column or a functional key part CAST(path AS type ARRAY)
Functions that use it =, ranges, ORDER BY MEMBER OF, JSON_CONTAINS, JSON_OVERLAPS
Covering index Possible Never
Building it online Possible Not online: writes have to wait while it is built

The limits matter for writes and plans. A row with eight tags writes eight entries on insert and again when its array changes. The manual states that such an index cannot be a covering index and supports neither range scans over a span of key values nor index-only scans, which is why the plan above says Using where, never Using index. An index on a scalar field and one on an array are therefore not interchangeable: use a generated column for $.pin_code and a multi-valued index for $.tags.

Checkpoint

Check: Multi-valued indexes on arrays

  1. Question 1 of 2 Which condition can use the multi-valued index idx_tags on the JSON array tags?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 'promo' MEMBER OF (tags)

    A multi-valued index holds one entry per array element, so it answers questions about membership: is this value in the array (MEMBER OF), are all of these (JSON_CONTAINS), is any of these (JSON_OVERLAPS).

  2. Question 2 of 2 A product has the tags array []. How many entries does it add to idx_tags?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: None, so no search through the index can find that product

    With 3,007 tags in 1,000 products the index has 3,007 entries; a product with no tags adds none. That is also why "products without a tag" is not a question this index can answer.

Partial updates write a few bytes to the binary log

Without partial updates, changing one field of a large document means writing the whole value, in the table and in the log. An update like the one below meets the manual’s conditions for changing the stored value in place: the column is the same on both sides, the function is JSON_SET, JSON_REPLACE or JSON_REMOVE, it replaces a value that already exists instead of adding a key, and the new value is no larger than the old one. The binary log has its own switch, binlog_row_value_options. With the value PARTIAL_JSON a row-based event carries only the change. This server starts with that option and with binlog_row_image=MINIMAL, the pair the manual says saves the most space:

Interactive · message by message The same JSON_SET, logged two ways Step through two updates of one field in a 661-byte document and see what each writes to the binary log.
The same JSON_SET, logged two ways, step 5 of 5. Messages so far: Client to InnoDB table: JSON_SET pin_code; InnoDB table to Binary log: partial: 73 bytes; InnoDB table to Binary log: whole: 711 bytes.ClientInnoDB table661 bytes, in placeBinary log711 bytesJSON_SET pin_codepartial: 73 byteswhole: 711 bytes

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. One field changes. The client sends UPDATE orders SET delivery = JSON_SET(delivery, '$.pin_code', '560002'). Only the 6-character pin code changes in a 661-byte document; the rest is a 600-character note.
  2. The row can change in place. The statement meets the conditions for a partial update: same column in and out, JSON_SET as the function, a value that already exists replaced, and a replacement no larger than the old value. The stored document is still 661 bytes.
  3. With PARTIAL_JSON, a diff. The server was started with binlog_row_value_options=PARTIAL_JSON, so the after-image holds only the change, logged as JSON_REPLACE(@2, '$.pin_code', '560002'). The event, Update_rows_partial, is 73 bytes.
  4. Without it, the whole document. With the option set to an empty string for the session, the same update logs the whole new document in the after-image. The event, Update_rows, is 711 bytes: nearly ten times larger for the same change.
  5. Two different savings. A partial update of the stored value and a partial entry in the binary log are separate things. The log format needs row-based or mixed logging, and a plain assignment of the column is logged whole.

The run stores two orders with the same 600-character note, updates the pin code of order 1 with the option on and of order 2 with the option switched off for the session, and reads the log with mysqlbinlog -vv:

The same JSON_SET in the binary log, partial and whole

partial_json_session.py

"""Partial JSON updates in the binary log: the same JSON_SET logged as a few bytes and as the whole document.

A throwaway MySQL 8.4.11 server (mysql_session.py) starts with --binlog-row-value-options=PARTIAL_JSON and
--binlog-row-image=MINIMAL, the pair that saves the most. partial_json.sql updates one order with the option on and one
with it switched off for the session. Then mysqlbinlog -vv reads the binary log and the session prints the lines of the
two update events, as grep and cut would, and the size of each event. Time stamps and checksums change from run to run
and so do the positions in the log (what the server wrote before depends on the run), so they are masked; the size of
an event, the difference of its two positions, does not change.
"""

import re

from mysql_session import Server

with Server(options=("--binlog-row-value-options=PARTIAL_JSON", "--binlog-row-image=MINIMAL")) as my:
    my.mask(r"#\d{6}\s+\d{1,2}:\d\d:\d\d", "#<date> <time>")
    my.mask(r"CRC32 0x[0-9a-f]+", "CRC32 <crc>")
    my.mask(r"table id \d+", "table id <n>")
    my.up()
    my.mysql("partial_json.sql", flags=("-t", "-v", "--skip-comments"))
    log = my.output("mysqlbinlog", "-vv", "--base64-output=DECODE-ROWS", "./data/binlog.000001").splitlines()
    print("$ mysqlbinlog -vv --base64-output=DECODE-ROWS data/binlog.000001 | grep -B1 -A5 Update_rows | cut -c1-100")
    sizes = []
    for i, line in enumerate(log):
        if re.search(r"\tUpdate_rows", line):
            start = int(re.search(r"# at (\d+)", log[i - 1]).group(1))
            end = int(re.search(r"end_log_pos (\d+)", line).group(1))
            sizes.append((re.search(r"\t(Update_rows\w*)", line).group(1), end - start))
            print("# at <position>")
            print(re.sub(r"end_log_pos \d+", "end_log_pos <position>", line)[:100])
            for shown in log[i + 1:i + 6]:
                print(shown[:100])
            print("--")
    print()
    print("$ # the size of an event is its end_log_pos minus its \"# at\" position")
    for name, size in sizes:
        print(f"{name}: {size} bytes")

Output

$ mysql -t -v --skip-comments < partial_json.sql
--------------
CREATE DATABASE shop
--------------

--------------
CREATE TABLE orders (id BIGINT PRIMARY KEY, delivery JSON)
--------------

--------------
INSERT INTO orders VALUES
  (1, JSON_OBJECT('pin_code', '560001', 'city', 'Bengaluru', 'note', REPEAT('deliver after 6 pm; ', 30))),
  (2, JSON_OBJECT('pin_code', '560001', 'city', 'Bengaluru', 'note', REPEAT('deliver after 6 pm; ', 30)))
--------------

--------------
SHOW VARIABLES WHERE Variable_name IN ('binlog_format', 'binlog_row_image', 'binlog_row_value_options')
--------------

+--------------------------+--------------+
| Variable_name            | Value        |
+--------------------------+--------------+
| binlog_format            | ROW          |
| binlog_row_image         | MINIMAL      |
| binlog_row_value_options | PARTIAL_JSON |
+--------------------------+--------------+
--------------
UPDATE orders SET delivery = JSON_SET(delivery, '$.pin_code', '560002') WHERE id = 1
--------------

--------------
SET SESSION binlog_row_value_options = ''
--------------

--------------
UPDATE orders SET delivery = JSON_SET(delivery, '$.pin_code', '560002') WHERE id = 2
--------------

--------------
SELECT id, delivery->>'$.pin_code' AS pin_code, JSON_STORAGE_SIZE(delivery) AS bytes FROM orders
--------------

+----+----------+-------+
| id | pin_code | bytes |
+----+----------+-------+
|  1 | 560002   |   661 |
|  2 | 560002   |   661 |
+----+----------+-------+

$ mysqlbinlog -vv --base64-output=DECODE-ROWS data/binlog.000001 | grep -B1 -A5 Update_rows | cut -c1-100
# at <position>
#<date> <time> server id 1  end_log_pos <position> CRC32 <crc> 	Update_rows_partial: table id <n> fl
### UPDATE `shop`.`orders`
### WHERE
###   @1=1 /* LONGINT meta=0 nullable=0 is_null=0 */
### SET
###   @2=JSON_REPLACE(@2, '$.pin_code', '560002') /* JSON meta=4 nullable=1 is_null=0 */
--
# at <position>
#<date> <time> server id 1  end_log_pos <position> CRC32 <crc> 	Update_rows: table id <n> flags: STM
### UPDATE `shop`.`orders`
### WHERE
###   @1=2 /* LONGINT meta=0 nullable=0 is_null=0 */
### SET
###   @2='{"city": "Bengaluru", "note": "deliver after 6 pm; deliver after 6 pm; deliver after 6 pm;
--

$ # the size of an event is its end_log_pos minus its "# at" position
Update_rows_partial: 73 bytes
Update_rows: 711 bytes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 partial_json_session.py

partial_json.sql

CREATE DATABASE shop;
USE shop;
CREATE TABLE orders (id BIGINT PRIMARY KEY, delivery JSON);
-- Two orders with the same 600-character note, so the document is much larger than the field that changes.
INSERT INTO orders VALUES
  (1, JSON_OBJECT('pin_code', '560001', 'city', 'Bengaluru', 'note', REPEAT('deliver after 6 pm; ', 30))),
  (2, JSON_OBJECT('pin_code', '560001', 'city', 'Bengaluru', 'note', REPEAT('deliver after 6 pm; ', 30)));
SHOW VARIABLES WHERE Variable_name IN ('binlog_format', 'binlog_row_image', 'binlog_row_value_options');
-- Order 1: the server was started with binlog_row_value_options=PARTIAL_JSON.
UPDATE orders SET delivery = JSON_SET(delivery, '$.pin_code', '560002') WHERE id = 1;
-- Order 2: the same change with the option switched off for this session.
SET SESSION binlog_row_value_options = '';
UPDATE orders SET delivery = JSON_SET(delivery, '$.pin_code', '560002') WHERE id = 2;
SELECT id, delivery->>'$.pin_code' AS pin_code, JSON_STORAGE_SIZE(delivery) AS bytes FROM orders;

Both orders end with a 661-byte document and the pin code 560002. The log differs. The partial event, printed as Update_rows_partial, is 73 bytes and shows the change as JSON_REPLACE(@2, '$.pin_code', '560002'). The ordinary Update_rows event is 711 bytes because it holds the whole new document. That is 638 bytes saved for one small change, and it grows with the size of the document and the number of such updates a replica has to receive. With the default binlog_row_image=FULL, the before-image still holds the whole document, so the saving is smaller.

The two kinds of partial update are separate. The manual says an update can still reach the log as a partial change when the stored value cannot be changed in place, because the new value is larger or adds a key. A plain SET delivery = '{...}' is neither kind, and the log option has no effect under statement-based logging, which logs only the changed parts of a document anyway.

Checkpoint

Check: Partial JSON updates and the binary log

  1. Question 1 of 2 The partial event is 73 bytes and the whole-document event is 711 bytes. How many bytes does PARTIAL_JSON save for this one update?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 638 bytes

    711 - 73 = 638 bytes, for a change to a 6-character field of a 661-byte document.

  2. Question 2 of 2 Which update can the server log as a partial JSON update?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: UPDATE orders SET delivery = JSON_SET(delivery, '$.pin_code', '560002') WHERE id = 1

    Partial updates need JSON_SET, JSON_REPLACE or JSON_REMOVE applied to the target column itself, replacing values with ones that are no larger. Anything that rebuilds the document is logged whole.

Full-text search ranks words, and the parser decides what a word is

LIKE '%wireless%' cannot use an index and cannot rank. An InnoDB full-text index: An InnoDB index that lists the words of character columns with the rows that hold them; MATCH ... AGAINST searches it in natural-language or boolean mode, and a parser decides what a word is. keeps a list of words with the rows that contain them, and MATCH (...) AGAINST (...) reads that list. The figure follows one product from its text to a ranked result:

Interactive · stage by stage From product text to a ranked search result Step through one product's text, the words the index keeps, and a search that ranks the rows.
From product text to a ranked search result, step 6 of 6. Showing Row text, Parser (ngram: 2-character pieces), Tokens kept (tv, the: dropped), Inverted index (wireless: 37 rows), MATCH ... AGAINST. In focus: Parser.Row textname and descriptionParserngram: 2-character piecesTokens kepttv, the: droppedInverted indexwireless: 37 rowsMATCH ... AGAINSTscores the rows

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A product is inserted. Product 87 is Konkan wireless headphones, with a description. Its name and description are the columns of the FULLTEXT index, so both are indexed together.
  2. The parser cuts tokens. The built-in parser splits at spaces and punctuation: konkan, wireless, headphones and the words of the description. A Japanese sentence has no spaces, so this parser keeps it as one long word.
  3. Short words and stopwords go. Tokens shorter than innodb_ft_min_token_size (3) are dropped, so tv from a TV mount is never indexed; a search for tv found none of the 19 products whose name contains TV. The 36 default stopwords, such as the, are dropped too.
  4. Words point at rows. The index maps each word to the rows that contain it, with a count per row. The word wireless leads to 37 of the 300 products, so a search reads one short list instead of 300 rows.
  5. The search scores rows. MATCH ... AGAINST('wireless headphones') looks up both words and gives each row a relevance score; a word found in fewer rows counts for more. Product 87, with both words in its name, scores 6.15 and comes first.
  6. With the ngram parser. WITH PARSER ngram cuts text into fixed-size pieces instead, 2 characters by default. The sentence 京都駅から becomes 京都, 都駅, 駅か, and so on, so a search needs no spaces, and 京都 can match inside 東京都.

The session builds 300 products from fixed word lists, with FULLTEXT (name, description). The natural-language search ranks by relevance: product 87, with both words in its name, scores 6.15 for 'wireless headphones', and the next four rows all score 5.33. Boolean mode gives the query its own operators instead: + requires a word, - excludes it, * matches a word prefix and double quotes match a phrase. Boolean results are not sorted by relevance.

Natural-language and boolean full-text search over 300 products

fts_session.py

"""InnoDB full-text search over 300 products: natural-language and boolean mode, the minimum token size and stopwords.

random.Random(11) builds 300 product names and descriptions from fixed word lists (no text from any outside source);
the INSERT statements go to products_load.sql, which a throwaway MySQL 8.4.11 server (mysql_session.py) runs quietly
after CREATE TABLE ... FULLTEXT (name, description). Then fts.sql runs the searches.
"""

import random

from mysql_session import Server

BRANDS = ["Nilgiri", "Kaveri", "Sahyadri", "Aravalli", "Vindhya", "Deccan", "Malabar", "Konkan"]
KINDS = ["headphones", "earbuds", "speaker", "charger", "cable", "power bank", "keyboard", "mouse", "webcam",
         "monitor", "router", "tripod", "backpack", "bottle", "lamp", "TV mount", "AC adapter", "laptop stand",
         "microphone", "hub"]
FEATURES = ["wireless", "wired", "bluetooth", "USB-C", "USB-A", "noise cancelling", "waterproof", "portable",
            "foldable", "rechargeable", "ergonomic", "compact", "mechanical", "gaming", "fast charging"]
EXTRAS = ["a carry pouch", "a braided cable", "a two year warranty", "an LED indicator", "a soft grip",
          "a quick start guide", "long battery life", "a travel case", "a metal stand", "a matte finish"]
ENDINGS = ["Ships in the box with the manual.", "Works with most phones and laptops.", "Built for daily use at home.",
           "A good fit for the office desk.", "Tested for a year of use in the lab.", "The cable is one metre long."]

rng = random.Random(11)
rows = []
for i in range(1, 301):
    kind, first, second = rng.choice(KINDS), rng.choice(FEATURES), rng.choice(FEATURES)
    name = f"{rng.choice(BRANDS)} {first} {kind}"
    description = f"{second.capitalize()} {kind} with {rng.choice(EXTRAS)} and {rng.choice(EXTRAS)}. {rng.choice(ENDINGS)}"
    rows.append(f"({i}, '{name}', '{description}')")
load = ["CREATE DATABASE shop;", "USE shop;",
        "CREATE TABLE products (id BIGINT PRIMARY KEY, name VARCHAR(200), description TEXT, FULLTEXT (name, description));"]
load += [f"INSERT INTO products VALUES {', '.join(rows[k:k + 50])};" for k in range(0, 300, 50)]

with Server() as my:
    my.up()
    my.write_file("products_load.sql", "\n".join(load) + "\n", show=False)
    my.mysql("products_load.sql", flags=("--skip-comments",))
    my.mysql("fts.sql", flags=("-t", "-v", "--skip-comments"))

Output

$ mysql --skip-comments < products_load.sql

$ mysql -t -v --skip-comments < fts.sql
--------------
SELECT id, name, ROUND(MATCH(name, description) AGAINST('wireless headphones'), 2) AS score
  FROM products
 WHERE MATCH(name, description) AGAINST('wireless headphones')
 ORDER BY score DESC LIMIT 5
--------------

+-----+---------------------------------+-------+
| id  | name                            | score |
+-----+---------------------------------+-------+
|  87 | Konkan wireless headphones      |  6.15 |
|  11 | Nilgiri USB-C headphones        |  5.33 |
|  69 | Nilgiri rechargeable headphones |  5.33 |
| 140 | Nilgiri USB-C headphones        |  5.33 |
| 148 | Nilgiri foldable headphones     |  5.33 |
+-----+---------------------------------+-------+
--------------
SELECT id, name, LEFT(description, 32) AS description_starts
  FROM products WHERE MATCH(name, description) AGAINST('+wireless -usb*' IN BOOLEAN MODE) LIMIT 4
--------------

+-----+---------------------------+----------------------------------+
| id  | name                      | description_starts               |
+-----+---------------------------+----------------------------------+
|   5 | Aravalli wireless speaker | Wireless speaker with an LED ind |
|  14 | Konkan wireless charger   | Wireless charger with a travel c |
| 271 | Deccan wireless speaker   | Wireless speaker with a quick st |
|   7 | Kaveri portable monitor   | Wireless monitor with a travel c |
+-----+---------------------------+----------------------------------+
--------------
SELECT (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('wireless' IN BOOLEAN MODE)) AS wireless,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('+wireless -usb*' IN BOOLEAN MODE)) AS wireless_no_usb,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('+"noise cancelling"' IN BOOLEAN MODE)) AS phrase
--------------

+----------+-----------------+--------+
| wireless | wireless_no_usb | phrase |
+----------+-----------------+--------+
|       37 |              32 |     36 |
+----------+-----------------+--------+
--------------
SHOW VARIABLES LIKE 'innodb_ft_min_token_size'
--------------

+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_ft_min_token_size | 3     |
+--------------------------+-------+
--------------
SELECT COUNT(*) AS default_stopwords FROM information_schema.INNODB_FT_DEFAULT_STOPWORD
--------------

+-------------------+
| default_stopwords |
+-------------------+
|                36 |
+-------------------+
--------------
SELECT (SELECT COUNT(*) FROM products WHERE name LIKE '%TV%') AS like_tv,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('tv' IN BOOLEAN MODE)) AS match_tv,
       (SELECT COUNT(*) FROM products WHERE description LIKE '% the %') AS like_the,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('the' IN BOOLEAN MODE)) AS match_the
--------------

+---------+----------+----------+-----------+
| like_tv | match_tv | like_the | match_the |
+---------+----------+----------+-----------+
|      19 |        0 |      205 |         0 |
+---------+----------+----------+-----------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fts_session.py

fts.sql

USE shop;
-- Natural-language mode ranks rows by relevance; the words are matched anywhere in name or description.
SELECT id, name, ROUND(MATCH(name, description) AGAINST('wireless headphones'), 2) AS score
  FROM products
 WHERE MATCH(name, description) AGAINST('wireless headphones')
 ORDER BY score DESC LIMIT 5;
-- Boolean mode: + must be there, - must not be, a trailing * matches any word that starts so.
SELECT id, name, LEFT(description, 32) AS description_starts
  FROM products WHERE MATCH(name, description) AGAINST('+wireless -usb*' IN BOOLEAN MODE) LIMIT 4;
SELECT (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('wireless' IN BOOLEAN MODE)) AS wireless,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('+wireless -usb*' IN BOOLEAN MODE)) AS wireless_no_usb,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('+"noise cancelling"' IN BOOLEAN MODE)) AS phrase;
-- Words under innodb_ft_min_token_size (3) and stopwords are never indexed.
SHOW VARIABLES LIKE 'innodb_ft_min_token_size';
SELECT COUNT(*) AS default_stopwords FROM information_schema.INNODB_FT_DEFAULT_STOPWORD;
SELECT (SELECT COUNT(*) FROM products WHERE name LIKE '%TV%') AS like_tv,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('tv' IN BOOLEAN MODE)) AS match_tv,
       (SELECT COUNT(*) FROM products WHERE description LIKE '% the %') AS like_the,
       (SELECT COUNT(*) FROM products WHERE MATCH(name, description) AGAINST('the' IN BOOLEAN MODE)) AS match_the;

Of the 300 products, 37 match wireless, 32 match +wireless -usb* and 36 contain the phrase “noise cancelling”. The last two queries show what the index never holds. innodb_ft_min_token_size is 3, so the two-letter word tv is not indexed: LIKE finds 19 products named TV mount, and MATCH finds none. The 36 default stopwords, such as the, are skipped as well: 205 descriptions contain “ the “, and a search for it returns none. The size is a startup option, read-only on a running server, and it applies when an index is built, so a different size means a restart and a rebuilt index. The 50 % rule that hides common words in MyISAM does not apply to InnoDB.

The built-in parser needs separators between words, which Chinese, Japanese and Korean text does not have. The ngram parser cuts text into pieces of ngram_token_size characters, 2 by default, instead.

The ngram parser against the default parser on 20 Japanese sentences

ngram_session.py

"""The ngram full-text parser on 20 Japanese sentences, against the default parser on the same text.

A throwaway MySQL 8.4.11 server (mysql_session.py) runs ngram_load.sql: the same 20 fixed sentences go into texts_cjk
(FULLTEXT ... WITH PARSER ngram) and texts_plain (the built-in parser). Then ngram.sql searches both for 京都 (Kyoto).
"""

from mysql_session import Server

with Server() as my:
    my.up()
    my.mysql("ngram_load.sql", flags=("--skip-comments",))
    my.mysql("ngram.sql", flags=("-t", "-v", "--skip-comments"))

Output

$ mysql --skip-comments < ngram_load.sql
Table	Op	Msg_type	Msg_text
shop.texts_cjk	analyze	status	OK
shop.texts_plain	analyze	status	OK

$ mysql -t -v --skip-comments < ngram.sql
--------------
SHOW VARIABLES LIKE 'ngram_token_size'
--------------

+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| ngram_token_size | 2     |
+------------------+-------+
--------------
SELECT id, body FROM texts_cjk WHERE MATCH(body) AGAINST('京都')
--------------

+----+--------------------------------------------------+
| id | body                                             |
+----+--------------------------------------------------+
|  1 | 京都は古い都です                                 |
|  2 | 東京都は日本の首都です                           |
|  4 | 京都のお寺はとても静かです                       |
| 10 | 京都駅から奈良まで電車で行きます                 |
| 16 | 東京都庁は新宿にあります                         |
| 20 | 京都の秋は紅葉が美しいです                       |
+----+--------------------------------------------------+
--------------
SELECT COUNT(*) AS default_parser_hits FROM texts_plain WHERE MATCH(body) AGAINST('京都')
--------------

+---------------------+
| default_parser_hits |
+---------------------+
|                   0 |
+---------------------+
--------------
EXPLAIN SELECT id FROM texts_cjk WHERE MATCH(body) AGAINST('京都')
--------------

+----+-------------+-----------+------------+----------+---------------+------+---------+-------+------+----------+-------------------------------+
| id | select_type | table     | partitions | type     | possible_keys | key  | key_len | ref   | rows | filtered | Extra                         |
+----+-------------+-----------+------------+----------+---------------+------+---------+-------+------+----------+-------------------------------+
|  1 | SIMPLE      | texts_cjk | NULL       | fulltext | body          | body | 0       | const |    1 |   100.00 | Using where; Ft_hints: sorted |
+----+-------------+-----------+------------+----------+---------------+------+---------+-------+------+----------+-------------------------------+

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ngram_session.py

ngram.sql

USE shop;
SHOW VARIABLES LIKE 'ngram_token_size';
SELECT id, body FROM texts_cjk WHERE MATCH(body) AGAINST('京都');
-- The default parser cuts words at spaces and punctuation, so a Japanese sentence is one long word.
SELECT COUNT(*) AS default_parser_hits FROM texts_plain WHERE MATCH(body) AGAINST('京都');
EXPLAIN SELECT id FROM texts_cjk WHERE MATCH(body) AGAINST('京都');

ngram_load.sql

-- Twenty short Japanese sentences, written for this lesson. 東京都 (the Tokyo prefecture) contains the two characters 京都.
CREATE DATABASE shop;
USE shop;
CREATE TABLE texts_cjk (id BIGINT PRIMARY KEY, body TEXT, FULLTEXT (body) WITH PARSER ngram);
CREATE TABLE texts_plain (id BIGINT PRIMARY KEY, body TEXT, FULLTEXT (body));
INSERT INTO texts_cjk VALUES
  (1, '京都は古い都です'), (2, '東京都は日本の首都です'), (3, '大阪は食べ物で有名です'),
  (4, '京都のお寺はとても静かです'), (5, '東京タワーは赤い塔です'), (6, '北海道は冬に雪が多いです'),
  (7, '福岡のラーメンはおいしいです'), (8, '奈良には大きな仏像があります'), (9, '東京駅は大きな駅です'),
  (10, '京都駅から奈良まで電車で行きます'), (11, '沖縄の海はとてもきれいです'), (12, '名古屋は日本の真ん中にあります'),
  (13, '札幌でビールを飲みました'), (14, '広島の夜景は美しいです'), (15, '神戸は港の町です'),
  (16, '東京都庁は新宿にあります'), (17, '金沢の兼六園は有名な庭です'), (18, '仙台は杜の都と呼ばれます'),
  (19, '横浜の中華街へ行きました'), (20, '京都の秋は紅葉が美しいです');
INSERT INTO texts_plain SELECT * FROM texts_cjk;
ANALYZE TABLE texts_cjk, texts_plain;

Searching 京都 (Kyoto) finds six sentences, and two of them, 東京都 (the Tokyo prefecture), contain the pair 京都 inside a longer word. That is how fixed-size tokens behave: they find every place the two characters sit next to each other, with no notion of words. The same text under the default parser answers 0 rows, because each sentence is one long word. The plan for the ngram table shows access type fulltext.

Using LIKE and expecting the index

A FULLTEXT index is used only by MATCH ... AGAINST. A LIKE query on the same columns ignores it, reads every row and gives different answers: 19 products with TV in the name against none for MATCH.

Checkpoint

Check: Full-text search and parsers

  1. Question 1 of 3 Which query can use the FULLTEXT index on products (name, description)?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: SELECT id FROM products WHERE MATCH(name, description) AGAINST('wireless')

    The full-text index is read only through MATCH ... AGAINST. The other forms scan rows and, as the run showed, can give different answers.

  2. Question 2 of 3 In the run, 19 products have TV in their name, yet MATCH ... AGAINST('tv' IN BOOLEAN MODE) returns 0. What explains it?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: tv has two letters, below innodb_ft_min_token_size (3), so it was never indexed

    A word under three characters is dropped when the index is built. To search for it you would start the server with a smaller innodb_ft_min_token_size and rebuild the index.

  3. Question 3 of 3 With the ngram parser, MATCH(body) AGAINST('京都') returns 東京都 sentences as well as 京都 sentences. Why?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: The parser cuts text into 2-character pieces, and 東京都 contains the piece 京都

    The ngram parser has no idea of words; it indexes every adjacent pair. That makes unsegmented text searchable and also makes pieces inside longer words match.

Practise: choose the index and the search for each case

Every question uses a number or a plan from the runs above. Questions on a concept you have missed come first:

Practice that adapts to you

Practise this lesson

13 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 Order 1's delivery value takes 142 bytes in the JSON column (JSON_STORAGE_SIZE) and is 125 characters as text. What does this show?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: For a small document the binary form is not smaller; its purpose is lookup by key

    Storage size is a cost of the binary format: about 17 bytes more here. The benefit is direct access to keys and array positions, plus validation when the value is stored.

  2. Question 2 of 13 An application inserts the text {"pin_code": "560001", "pin_code": "560002"} into a JSON column. What does the column hold?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: {"pin_code": "560002"}

    Normalisation keeps one value per key, the last, and sorts the keys. That is why a stored document is not always byte-for-byte what the client sent.

  3. Question 3 of 13 You need total units per sku across all 20 orders, with the items inside delivery. Which approach gives the numbers of the run's second query?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: Join each order to JSON_TABLE(delivery, '$.items[*]' COLUMNS (...)) and GROUP BY the sku column

    Turn the array into rows first, then aggregate. The groups come out of the sku column that JSON_TABLE created.

  4. Question 4 of 13 A table of 20 million orders already holds some documents whose pin_code is a number. The team adds the CHECK with JSON_SCHEMA_VALID to it. What should they expect?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: The ALTER TABLE reads the existing rows and fails with error 3819 if any breaks the schema, so clean the data first

    Check the stored documents with JSON_SCHEMA_VALID in a SELECT first, repair the ones that fail, and schedule the ALTER knowing it reads every row.

  5. Question 5 of 13 With idx_pin on the VIRTUAL column pin_code, the query WHERE delivery->>'$.pin_code' = '560001' also shows key idx_pin in the run, although it never names pin_code. Why?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: The optimizer looks for an indexed virtual column whose expression matches the one in the query

    A virtual column's index can serve queries that repeat its expression exactly, which is also why a query that spells the expression differently, like the cast with another collation, can miss it.

  6. Question 6 of 13 A table of 50 million rows needs a fast lookup on one JSON field, and queries almost always use that field in a WHERE clause but rarely select it. Which choice does the manual's reasoning favour?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: A VIRTUAL column with an index, because the values then live only in the index and not in every row

    A virtual column is cheap to add (instant) and holds nothing in the row. The index costs work on each insert and update of the document. A stored column is worth it only when the value is read often without the index.

  7. Question 7 of 13 The run counts 3 products for JSON_CONTAINS(tags, '["promo", "sale"]') and 122 for JSON_OVERLAPS with the same list. Which one finds products that have promo, sale or both?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: JSON_OVERLAPS, the 122

    Use JSON_CONTAINS for "has all of these" and JSON_OVERLAPS for "has any of these". Both can use the same multi-valued index.

  8. Question 8 of 13 The plan of 'promo' MEMBER OF (tags) shows Extra: Using where in the run, never Using index. A teammate wants a covering index on tags. What do you say?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: A multi-valued index can never be a covering index, so the rows are always fetched and checked

    Plan for one row fetch per matching product, and check each plan with EXPLAIN. The 63 products with the tag promo mean 63 fetches.

  9. Question 9 of 13 The lesson's server also sets binlog_row_image=MINIMAL. Why does it matter for the size of a partial event?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: With FULL, the before-image keeps the whole document, so only the after-image shrinks

    The pair works best together: PARTIAL_JSON shrinks the after-image to the changed part, and MINIMAL shrinks the before-image to the key. Either alone leaves a large piece of the document in the log.

  10. Question 10 of 13 A server logs with binlog_format=STATEMENT. What does setting binlog_row_value_options=PARTIAL_JSON do?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Nothing; the option applies only to row-based and mixed logging, and statements already log the changed parts

    Check binlog_format before counting on the saving. Most 8.4 servers default to ROW, where the option applies.

  11. Question 11 of 13 How many words does the default InnoDB stopword list hold, according to INNODB_FT_DEFAULT_STOPWORD in the run?

    Type a number.

    Show the answer: Question 11 of 13

    Answer: 36 words

    The run counts 36 rows in the table, with words such as the, with and for. Words on the list are not indexed.

  12. Question 12 of 13 What does AGAINST('+wireless -usb*' IN BOOLEAN MODE) return?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Rows that contain wireless and no word starting with usb (32 of the 300 products)

    Operators go directly in front of or behind the word they apply to: + in front, - in front, * behind. The result is a set of rows, so add ORDER BY if you want an order.

  13. Question 13 of 13 The same 20 sentences are indexed with the default parser, and MATCH(body) AGAINST('京都') returns 0 rows. A colleague blames a missing stopword list. What is the real cause?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: The default parser cuts words at spaces and punctuation, so each sentence is one long token that is not 京都

    The parser decides what a word is. For text with no separators, create the index WITH PARSER ngram, and accept that pieces inside longer words, such as 京都 inside 東京都, will match.

On your own server

Paste one of your delivery documents into the JSON formatter to see its structure, add a VIRTUAL column and an index for one field on a copy of the table, and compare the two EXPLAIN plans before you touch the real table.

Interview questions

A query filters on one value inside a JSON column. How do you make it fast?

A JSON column cannot be indexed directly, so index something that holds the field’s value. The usual way is a generated column such as pin_code VARCHAR(10) AS (delivery->>'$.pin_code'), left VIRTUAL, with an ordinary index on it: the index keeps a copy of each value and the rows stay the same size. A functional index on CAST(delivery->>'$.pin_code' AS CHAR(10)) does the same without a visible column. Either way the query must produce the same expression, and the collation counts: ->> gives utf8mb4_bin text while CAST gives the server default, so in the lesson’s run the plain ->> query missed the cast index. Confirm with EXPLAIN that the key is used. A STORED column also works, but it writes the value into every row and rebuilds the table when it is added.

Two harder questions ask for a decision on a bigger table and on a search over mixed English and Chinese text. Settle your own design before you open either first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A catalogue of 12 million products stores attrs JSON with a brand string and a colours array of 1 to 6 values, and the main search filters on both. A teammate wants one multi-valued index to cover it. What do you build, what do the indexes cost on writes, and when would you move colours to its own table?

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

  2. Mid to senior

    A shop's 1.5 million listings have an English title and a Chinese description. One FULLTEXT index covers both with the default parser: English searches work, Chinese searches return nothing. Someone rebuilds it WITH PARSER ngram and the English results get worse. What do you change, and what does it cost?

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. On a MySQL JSON column, how do -> and ->> differ?

    -> returns a JSON value, quotes included; ->> returns plain text. Show or store values with ->>, and keep -> for handing a value to another JSON function. MariaDB keeps JSON as checked text and lacks both operators.

  2. How can MySQL turn a JSON array into rows, and refuse documents of the wrong shape?

    JSON_TABLE turns the array into rows that joins and GROUP BY can use. A CHECK constraint with JSON_SCHEMA_VALID rejects a document of the wrong shape, such as a pin code given as a number.

  3. What is the cheapest way to index one field of a JSON document?

    An index on a VIRTUAL generated column, which adds nothing to the row, or a functional index whose expression and collation the query repeats exactly. Adding a STORED column rebuilds the table, and it is not indexed until you add an index.

  4. What does a multi-valued index do, and what can it not do?

    It holds one entry per array element and serves MEMBER OF, JSON_CONTAINS and JSON_OVERLAPS. It never covers a query and cannot be built online.

  5. How much did PARTIAL_JSON save in the binary log, and when does it apply?

    A JSON_SET of one field was logged in 73 bytes instead of 711 for the whole document. It needs row-based or mixed logging and an update that sets the column from a function call on that same column.

  6. Which words does an InnoDB full-text index leave out, and when is the ngram parser needed?

    Words under three characters and the 36 default stopwords. Text written without spaces between words, such as Japanese, needs the ngram parser, which also finds pieces inside longer words.

  7. Does a LIKE query use a FULLTEXT index on the same column?

    No. Only MATCH ... AGAINST uses it; LIKE reads every row and can give other answers: 19 products with TV in the name against none for MATCH, since tv is shorter than the minimum token size.

Key takeaways

  • A JSON column keeps a validated binary document that is read by path: -> returns JSON with its quotes, ->> returns plain text, and on MariaDB, where JSON is checked text, the operators are not available.
  • JSON_TABLE turns an array into rows that GROUP BY and joins can use, and a CHECK with JSON_SCHEMA_VALID refuses documents of the wrong shape, such as a pin code that is a number.
  • A VIRTUAL generated column with an index puts a JSON field on an index at no cost in row size, and a functional index does the same when its expression and collation match the query; adding a STORED column rebuilds the table.
  • A multi-valued index writes one entry for every array element and serves MEMBER OF, JSON_CONTAINS and JSON_OVERLAPS, but it is never covering and cannot be built online.
  • With PARTIAL_JSON, a JSON_SET on one field was logged in 73 bytes against 711 for the whole document, provided the logging is row-based or mixed and the update is a function call on the same column.
  • An InnoDB full-text index drops words under three characters and the 36 default stopwords, ranks natural-language results, offers +, -, * and phrases in boolean mode, and needs the ngram parser for text without spaces, at the price of matching pieces inside words.

Terms in this lesson

full-text index
An InnoDB index that lists the words of character columns with the rows that hold them; MATCH ... AGAINST searches it in natural-language or boolean mode, and a parser decides what a word is.
generated column
A column whose value is an expression of other columns. A VIRTUAL one is computed when the row is read and takes no row space; a STORED one is computed on insert and update and kept in the row.
multi-valued index
An InnoDB secondary index on the values of a JSON array, made with CAST(... AS type ARRAY): one index entry per element. MEMBER OF, JSON_CONTAINS and JSON_OVERLAPS can use it.

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.