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 6 – The optimizer, EXPLAIN and query tuning

Reading EXPLAIN in MySQL: plans line by line

Read the columns of EXPLAIN on MySQL 8.4: type, key, key_len, rows and filtered, Extra, with SHOW WARNINGS and EXPLAIN FOR CONNECTION for a live plan.

  • Beginner
  • 20 minutes
  • Examples run with MySQL 8.4.11, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answer to its interview question.

What you will learn

  • Read the type column of EXPLAIN and name each access path from const to ALL
  • Work out key_len from a key's columns, byte by byte, from their data types
  • Separate the optimizer's row estimates from the true counts a run would return
  • Explain each Extra value a plan this lesson produces, in one sentence
  • Capture the plan of a statement already running in another session

Before you start

What you’ll learn · Before you start

What you will learn

  • Read the type column of EXPLAIN and name each access path from const to ALL
  • Work out key_len from a key's columns, byte by byte, from their data types
  • Separate the optimizer's row estimates from the true counts a run would return
  • Explain each Extra value a plan this lesson produces, in one sentence
  • Capture the plan of a statement already running in another session

Before you start

On this page

On the lab’s 50,000 orders, one filter reads 42 index entries and another reads every row of the table; the ratio is close to 1,200 to 1, and EXPLAIN says which one the server picked in the plan’s very first row. The plan has eight columns worth reading: a type the manual lists from best to worst, a key and its byte count in key_len, two estimates in rows and filtered the optimizer costs the plan with, and a short list of notes in Extra that name what runs after the seek.

Six recorded sessions on MySQL 8.4.11 build those columns one at a time, with the real row counts next to the optimizer’s guesses, and two more statements, SHOW WARNINGS and EXPLAIN FOR CONNECTION, read the plan of a query already running in another session. Secondary, composite and covering indexes is the base this lesson stands on; EXPLAIN ANALYZE and JSON plans is where it goes next.

EXPLAIN and its three formats

EXPLAIN returns the plan the optimizer picked for a statement without running it: the plan a SELECT, INSERT, UPDATE, DELETE, REPLACE or TABLE would use, written as a table a reader can scan. The server can print the plan in three shapes, named by the FORMAT clause: TRADITIONAL, a table with one row per table of the plan; TREE, a text tree of iterators from the outer step inwards; and JSON, a document a program can walk node by node. A bare EXPLAIN picks whichever shape the session variable @@explain_format names.

The default of that variable moved between releases. On MySQL 8.4.11 it is TRADITIONAL, so a plain EXPLAIN SELECT ... prints the table every row of this lesson shows. On MySQL 9.7 and 26.x the default changed to TREE, so the same statement prints the iterator tree instead. A script that captures plans across releases names the format in the statement, for example EXPLAIN FORMAT=TRADITIONAL SELECT ..., or asks for FORMAT=JSON and reads the document.

Behind every one of those shapes is the same work: five stages the server runs between the client’s statement and the rows it returns.

Interactive · stage by stage How a SELECT turns into the plan EXPLAIN prints Walk through the five stages the server runs between the client's SELECT and the rows it sends back, and read what each one adds to the plan.
How a SELECT turns into the plan EXPLAIN prints, step 5 of 5. Showing Parse, Resolve and rewrite, Cost access paths, Pick a plan, Run iterators. In focus: Run iterators.Parsesyntax to a treeResolve and rewritesubqueries, derived tablesCost access pathsstatistics, histogramsPick a plancheapest known choiceRun iteratorsrows sent to the client

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

Every step as text
  1. Parse. The client sends one statement. The parser turns its bytes into a syntax tree, catches wrong SQL and returns an error without touching a table.
  2. Resolve and rewrite. Names become table and column objects. The optimizer rewrites subqueries, derived tables and views into shapes a plan can hold; the Note 1003 that SHOW WARNINGS prints after EXPLAIN is this stage's output.
  3. Cost the access paths. For each table the optimizer lists the ways to reach its rows (a primary-key seek, a secondary-index range, a scan) and prices each from the index statistics and from any histograms. The row counts the plan prints come from this stage.
  4. Pick a plan. The cheapest join order and access paths win; alternatives the optimizer never tried never appear. The result is one tree of iterators, saved for the real run.
  5. Run iterators. Without EXPLAIN the chosen tree runs now and sends rows to the client. EXPLAIN stops after the pick and prints the plan's shape instead; EXPLAIN ANALYZE runs the plan and adds a time to every iterator.

The stages matter for a reader of EXPLAIN. Parse catches wrong SQL; a plan read without touching tables lives in the next three stages. Resolve and rewrite turns subqueries and derived tables into shapes the plan can hold, and the rewrite is the one SHOW WARNINGS prints as Note 1003. Cost access paths is where rows and filtered come from, read against the index statistics and any histogram. Pick a plan chooses the cheapest tree known so far; alternatives the optimizer never tried never appear. Run iterators is where EXPLAIN stops and EXPLAIN ANALYZE goes on.

Try it on your own server

Run SHOW VARIABLES LIKE 'explain_format'; and EXPLAIN SELECT 1;. If the first prints TRADITIONAL, the second prints the table of this lesson. On MySQL 9.7 or 26.x the same two statements print TREE instead, and a FORMAT=TRADITIONAL is needed to see the table.

The type column names the access path

The type column of a TRADITIONAL plan names how the server reached each table, its access type: The value in the type column of EXPLAIN, naming how MySQL reaches a table: const for one resolved row, eq_ref/ref/range for an index seek, index for an index scan and ALL for a table scan, best to worst.: a single row looked up by its primary key, a seek through an index, a full table scan, or something in between. The MySQL manual lists the access types from best to worst; seven of them cover most plans, and this lesson’s sessions produce six of those against the lab (the seventh, system, is the special case of a one-row system table). The others, such as ref_or_null and index_merge, are variants for particular conditions. The first session walks a small orders table of 50,000 rows and a payments table of 40,000, with one secondary index customer_created on (customer_id, created_at):

Six EXPLAIN plans against the lab, one per access type

access_types_session.py

"""Six EXPLAIN plans, one per access type in the type column, against the lab's 50,000 orders and 40,000 payments.

Best to worst: const (primary-key equality), eq_ref (a unique-key lookup inside a join), ref (an equality on a
non-unique index), range (an equality then a half-open range), index (a full index scan), ALL (a table scan). The
same `rows` and `filtered` values appear in two runs because the server reads 300 pages of index statistics instead
of the default 20.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads lab_schema.sql quietly, then runs access_types.sql through
the mysql client.
"""

from mysql_session import Server

with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
    my.up()
    my.sql("CREATE DATABASE shop", flags=())
    my.mysql("lab_schema.sql", db="shop", flags=("-t", "--skip-comments"))
    my.mysql("access_types.sql")

Output

$ mysql -e "CREATE DATABASE shop"

$ mysql -t --skip-comments shop < lab_schema.sql
+----------------+---------+----------+----------+
| Table          | Op      | Msg_type | Msg_text |
+----------------+---------+----------+----------+
| shop.customers | analyze | status   | OK       |
| shop.orders    | analyze | status   | OK       |
| shop.payments  | analyze | status   | OK       |
+----------------+---------+----------+----------+

$ mysql -t -vvv --skip-comments < access_types.sql
--------------
EXPLAIN SELECT id FROM orders WHERE id = 42
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: const
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 4
          ref: const
         rows: 1
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT COUNT(*) FROM orders o JOIN payments p ON p.order_id = o.id WHERE o.customer_id = 42
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: p
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 40000
     filtered: 100.00
        Extra: NULL
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: o
   partitions: NULL
         type: eq_ref
possible_keys: PRIMARY,customer_created
          key: PRIMARY
      key_len: 4
          ref: shop.p.order_id
         rows: 1
     filtered: 5.00
        Extra: Using where
2 rows in set, 1 warning

--------------
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: customer_created
          key: customer_created
      key_len: 4
          ref: const
         rows: 42
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: customer_created
          key: customer_created
      key_len: 9
          ref: NULL
         rows: 28
     filtered: 100.00
        Extra: Using where; Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT MAX(created_at) FROM orders
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: index
possible_keys: NULL
          key: customer_created
      key_len: 9
          ref: NULL
         rows: 50000
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT COUNT(*) FROM orders WHERE note = 'order note 7'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 10.00
        Extra: Using where
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 access_types_session.py

access_types.sql

-- Six EXPLAIN plans against the lab's orders and payments tables, each one producing a different access type in the
-- type column. Best to worst: const, eq_ref, ref, range, index, ALL. The lab's indexes are PRIMARY (id) on all three
-- tables and customer_created (customer_id, created_at) on orders.
USE shop;

-- const: a primary-key equality finds at most one row, so the plan reads one entry of the clustered index.
EXPLAIN SELECT id FROM orders WHERE id = 42\G

-- eq_ref: in a join, each row of the driving table reaches at most one row of this one through a unique key. Here p
-- has no index on order_id, so it scans; its id is a value that o reads through its PRIMARY key, one row at a time.
EXPLAIN SELECT COUNT(*) FROM orders o JOIN payments p ON p.order_id = o.id WHERE o.customer_id = 42\G

-- ref: a non-unique index equality returns every row with the given value. customer_created leads with customer_id,
-- so the seek lands on the first entry with customer_id = 42 and reads until the next value starts.
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42\G

-- range: an equality on the first column of the index, then a half-open range on the second. The seek narrows on
-- both; key_len rises from 4 (customer_id alone) to 9 (customer_id and created_at).
EXPLAIN SELECT id FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01'\G

-- index: the plan reads every entry of the index because the index holds every column the query needs. Also known
-- as a full index scan, read as an alternative to the table scan below.
EXPLAIN SELECT MAX(created_at) FROM orders\G

-- ALL: no index serves this filter, so the plan reads every row of the table and the server tests the WHERE on each.
EXPLAIN SELECT COUNT(*) FROM orders WHERE note = 'order note 7'\G

lab_schema.sql

-- The lesson lab: 1,200 customers, 50,000 orders and 40,000 payments, built without RAND()
-- so that every run on every machine holds exactly the same rows.
SET SESSION cte_max_recursion_depth = 100000;

CREATE TABLE customers (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name          VARCHAR(60)  NOT NULL,
  city          VARCHAR(40)  NOT NULL,
  signed_up_on  DATE         NOT NULL,
  PRIMARY KEY (id),
  KEY city_signed (city, signed_up_on)
) ENGINE=InnoDB;

CREATE TABLE orders (
  id           INT UNSIGNED     NOT NULL AUTO_INCREMENT,
  customer_id  INT UNSIGNED     NOT NULL,
  created_at   DATETIME         NOT NULL,
  status       TINYINT UNSIGNED NOT NULL,
  total_paise  INT UNSIGNED     NOT NULL,
  ship_city    VARCHAR(40)      NOT NULL,
  note         VARCHAR(120)     NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY customer_created (customer_id, created_at)
) ENGINE=InnoDB;

-- payments has no index on order_id: an equi-join to orders has no index to use.
CREATE TABLE payments (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_id      INT UNSIGNED NOT NULL,
  amount_paise  INT UNSIGNED NOT NULL,
  method        VARCHAR(12)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

-- 1,200 customers in 8 cities (13 and 8 are coprime, so the cities cycle evenly).
INSERT INTO customers (name, city, signed_up_on)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1200
)
SELECT CONCAT('Customer ', LPAD(n, 4, '0')),
       ELT(1 + (n * 13) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       DATE_ADD('2025-01-01', INTERVAL (n * 7) MOD 365 DAY)
FROM seq;

-- 50,000 orders over the first 180 days of 2026. customer_id spreads over all 1,200 customers
-- (7919 is prime, so n * 7919 MOD 1200 cycles); total_paise is skewed, with one order in fifty large.
INSERT INTO orders (customer_id, created_at, status, total_paise, ship_city, note)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 50000
)
SELECT 1 + (n * 7919) MOD 1200,
       DATE_ADD(DATE_ADD('2026-01-01 00:00:00', INTERVAL (n * 311) MOD 180 DAY),
                INTERVAL (n * 1013) MOD 86400 SECOND),
       ELT(1 + (n * 3) MOD 4, 1, 2, 2, 3),
       CASE WHEN n MOD 50 = 0 THEN 400000 + (n * 97) MOD 200000
            ELSE 5000 + (n * 89) MOD 95000 END,
       ELT(1 + (n * 17) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       CONCAT('order note ', n MOD 997)
FROM seq;

-- 40,000 payments: the orders whose id is not a multiple of five are paid once.
INSERT INTO payments (order_id, amount_paise, method)
SELECT id, total_paise, ELT(1 + (id * 7) MOD 4, 'upi', 'card', 'netbanking', 'wallet')
FROM orders
WHERE id MOD 5 <> 0;

ANALYZE TABLE customers, orders, payments;

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, for sessions that compare versions.

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.
A session reads only this folder and the MySQL and Python programs it runs, and connects 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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

The plans the session recorded, side by side:

Query type key key_len rows Extra
WHERE id = 42 const PRIMARY 4 1 Using index
join on payments.order_id = orders.id p ALL, o eq_ref -, PRIMARY -, 4 40000, 1 -, Using where
WHERE customer_id = 42 ref customer_created 4 42 Using index
+ created_at >= '2026-03-01' range customer_created 9 28 Using where; Using index
SELECT MAX(created_at) FROM orders index customer_created 9 50000 Using index
WHERE note = 'order note 7' ALL NULL NULL 50000 Using where

Walk through them one more time, stage by stage, with the entries the index holds in mind:

Interactive · step by step Six access types of EXPLAIN, best to worst Step through the type column from const (one row) down to ALL (every row), with the lab query that produces each.
Six access types of EXPLAIN, best to worst, step 6 of 6. Showing const (WHERE id = 42), eq_ref (ON p.order_id = o.id), ref (WHERE customer_id = 42), range (+ created_at >= date), index (SELECT MAX(created_at)), ALL (WHERE note = ...). In focus: ALL.constWHERE id = 42eq_refON p.order_id = o.idrefWHERE customer_id = 42range+ created_at >= dateindexSELECT MAX(created_at)ALLWHERE note = ...

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

Every step as text
  1. const. A primary-key equality: WHERE id = 42 on orders. The plan reads at most one row of the clustered index, key_len 4, Extra Using index.
  2. eq_ref. In a join: p ALL on payments drives the join; for each row of p, orders.id is sought through its PRIMARY key. The lab's two-row plan has eq_ref on the second row.
  3. ref. A non-unique index equality: WHERE customer_id = 42. The seek lands on the first entry with that value and reads 42 of them, key_len 4.
  4. range. An equality then a range: WHERE customer_id = 42 AND created_at >= a date. Both columns narrow the seek, key_len 9, rows 28 in the recorded run.
  5. index. A full index scan when every column the query needs is in the index: EXPLAIN SELECT MAX(created_at) FROM orders uses customer_created, key_len 9, rows 50000.
  6. ALL. A table scan when no index serves the filter: WHERE note = 'order note 7'. Every row of orders is read, rows 50000, filtered 10.00.

const is at the top because the optimizer resolved the row before the query ran: id = 42 is a primary-key equality, and the plan reads one entry of the clustered index for it. eq_ref is const’s cousin for a join: for each row of the driving table, the inner table is reached through a unique key, exactly once. ref is the same idea for a non-unique index: the seek lands on the first entry with the value and reads every entry with it (the 42 orders of customer 42). range is a seek on a half-open interval instead of a single value; the lab’s range plan customer_id = 42 AND created_at >= '2026-03-01' narrows the seek on both columns.

The last two sit below the seeks: index reads every entry of the index (the lab’s SELECT MAX(created_at) plan did, because customer_created holds the column and the primary key id), and ALL reads every row of the table. The order from const to ALL is the manual’s own ranking, so a quick first read of a plan looks at the type column and the rows next to it: a ref with rows: 42 is a different problem to tune than a ref with rows: 42000.

Reading `type: ALL` as always wrong

A full-table ALL is the right plan when a statement needs most of a table’s rows, such as a report over a 5,000-row configuration table: an index would add a lookup for each row it reads and a cost on every write that touches the table. Judge the plan by the rows it reads and the time it takes, not by the word in the type column.

Checkpoint

Check: EXPLAIN and its three formats · The type column, best to worst

  1. Question 1 of 2 SHOW VARIABLES LIKE 'explain_format' on MySQL 8.4.11 reports TRADITIONAL. What does the server print for a plain EXPLAIN SELECT ... when a session has not changed that variable?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A table with one row per table of the plan

    EXPLAIN by itself follows @@explain_format. On MySQL 8.4 that is TRADITIONAL, the table every row of this lesson shows; a script that must stay the same on 9.7 and 26.x names the format in the statement.

  2. Question 2 of 2 Which access type does EXPLAIN name for SELECT id FROM orders WHERE id = 42, where orders is keyed by id?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: const

    The type column names the plan's shape. const is at the top: a plan the optimizer resolved to one row before the query runs. Every other access type reads more entries.

key and key length say which bytes the plan used

The key column names the index the plan chose and key_len: The number of bytes of the chosen index EXPLAIN says a plan uses. Each column of the leftmost prefix the WHERE fixes adds its own bytes; a nullable column adds one more byte, and a VARCHAR key part adds a two-byte length prefix. the number of bytes of its key the plan actually used. The number is small, but it answers a precise question: how many columns of a composite index narrowed the seek, and how many bytes did they take. Each column of the leftmost prefix the WHERE fixes adds its own byte count, from the manual’s data-type sizes; a column that is nullable adds one more byte for a NULL flag the server reads before the value; a variable-length column adds two more bytes for its length prefix. The second session reads two indexes of the lab byte by byte:

key_len for four plans, read column by column

key_bytes_session.py

"""key and key_len column by column, for two indexes of the lab.

customer_created (customer_id INT NOT NULL + created_at DATETIME NOT NULL): seek on customer_id alone reports
key_len 4; seek on both reports key_len 9. city_signed (city VARCHAR(40) NOT NULL utf8mb4 + signed_up_on DATE
NOT NULL): seek on city alone reports key_len 162 (40 characters times 4 bytes per character, plus a 2-byte length
prefix); seek on both reports key_len 165. Every byte of the index that narrows the seek is in key_len; every column
that comes after a gap or a range column is not.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads lab_schema.sql quietly, then runs key_bytes.sql through
the mysql client.
"""

from mysql_session import Server

with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
    my.up()
    my.sql("CREATE DATABASE shop", flags=())
    my.mysql("lab_schema.sql", db="shop", flags=("-t", "--skip-comments"))
    my.mysql("key_bytes.sql")

Output

$ mysql -e "CREATE DATABASE shop"

$ mysql -t --skip-comments shop < lab_schema.sql
+----------------+---------+----------+----------+
| Table          | Op      | Msg_type | Msg_text |
+----------------+---------+----------+----------+
| shop.customers | analyze | status   | OK       |
| shop.orders    | analyze | status   | OK       |
| shop.payments  | analyze | status   | OK       |
+----------------+---------+----------+----------+

$ mysql -t -vvv --skip-comments < key_bytes.sql
--------------
EXPLAIN SELECT id FROM orders WHERE customer_id = 42
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: customer_created
          key: customer_created
      key_len: 4
          ref: const
         rows: 42
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: customer_created
          key: customer_created
      key_len: 9
          ref: NULL
         rows: 28
     filtered: 100.00
        Extra: Using where; Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM customers WHERE city = 'Pune'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: customers
   partitions: NULL
         type: ref
possible_keys: city_signed
          key: city_signed
      key_len: 162
          ref: const
         rows: 150
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM customers WHERE city = 'Pune' AND signed_up_on >= '2025-07-01'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: customers
   partitions: NULL
         type: range
possible_keys: city_signed
          key: city_signed
      key_len: 165
          ref: NULL
         rows: 77
     filtered: 100.00
        Extra: Using where; Using index
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 key_bytes_session.py

key_bytes.sql

-- key and key_len for two indexes of the lab, read column by column.
--   customer_created on orders is on (customer_id, created_at): customer_id is INT UNSIGNED NOT NULL (4 bytes),
--   created_at is DATETIME NOT NULL (5 bytes), so a seek on customer_id alone reports key_len 4, and a seek that
--   adds created_at reports key_len 9.
--   city_signed on customers is on (city, signed_up_on): city is VARCHAR(40) NOT NULL in utf8mb4 (40 characters
--   store up to 160 bytes, plus a 2-byte length prefix = 162), signed_up_on is DATE NOT NULL (3 bytes), so a seek
--   on city alone reports key_len 162, and a seek that adds signed_up_on reports key_len 165.
USE shop;

EXPLAIN SELECT id FROM orders WHERE customer_id = 42\G
EXPLAIN SELECT id FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01'\G
EXPLAIN SELECT id FROM customers WHERE city = 'Pune'\G
EXPLAIN SELECT id FROM customers WHERE city = 'Pune' AND signed_up_on >= '2025-07-01'\G

lab_schema.sql

-- The lesson lab: 1,200 customers, 50,000 orders and 40,000 payments, built without RAND()
-- so that every run on every machine holds exactly the same rows.
SET SESSION cte_max_recursion_depth = 100000;

CREATE TABLE customers (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name          VARCHAR(60)  NOT NULL,
  city          VARCHAR(40)  NOT NULL,
  signed_up_on  DATE         NOT NULL,
  PRIMARY KEY (id),
  KEY city_signed (city, signed_up_on)
) ENGINE=InnoDB;

CREATE TABLE orders (
  id           INT UNSIGNED     NOT NULL AUTO_INCREMENT,
  customer_id  INT UNSIGNED     NOT NULL,
  created_at   DATETIME         NOT NULL,
  status       TINYINT UNSIGNED NOT NULL,
  total_paise  INT UNSIGNED     NOT NULL,
  ship_city    VARCHAR(40)      NOT NULL,
  note         VARCHAR(120)     NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY customer_created (customer_id, created_at)
) ENGINE=InnoDB;

-- payments has no index on order_id: an equi-join to orders has no index to use.
CREATE TABLE payments (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_id      INT UNSIGNED NOT NULL,
  amount_paise  INT UNSIGNED NOT NULL,
  method        VARCHAR(12)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

-- 1,200 customers in 8 cities (13 and 8 are coprime, so the cities cycle evenly).
INSERT INTO customers (name, city, signed_up_on)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1200
)
SELECT CONCAT('Customer ', LPAD(n, 4, '0')),
       ELT(1 + (n * 13) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       DATE_ADD('2025-01-01', INTERVAL (n * 7) MOD 365 DAY)
FROM seq;

-- 50,000 orders over the first 180 days of 2026. customer_id spreads over all 1,200 customers
-- (7919 is prime, so n * 7919 MOD 1200 cycles); total_paise is skewed, with one order in fifty large.
INSERT INTO orders (customer_id, created_at, status, total_paise, ship_city, note)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 50000
)
SELECT 1 + (n * 7919) MOD 1200,
       DATE_ADD(DATE_ADD('2026-01-01 00:00:00', INTERVAL (n * 311) MOD 180 DAY),
                INTERVAL (n * 1013) MOD 86400 SECOND),
       ELT(1 + (n * 3) MOD 4, 1, 2, 2, 3),
       CASE WHEN n MOD 50 = 0 THEN 400000 + (n * 97) MOD 200000
            ELSE 5000 + (n * 89) MOD 95000 END,
       ELT(1 + (n * 17) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       CONCAT('order note ', n MOD 997)
FROM seq;

-- 40,000 payments: the orders whose id is not a multiple of five are paid once.
INSERT INTO payments (order_id, amount_paise, method)
SELECT id, total_paise, ELT(1 + (id * 7) MOD 4, 'upi', 'card', 'netbanking', 'wallet')
FROM orders
WHERE id MOD 5 <> 0;

ANALYZE TABLE customers, orders, payments;

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, for sessions that compare versions.

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.
A session reads only this folder and the MySQL and Python programs it runs, and connects 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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

The A composite index on the lab's orders table, declared as KEY customer_created (customer_id, created_at); its leftmost prefix is customer_id, so a seek on that column alone works, and a seek on both narrows further. index is on (customer_id, created_at): customer_id is INT UNSIGNED NOT NULL (4 bytes from the manual) and created_at is DATETIME NOT NULL with no fractional seconds (5 bytes). A seek on customer_id = 42 alone reports key_len 4, and a seek that adds the range on created_at reports key_len 9. The city_signed index is on (city, signed_up_on): city is VARCHAR(40) NOT NULL in utf8mb4 and signed_up_on is DATE NOT NULL (3 bytes). The VARCHAR key takes 40 * 4 = 160 bytes for the characters plus a 2-byte length prefix, so city = 'Pune' alone reports key_len 162, and the range that adds signed_up_on reports key_len 165.

The byte count is the sum, from the leftmost column, until a column cannot narrow the seek any further:

Column of a key Bytes added to key_len
TINYINT, SMALLINT, MEDIUMINT, INT, BIGINT 1, 2, 3, 4, 8
DATE, TIME with no fractional seconds 3, 3
DATETIME, TIMESTAMP with no fractional seconds 5, 4
CHAR(N) in a character set N * bytes_per_char
VARCHAR(N) in a character set N * bytes_per_char + 2 (the length prefix)
any column declared without NOT NULL + 1 byte

The two bytes of a VARCHAR key part are worth a measurement of their own, because the rule a reader meets first is the rule for a row, not for a key: a VARCHAR column whose values can never need more than 255 bytes is stored with a one-byte length prefix. A key part always carries two, and the next session reports it on four columns of one table:

The length prefix of a VARCHAR key part, on four indexes

prefix_bytes_session.py

"""The length prefix a VARCHAR key part adds to key_len, measured on four indexes of one table.

A VARCHAR column's row format carries a 1-byte length prefix while a value needs at most 255 bytes and a 2-byte
prefix above that. A key part follows a different rule: in key_len a VARCHAR part always adds 2 bytes for its
length. VARCHAR(10) in latin1 reports key_len 12, VARCHAR(300) in latin1 reports 302, VARCHAR(40) in utf8mb4
reports 162 (the number the lab's city_signed plan reports), and a nullable VARCHAR(10) reports 13: the 12 bytes
plus one byte for the NULL flag.

A throwaway MySQL 8.4.11 server (mysql_session.py) runs prefix_bytes.sql, which builds its own 240-row table, so
this session does not need the lesson lab.
"""

from mysql_session import Server

with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
    my.up()
    my.sql("CREATE DATABASE shop", flags=())
    my.mysql("prefix_bytes.sql")

Output

$ mysql -e "CREATE DATABASE shop"

$ mysql -t -vvv --skip-comments < prefix_bytes.sql
--------------
CREATE TABLE prefix_demo (
  id        INT UNSIGNED NOT NULL AUTO_INCREMENT,
  code10    VARCHAR(10)  CHARACTER SET latin1  NOT NULL,
  label300  VARCHAR(300) CHARACTER SET latin1  NOT NULL,
  city40    VARCHAR(40)  CHARACTER SET utf8mb4 NOT NULL,
  tag10     VARCHAR(10)  CHARACTER SET latin1      NULL,
  PRIMARY KEY (id),
  KEY code_only (code10),
  KEY label_only (label300),
  KEY city_only (city40),
  KEY tag_only (tag10)
) ENGINE=InnoDB
--------------

Query OK, 0 rows affected

--------------
INSERT INTO prefix_demo (code10, label300, city40, tag10)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 240
)
SELECT CONCAT('c', LPAD(n % 24, 4, '0')),
       CONCAT('label ', n % 24),
       CONCAT('city ', n % 24),
       IF(n % 6 = 0, NULL, CONCAT('t', n % 24))
FROM seq
--------------

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

--------------
ANALYZE TABLE prefix_demo
--------------

+------------------+---------+----------+----------+
| Table            | Op      | Msg_type | Msg_text |
+------------------+---------+----------+----------+
| shop.prefix_demo | analyze | status   | OK       |
+------------------+---------+----------+----------+
1 row in set

--------------
EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (code_only) WHERE code10 = 'c0007'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: prefix_demo
   partitions: NULL
         type: ref
possible_keys: code_only
          key: code_only
      key_len: 12
          ref: const
         rows: 10
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (label_only) WHERE label300 = 'label 7'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: prefix_demo
   partitions: NULL
         type: ref
possible_keys: label_only
          key: label_only
      key_len: 302
          ref: const
         rows: 10
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (city_only) WHERE city40 = 'city 7'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: prefix_demo
   partitions: NULL
         type: ref
possible_keys: city_only
          key: city_only
      key_len: 162
          ref: const
         rows: 10
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (tag_only) WHERE tag10 = 't7'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: prefix_demo
   partitions: NULL
         type: ref
possible_keys: tag_only
          key: tag_only
      key_len: 13
          ref: const
         rows: 11
     filtered: 100.00
        Extra: Using index
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 prefix_bytes_session.py

prefix_bytes.sql

-- How many bytes a VARCHAR key part adds to key_len, measured on four indexes of one small table.
--   The row format of a VARCHAR column uses a 1-byte length prefix while a value needs at most 255 bytes and a
--   2-byte prefix above that (MySQL 8.4 Reference Manual, Data Type Storage Requirements). A key part is not a
--   row: in key_len a VARCHAR part always adds 2 bytes for its length, whatever the column's maximum byte length.
--   code10 is VARCHAR(10) in latin1 (10 bytes), so key_len 12; label300 is VARCHAR(300) in latin1 (300 bytes),
--   so key_len 302; city40 is VARCHAR(40) in utf8mb4 (160 bytes), so key_len 162, the number the lab's
--   city_signed plan reports; tag10 is VARCHAR(10) in latin1 and nullable, so 12 plus one NULL-flag byte = 13.
--   FORCE INDEX keeps the plan on the index under test: on a table this small a scan would otherwise be cheaper.
USE shop;

CREATE TABLE prefix_demo (
  id        INT UNSIGNED NOT NULL AUTO_INCREMENT,
  code10    VARCHAR(10)  CHARACTER SET latin1  NOT NULL,
  label300  VARCHAR(300) CHARACTER SET latin1  NOT NULL,
  city40    VARCHAR(40)  CHARACTER SET utf8mb4 NOT NULL,
  tag10     VARCHAR(10)  CHARACTER SET latin1      NULL,
  PRIMARY KEY (id),
  KEY code_only (code10),
  KEY label_only (label300),
  KEY city_only (city40),
  KEY tag_only (tag10)
) ENGINE=InnoDB;

-- 240 rows in 24 groups, built from a counter so that every run holds exactly the same rows.
INSERT INTO prefix_demo (code10, label300, city40, tag10)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 240
)
SELECT CONCAT('c', LPAD(n % 24, 4, '0')),
       CONCAT('label ', n % 24),
       CONCAT('city ', n % 24),
       IF(n % 6 = 0, NULL, CONCAT('t', n % 24))
FROM seq;

ANALYZE TABLE prefix_demo;

EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (code_only) WHERE code10 = 'c0007'\G
EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (label_only) WHERE label300 = 'label 7'\G
EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (city_only) WHERE city40 = 'city 7'\G
EXPLAIN SELECT id FROM prefix_demo FORCE INDEX (tag_only) WHERE tag10 = 't7'\G

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, for sessions that compare versions.

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.
A session reads only this folder and the MySQL and Python programs it runs, and connects 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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

VARCHAR(10) in latin1 reports key_len 12, not 11; VARCHAR(300) in the same character set reports 302; VARCHAR(40) in utf8mb4 reports the lab’s 162; and the nullable VARCHAR(10) reports 13, the 12 bytes plus the NULL flag. So a key_len that is two more than a round number is usually a VARCHAR seek, and one that is three more is a nullable VARCHAR seek.

Key idea

key_len is a sum over the columns of the leftmost prefix the WHERE fixed: each one adds its data-type bytes, plus one byte if the column is nullable, plus two bytes if the column is variable-length.

The exercise at the end of the lesson is a key_len calculator in Python: given the parts of an index and the number of leading columns the plan uses, it returns the number of bytes the plan would print, with sample tests that mirror the four lab plans above plus a nullable INT and the short VARCHAR these two sessions measured.

Checkpoint

Check: key and key_len, byte by byte

  1. Question 1 of 2 customer_created on orders is on (customer_id INT NOT NULL, created_at DATETIME NOT NULL). A query asks for customer 42's orders from a given date on: WHERE customer_id = 42 AND created_at >= that date. What does EXPLAIN report as key_len?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 9

    INT NOT NULL adds 4 bytes and DATETIME NOT NULL adds 5, so the two columns together add 9. The recorded range plan prints key_len 9 and rows 28.

  2. Question 2 of 2 city_signed on customers is on (city VARCHAR(40) NOT NULL utf8mb4, signed_up_on DATE NOT NULL). EXPLAIN reports key_len 162 for WHERE city = 'Pune'. Which numbers add up to 162?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: 40 characters * 4 bytes per character + 2 bytes for the length prefix

    key_len holds the bytes of the key the plan uses. A VARCHAR key part adds length * bytes_per_char bytes plus a two-byte length prefix; a nullable column would add one more byte.

rows and filtered are estimates, not counts

Two columns of the plan carry the optimizer’s own guesses. rows is how many rows the plan expects to read from the table at this step, read from the index statistics or, for a scan, from the row count of the table. filtered, the filtered estimate: The percentage in the filtered column of EXPLAIN, the fraction of rows the optimizer expects the WHERE to keep after the step. rows times filtered divided by 100 is the row count the optimizer compares between candidate plans., is a percentage: how many of those rows the optimizer expects the WHERE to keep. The product rows * filtered / 100 is the row count the optimizer compares between candidate plans, so a plan with 50,000 rows and 1 % filtered is costed as 500 rows, not 50,000.

EXPLAIN estimates next to the true row counts

estimates_session.py

"""rows and filtered against the real counts, from three EXPLAIN plans.

The lesson teaches that rows is the server's guess of how many rows the plan will read, and filtered is a per cent
of them it expects the WHERE to keep. Their product (rows * filtered / 100) is the row count the optimizer compares.
Here the index estimate for customer_id = 42 is exact (42 of 42), while a filter on total_paise with no histogram
is far off: filtered 33.33 against a real share of about 1.1 %.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads lab_schema.sql quietly, then runs estimates.sql through
the mysql client.
"""

from mysql_session import Server

with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
    my.up()
    my.sql("CREATE DATABASE shop", flags=())
    my.mysql("lab_schema.sql", db="shop", flags=("-t", "--skip-comments"))
    my.mysql("estimates.sql")

Output

$ mysql -e "CREATE DATABASE shop"

$ mysql -t --skip-comments shop < lab_schema.sql
+----------------+---------+----------+----------+
| Table          | Op      | Msg_type | Msg_text |
+----------------+---------+----------+----------+
| shop.customers | analyze | status   | OK       |
| shop.orders    | analyze | status   | OK       |
| shop.payments  | analyze | status   | OK       |
+----------------+---------+----------+----------+

$ mysql -t -vvv --skip-comments < estimates.sql
--------------
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: customer_created
          key: customer_created
      key_len: 4
          ref: const
         rows: 42
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
SELECT COUNT(*) AS true_rows FROM orders WHERE customer_id = 42
--------------

+-----------+
| true_rows |
+-----------+
|        42 |
+-----------+
1 row in set

--------------
EXPLAIN SELECT id FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: customer_created
          key: customer_created
      key_len: 9
          ref: NULL
         rows: 28
     filtered: 100.00
        Extra: Using where; Using index
1 row in set, 1 warning

--------------
SELECT COUNT(*) AS true_rows FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01'
--------------

+-----------+
| true_rows |
+-----------+
|        28 |
+-----------+
1 row in set

--------------
EXPLAIN SELECT COUNT(*) FROM orders WHERE total_paise > 490000
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 33.33
        Extra: Using where
1 row in set, 1 warning

--------------
SELECT COUNT(*) AS true_rows FROM orders WHERE total_paise > 490000
--------------

+-----------+
| true_rows |
+-----------+
|       544 |
+-----------+
1 row in set

--------------
SELECT COUNT(*) AS all_rows FROM orders
--------------

+----------+
| all_rows |
+----------+
|    50000 |
+----------+
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 estimates_session.py

estimates.sql

-- rows and filtered are the optimizer's estimates: the rows it expects to read, and the fraction it expects to keep.
-- Their product is the row count the optimizer compares between candidate plans. The counts under each EXPLAIN are
-- the real numbers, so a reader can see where the estimate is on and where it is far off.
USE shop;

-- customer_id = 42: the secondary index customer_created leads with customer_id, and the server has sampled 300 of
-- its pages, so rows is close to the truth and filtered is 100.00 because the equality uses the whole seek.
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42\G
SELECT COUNT(*) AS true_rows FROM orders WHERE customer_id = 42;

-- customer_id = 42 AND created_at >= '2026-03-01': a range narrows the seek. filtered stays at 100.00 because the
-- range is still the seek, not a condition after it.
EXPLAIN SELECT id FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01'\G
SELECT COUNT(*) AS true_rows FROM orders WHERE customer_id = 42 AND created_at >= '2026-03-01';

-- total_paise > 490000: no index serves this filter, so the plan reads every row (type ALL, rows 50000) and the
-- optimizer guesses that about a third of the rows (filtered 33.33) will match. In this lab the real share is a
-- little over 1 % (544 of 50,000). The gap is the optimizer's second-best option when it has no histogram.
EXPLAIN SELECT COUNT(*) FROM orders WHERE total_paise > 490000\G
SELECT COUNT(*) AS true_rows FROM orders WHERE total_paise > 490000;
SELECT COUNT(*) AS all_rows FROM orders;

lab_schema.sql

-- The lesson lab: 1,200 customers, 50,000 orders and 40,000 payments, built without RAND()
-- so that every run on every machine holds exactly the same rows.
SET SESSION cte_max_recursion_depth = 100000;

CREATE TABLE customers (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name          VARCHAR(60)  NOT NULL,
  city          VARCHAR(40)  NOT NULL,
  signed_up_on  DATE         NOT NULL,
  PRIMARY KEY (id),
  KEY city_signed (city, signed_up_on)
) ENGINE=InnoDB;

CREATE TABLE orders (
  id           INT UNSIGNED     NOT NULL AUTO_INCREMENT,
  customer_id  INT UNSIGNED     NOT NULL,
  created_at   DATETIME         NOT NULL,
  status       TINYINT UNSIGNED NOT NULL,
  total_paise  INT UNSIGNED     NOT NULL,
  ship_city    VARCHAR(40)      NOT NULL,
  note         VARCHAR(120)     NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY customer_created (customer_id, created_at)
) ENGINE=InnoDB;

-- payments has no index on order_id: an equi-join to orders has no index to use.
CREATE TABLE payments (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_id      INT UNSIGNED NOT NULL,
  amount_paise  INT UNSIGNED NOT NULL,
  method        VARCHAR(12)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

-- 1,200 customers in 8 cities (13 and 8 are coprime, so the cities cycle evenly).
INSERT INTO customers (name, city, signed_up_on)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1200
)
SELECT CONCAT('Customer ', LPAD(n, 4, '0')),
       ELT(1 + (n * 13) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       DATE_ADD('2025-01-01', INTERVAL (n * 7) MOD 365 DAY)
FROM seq;

-- 50,000 orders over the first 180 days of 2026. customer_id spreads over all 1,200 customers
-- (7919 is prime, so n * 7919 MOD 1200 cycles); total_paise is skewed, with one order in fifty large.
INSERT INTO orders (customer_id, created_at, status, total_paise, ship_city, note)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 50000
)
SELECT 1 + (n * 7919) MOD 1200,
       DATE_ADD(DATE_ADD('2026-01-01 00:00:00', INTERVAL (n * 311) MOD 180 DAY),
                INTERVAL (n * 1013) MOD 86400 SECOND),
       ELT(1 + (n * 3) MOD 4, 1, 2, 2, 3),
       CASE WHEN n MOD 50 = 0 THEN 400000 + (n * 97) MOD 200000
            ELSE 5000 + (n * 89) MOD 95000 END,
       ELT(1 + (n * 17) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       CONCAT('order note ', n MOD 997)
FROM seq;

-- 40,000 payments: the orders whose id is not a multiple of five are paid once.
INSERT INTO payments (order_id, amount_paise, method)
SELECT id, total_paise, ELT(1 + (id * 7) MOD 4, 'upi', 'card', 'netbanking', 'wallet')
FROM orders
WHERE id MOD 5 <> 0;

ANALYZE TABLE customers, orders, payments;

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, for sessions that compare versions.

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.
A session reads only this folder and the MySQL and Python programs it runs, and connects 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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

For a seek on an index the estimate is close to the truth. WHERE customer_id = 42 reports rows 42, filtered 100.00, and the real count is 42: the index has an entry per row of the table, so the server knows the number before it reads anything. The range WHERE customer_id = 42 AND created_at >= '2026-03-01' reports rows 28, filtered 100.00, and the real count is also 28. filtered stays at 100 because every entry the seek reads matches the WHERE: the range is the seek, not a condition after it.

For a scan the estimate can be far off. WHERE total_paise > 490000 reports rows 50000, filtered 33.33, so the optimizer costs the plan with about 16,665 rows. The real count is 544, close to 1.1 %. There is no index on total_paise, so the server has no statistics on its distribution and falls back to a built-in guess of a third. The gap is wide enough to pick a wrong plan when a better one exists; closing it is the job of optimizer statistics and histograms, later in this module, which adds a histogram and watches the number drop to the right order of magnitude.

Treating `filtered` as a measurement

filtered is an estimate the optimizer forms before the query runs, not a per-row log of what the last run saw. The next lesson’s EXPLAIN ANALYZE prints the actual numbers next to each iterator; until then, read filtered as the optimizer’s prior.

Checkpoint

Check: rows and filtered are estimates

  1. Question 1 of 2 EXPLAIN SELECT COUNT(*) FROM orders WHERE total_paise > 490000 reports rows 50000 and filtered 33.33. How many rows does the optimizer expect to keep after the filter? Round to the nearest whole number.

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 16665

    filtered is a percentage of rows the optimizer expects the WHERE to keep. 50000 * 33.33 / 100 is about 16665. Without a histogram the server guesses that about a third of the rows will match; the real number in the lab is 544.

  2. Question 2 of 2 A full-scan EXPLAIN of the same filter prints rows 50000 and filtered 33.33, while a true SELECT COUNT(*) says 544. Which statement is the right lesson?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: An EXPLAIN value is an estimate: the optimizer compares plans with it, not with the real count

    rows times filtered is the row count the optimizer uses to compare plans. The real count can be far from it, especially when the filter is on an unindexed column with a skewed value distribution.

The Extra column names what happens after the seek

The Extra column of a traditional plan is a short list of notes that say what the server did around the seek: whether it fetched a row at all, whether it tested a filter on the index entries, and whether it sorted the rows or built a temporary table for them. Five notes carry most of the signal a reader of a plan needs, and the fifth session shows four of them in a row.

Four values of the Extra column, each from one short query

extra_flags_session.py

"""The Extra values this lesson teaches, each one from a short query against the lab, plus SHOW WARNINGS.

Using index (covering read), Using where (post-fetch filter), Using filesort (sort outside the index) and Using
temporary (a temporary table for GROUP BY). The last EXPLAIN turns an IN subquery into a semi-join and prints
SHOW WARNINGS Note 1003: the query as the optimizer rewrote it.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads lab_schema.sql quietly, then runs extra_flags.sql through
the mysql client.
"""

from mysql_session import Server

with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
    my.up()
    my.sql("CREATE DATABASE shop", flags=())
    my.mysql("lab_schema.sql", db="shop", flags=("-t", "--skip-comments"))
    my.mysql("extra_flags.sql")

Output

$ mysql -e "CREATE DATABASE shop"

$ mysql -t --skip-comments shop < lab_schema.sql
+----------------+---------+----------+----------+
| Table          | Op      | Msg_type | Msg_text |
+----------------+---------+----------+----------+
| shop.customers | analyze | status   | OK       |
| shop.orders    | analyze | status   | OK       |
| shop.payments  | analyze | status   | OK       |
+----------------+---------+----------+----------+

$ mysql -t -vvv --skip-comments < extra_flags.sql
--------------
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: customer_created
          key: customer_created
      key_len: 4
          ref: const
         rows: 42
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT COUNT(*) FROM orders WHERE note = 'order note 7'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 10.00
        Extra: Using where
1 row in set, 1 warning

--------------
EXPLAIN SELECT id, total_paise FROM orders WHERE customer_id = 42 ORDER BY total_paise LIMIT 5
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: customer_created
          key: customer_created
      key_len: 4
          ref: const
         rows: 42
     filtered: 100.00
        Extra: Using filesort
1 row in set, 1 warning

--------------
EXPLAIN SELECT ship_city, COUNT(*) c FROM orders GROUP BY ship_city ORDER BY c DESC
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 100.00
        Extra: Using temporary; Using filesort
1 row in set, 1 warning

--------------
EXPLAIN SELECT c.name FROM customers c
  WHERE c.id IN (SELECT o.customer_id FROM orders o WHERE o.total_paise > 490000)
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: c
   partitions: NULL
         type: ALL
possible_keys: PRIMARY
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 1200
     filtered: 100.00
        Extra: NULL
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: <subquery2>
   partitions: NULL
         type: eq_ref
possible_keys: <auto_distinct_key>
          key: <auto_distinct_key>
      key_len: 4
          ref: shop.c.id
         rows: 1
     filtered: 100.00
        Extra: NULL
*************************** 3. row ***************************
           id: 2
  select_type: MATERIALIZED
        table: o
   partitions: NULL
         type: ALL
possible_keys: customer_created
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 33.33
        Extra: Using where
3 rows in set, 1 warning

--------------
SHOW WARNINGS
--------------

*************************** 1. row ***************************
  Level: Note
   Code: 1003
Message: /* select#1 */ select `shop`.`c`.`name` AS `name` from `shop`.`customers` `c` semi join (`shop`.`orders` `o`) where ((`<subquery2>`.`customer_id` = `shop`.`c`.`id`) and (`shop`.`o`.`total_paise` > 490000))
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 extra_flags_session.py

extra_flags.sql

-- Four values of the Extra column, each one produced by a short query against the lab.
USE shop;

-- Using index: a covering read. customer_created holds customer_id, created_at and (by InnoDB) the primary key id,
-- so SELECT id, created_at never reaches a row.
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42\G

-- Using where: the server tests the WHERE after reading the row. Here no index serves the filter, so the plan
-- reads every row of the table (type ALL) and discards most.
EXPLAIN SELECT COUNT(*) FROM orders WHERE note = 'order note 7'\G

-- Using filesort: the plan reads 42 rows for customer_id = 42 and then sorts them by total_paise, which no index
-- holds in order. ORDER BY runs after the seek, outside the index.
EXPLAIN SELECT id, total_paise FROM orders WHERE customer_id = 42 ORDER BY total_paise LIMIT 5\G

-- Using temporary: GROUP BY on a column no index starts with, so the server builds a temporary table of city counts.
-- Here Using filesort also appears, because ORDER BY c is on a column that is computed, not stored.
EXPLAIN SELECT ship_city, COUNT(*) c FROM orders GROUP BY ship_city ORDER BY c DESC\G

-- SHOW WARNINGS: Note 1003 holds the query as the optimizer rewrote it. An IN subquery is turned into a semi-join
-- (semi join (orders)), and <subquery2> is the materialized distinct result of the subquery.
EXPLAIN SELECT c.name FROM customers c
  WHERE c.id IN (SELECT o.customer_id FROM orders o WHERE o.total_paise > 490000)\G
SHOW WARNINGS\G

lab_schema.sql

-- The lesson lab: 1,200 customers, 50,000 orders and 40,000 payments, built without RAND()
-- so that every run on every machine holds exactly the same rows.
SET SESSION cte_max_recursion_depth = 100000;

CREATE TABLE customers (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name          VARCHAR(60)  NOT NULL,
  city          VARCHAR(40)  NOT NULL,
  signed_up_on  DATE         NOT NULL,
  PRIMARY KEY (id),
  KEY city_signed (city, signed_up_on)
) ENGINE=InnoDB;

CREATE TABLE orders (
  id           INT UNSIGNED     NOT NULL AUTO_INCREMENT,
  customer_id  INT UNSIGNED     NOT NULL,
  created_at   DATETIME         NOT NULL,
  status       TINYINT UNSIGNED NOT NULL,
  total_paise  INT UNSIGNED     NOT NULL,
  ship_city    VARCHAR(40)      NOT NULL,
  note         VARCHAR(120)     NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY customer_created (customer_id, created_at)
) ENGINE=InnoDB;

-- payments has no index on order_id: an equi-join to orders has no index to use.
CREATE TABLE payments (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_id      INT UNSIGNED NOT NULL,
  amount_paise  INT UNSIGNED NOT NULL,
  method        VARCHAR(12)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

-- 1,200 customers in 8 cities (13 and 8 are coprime, so the cities cycle evenly).
INSERT INTO customers (name, city, signed_up_on)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1200
)
SELECT CONCAT('Customer ', LPAD(n, 4, '0')),
       ELT(1 + (n * 13) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       DATE_ADD('2025-01-01', INTERVAL (n * 7) MOD 365 DAY)
FROM seq;

-- 50,000 orders over the first 180 days of 2026. customer_id spreads over all 1,200 customers
-- (7919 is prime, so n * 7919 MOD 1200 cycles); total_paise is skewed, with one order in fifty large.
INSERT INTO orders (customer_id, created_at, status, total_paise, ship_city, note)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 50000
)
SELECT 1 + (n * 7919) MOD 1200,
       DATE_ADD(DATE_ADD('2026-01-01 00:00:00', INTERVAL (n * 311) MOD 180 DAY),
                INTERVAL (n * 1013) MOD 86400 SECOND),
       ELT(1 + (n * 3) MOD 4, 1, 2, 2, 3),
       CASE WHEN n MOD 50 = 0 THEN 400000 + (n * 97) MOD 200000
            ELSE 5000 + (n * 89) MOD 95000 END,
       ELT(1 + (n * 17) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       CONCAT('order note ', n MOD 997)
FROM seq;

-- 40,000 payments: the orders whose id is not a multiple of five are paid once.
INSERT INTO payments (order_id, amount_paise, method)
SELECT id, total_paise, ELT(1 + (id * 7) MOD 4, 'upi', 'card', 'netbanking', 'wallet')
FROM orders
WHERE id MOD 5 <> 0;

ANALYZE TABLE customers, orders, payments;

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, for sessions that compare versions.

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.
A session reads only this folder and the MySQL and Python programs it runs, and connects 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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

Using index, the plan on SELECT id, created_at FROM orders WHERE customer_id = 42, is a covering index: An index whose entries hold every column a query returns, so the engine never reads the row through the clustered index. EXPLAIN reports it as Using index. More in “Secondary, composite and covering indexes”: customer_created holds customer_id, created_at and (by InnoDB’s rule) the primary key id, so every column the query reads is in the index entries and the row itself is never fetched. Using where, the plan on WHERE note = 'order note 7', is the opposite: no index serves the filter, so the plan reads every row and the server tests the WHERE on each. Using filesort, on ORDER BY total_paise LIMIT 5 with the seek on customer_id, says the 42 rows that match have to be sorted after the seek, because the index does not keep them in total_paise order. Using temporary appears on the GROUP BY ship_city plan: no index starts with ship_city, so the server builds a temporary table of city counts.

A fifth note deserves its own paragraph. index condition pushdown: An optimization where the server hands a part of the WHERE clause that uses only index columns to the storage engine, which tests it on each index entry and fetches the full row only for the entries that pass; shown in EXPLAIN as Using index condition. is MySQL’s name for a filter the server pushes down into the storage engine; the engine then checks it on every index entry it reads and only fetches the full row when the filter passes. The plan then reads Using index condition in Extra; the recorded run of secondary, composite and covering indexes showed its effect, with 20 row reads instead of 1,000 for the same WHERE. On InnoDB the pushdown fires for secondary-index reads only, because a lookup through the clustered index returns the row in one step. The manual’s switch for it, index_condition_pushdown in optimizer_switch, is on by default.

The five notes form a short list a reader can keep in mind:

Extra What the server did Row reads
Using index read columns from index entries only, no row fetch 0 for the matched entries
Using index condition engine tested a pushed filter on entries, fetched passing rows fewer than the entries read
Using where fetched rows and tested the WHERE on each the rows the index returned
Using filesort sorted the matched rows after the seek one sort per plan
Using temporary built a temporary table (for GROUP BY, DISTINCT, some joins) depends on groups

`Using index` is a stronger claim than `Using the index`

Every plan that names a key is using that index. Only the plans whose Extra says Using index avoid the row read as well; a plan with Using index condition or Using where is reading rows for the entries that pass.

Checkpoint

Check: What the Extra column names

  1. Question 1 of 2 EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42 reports type ref, key customer_created and Extra: Using index. What does Using index tell you?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Every column the query reads is in the index entries; no row is fetched through the clustered index

    Using index names a covering read: the server stops at the index entries. Using where names a filter applied after fetching the row; Using index condition names a filter pushed to the engine and tested on each entry.

  2. Question 2 of 2 EXPLAIN SELECT id, total_paise FROM orders WHERE customer_id = 42 ORDER BY total_paise LIMIT 5 reports Extra: Using filesort. Why?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The index has no total_paise column, so the 42 rows have to be sorted after the seek

    Using filesort appears when the plan has to sort the rows after the seek, because the index does not keep them in the ORDER BY's order. Here total_paise is not in the index, so the 42 rows have to be sorted outside.

SHOW WARNINGS and EXPLAIN FOR CONNECTION show the live plan

The last column of a plan a reader needs is not in the plan itself: it is the rewritten query the optimizer actually priced. The server runs the rewrite step before it costs any access path, so a plan that looks puzzling (a type or a key_len that does not match the WHERE you typed) often reads easily against the shape the optimizer kept. SHOW WARNINGS right after EXPLAIN prints that shape as Note 1003: a text that the server generates from the query tree, not the SQL you ran, but close enough for a reader to compare.

The same session ends with an IN subquery and reads the warnings; its record is shown again here:

Note 1003 after EXPLAIN: the rewritten query

extra_flags_session.py

"""The Extra values this lesson teaches, each one from a short query against the lab, plus SHOW WARNINGS.

Using index (covering read), Using where (post-fetch filter), Using filesort (sort outside the index) and Using
temporary (a temporary table for GROUP BY). The last EXPLAIN turns an IN subquery into a semi-join and prints
SHOW WARNINGS Note 1003: the query as the optimizer rewrote it.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads lab_schema.sql quietly, then runs extra_flags.sql through
the mysql client.
"""

from mysql_session import Server

with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
    my.up()
    my.sql("CREATE DATABASE shop", flags=())
    my.mysql("lab_schema.sql", db="shop", flags=("-t", "--skip-comments"))
    my.mysql("extra_flags.sql")

Output

$ mysql -e "CREATE DATABASE shop"

$ mysql -t --skip-comments shop < lab_schema.sql
+----------------+---------+----------+----------+
| Table          | Op      | Msg_type | Msg_text |
+----------------+---------+----------+----------+
| shop.customers | analyze | status   | OK       |
| shop.orders    | analyze | status   | OK       |
| shop.payments  | analyze | status   | OK       |
+----------------+---------+----------+----------+

$ mysql -t -vvv --skip-comments < extra_flags.sql
--------------
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: customer_created
          key: customer_created
      key_len: 4
          ref: const
         rows: 42
     filtered: 100.00
        Extra: Using index
1 row in set, 1 warning

--------------
EXPLAIN SELECT COUNT(*) FROM orders WHERE note = 'order note 7'
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 10.00
        Extra: Using where
1 row in set, 1 warning

--------------
EXPLAIN SELECT id, total_paise FROM orders WHERE customer_id = 42 ORDER BY total_paise LIMIT 5
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ref
possible_keys: customer_created
          key: customer_created
      key_len: 4
          ref: const
         rows: 42
     filtered: 100.00
        Extra: Using filesort
1 row in set, 1 warning

--------------
EXPLAIN SELECT ship_city, COUNT(*) c FROM orders GROUP BY ship_city ORDER BY c DESC
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 100.00
        Extra: Using temporary; Using filesort
1 row in set, 1 warning

--------------
EXPLAIN SELECT c.name FROM customers c
  WHERE c.id IN (SELECT o.customer_id FROM orders o WHERE o.total_paise > 490000)
--------------

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: c
   partitions: NULL
         type: ALL
possible_keys: PRIMARY
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 1200
     filtered: 100.00
        Extra: NULL
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: <subquery2>
   partitions: NULL
         type: eq_ref
possible_keys: <auto_distinct_key>
          key: <auto_distinct_key>
      key_len: 4
          ref: shop.c.id
         rows: 1
     filtered: 100.00
        Extra: NULL
*************************** 3. row ***************************
           id: 2
  select_type: MATERIALIZED
        table: o
   partitions: NULL
         type: ALL
possible_keys: customer_created
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 50000
     filtered: 33.33
        Extra: Using where
3 rows in set, 1 warning

--------------
SHOW WARNINGS
--------------

*************************** 1. row ***************************
  Level: Note
   Code: 1003
Message: /* select#1 */ select `shop`.`c`.`name` AS `name` from `shop`.`customers` `c` semi join (`shop`.`orders` `o`) where ((`<subquery2>`.`customer_id` = `shop`.`c`.`id`) and (`shop`.`o`.`total_paise` > 490000))
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 extra_flags_session.py

extra_flags.sql

-- Four values of the Extra column, each one produced by a short query against the lab.
USE shop;

-- Using index: a covering read. customer_created holds customer_id, created_at and (by InnoDB) the primary key id,
-- so SELECT id, created_at never reaches a row.
EXPLAIN SELECT id, created_at FROM orders WHERE customer_id = 42\G

-- Using where: the server tests the WHERE after reading the row. Here no index serves the filter, so the plan
-- reads every row of the table (type ALL) and discards most.
EXPLAIN SELECT COUNT(*) FROM orders WHERE note = 'order note 7'\G

-- Using filesort: the plan reads 42 rows for customer_id = 42 and then sorts them by total_paise, which no index
-- holds in order. ORDER BY runs after the seek, outside the index.
EXPLAIN SELECT id, total_paise FROM orders WHERE customer_id = 42 ORDER BY total_paise LIMIT 5\G

-- Using temporary: GROUP BY on a column no index starts with, so the server builds a temporary table of city counts.
-- Here Using filesort also appears, because ORDER BY c is on a column that is computed, not stored.
EXPLAIN SELECT ship_city, COUNT(*) c FROM orders GROUP BY ship_city ORDER BY c DESC\G

-- SHOW WARNINGS: Note 1003 holds the query as the optimizer rewrote it. An IN subquery is turned into a semi-join
-- (semi join (orders)), and <subquery2> is the materialized distinct result of the subquery.
EXPLAIN SELECT c.name FROM customers c
  WHERE c.id IN (SELECT o.customer_id FROM orders o WHERE o.total_paise > 490000)\G
SHOW WARNINGS\G

lab_schema.sql

-- The lesson lab: 1,200 customers, 50,000 orders and 40,000 payments, built without RAND()
-- so that every run on every machine holds exactly the same rows.
SET SESSION cte_max_recursion_depth = 100000;

CREATE TABLE customers (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name          VARCHAR(60)  NOT NULL,
  city          VARCHAR(40)  NOT NULL,
  signed_up_on  DATE         NOT NULL,
  PRIMARY KEY (id),
  KEY city_signed (city, signed_up_on)
) ENGINE=InnoDB;

CREATE TABLE orders (
  id           INT UNSIGNED     NOT NULL AUTO_INCREMENT,
  customer_id  INT UNSIGNED     NOT NULL,
  created_at   DATETIME         NOT NULL,
  status       TINYINT UNSIGNED NOT NULL,
  total_paise  INT UNSIGNED     NOT NULL,
  ship_city    VARCHAR(40)      NOT NULL,
  note         VARCHAR(120)     NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY customer_created (customer_id, created_at)
) ENGINE=InnoDB;

-- payments has no index on order_id: an equi-join to orders has no index to use.
CREATE TABLE payments (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_id      INT UNSIGNED NOT NULL,
  amount_paise  INT UNSIGNED NOT NULL,
  method        VARCHAR(12)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

-- 1,200 customers in 8 cities (13 and 8 are coprime, so the cities cycle evenly).
INSERT INTO customers (name, city, signed_up_on)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1200
)
SELECT CONCAT('Customer ', LPAD(n, 4, '0')),
       ELT(1 + (n * 13) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       DATE_ADD('2025-01-01', INTERVAL (n * 7) MOD 365 DAY)
FROM seq;

-- 50,000 orders over the first 180 days of 2026. customer_id spreads over all 1,200 customers
-- (7919 is prime, so n * 7919 MOD 1200 cycles); total_paise is skewed, with one order in fifty large.
INSERT INTO orders (customer_id, created_at, status, total_paise, ship_city, note)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 50000
)
SELECT 1 + (n * 7919) MOD 1200,
       DATE_ADD(DATE_ADD('2026-01-01 00:00:00', INTERVAL (n * 311) MOD 180 DAY),
                INTERVAL (n * 1013) MOD 86400 SECOND),
       ELT(1 + (n * 3) MOD 4, 1, 2, 2, 3),
       CASE WHEN n MOD 50 = 0 THEN 400000 + (n * 97) MOD 200000
            ELSE 5000 + (n * 89) MOD 95000 END,
       ELT(1 + (n * 17) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       CONCAT('order note ', n MOD 997)
FROM seq;

-- 40,000 payments: the orders whose id is not a multiple of five are paid once.
INSERT INTO payments (order_id, amount_paise, method)
SELECT id, total_paise, ELT(1 + (id * 7) MOD 4, 'upi', 'card', 'netbanking', 'wallet')
FROM orders
WHERE id MOD 5 <> 0;

ANALYZE TABLE customers, orders, payments;

The plan has three rows: c is a full scan of customers, <subquery2> is an auto-created distinct key the server built for the semi-join, and o is a materialized scan of orders with the total_paise filter. The warning spells out the rewrite: semi join (orders) where (<subquery2>.customer_id = c.id and o.total_paise > 490000). The IN became a A join shape in which the server returns a row of the outer table at most once, however many rows of the inner table match: used for IN and EXISTS subqueries., which is both faster than the subquery and cheaper to read against the three-row plan.

For a plan of a query already running in another session, the server has a second statement: EXPLAIN FOR CONNECTION <id>, which reads the plan of the statement the given connection is still executing. The sixth session has two concurrent clients: a reporting one that starts a slow join and a dba one that reads its plan without stopping it.

EXPLAIN FOR CONNECTION: a plan read from another session

for_connection_session.py

"""EXPLAIN FOR CONNECTION: the plan of a statement already running in another session.

Two concurrent clients: a reporting session starts a join that runs for a few seconds, and a dba session reads its
plan without stopping it. EXPLAIN FOR CONNECTION gives an empty result for a connection that is not executing a
statement, so the dba first waits until performance_schema.threads shows the join in the executing state, asking from
its own open connection so that each check takes milliseconds.

A throwaway MySQL 8.4.11 server (mysql_session.py) loads lab_schema.sql, then the two mysql clients run against it.
"""

import sys
import time

from mysql_session import Server

SLOW = "SELECT COUNT(*) FROM customers c JOIN orders o ON o.ship_city = c.city"
EXECUTING = ("SELECT COUNT(*) FROM performance_schema.threads"
             " WHERE PROCESSLIST_COMMAND = 'Query' AND PROCESSLIST_STATE = 'executing'"
             " AND PROCESSLIST_INFO LIKE 'SELECT COUNT(*) FROM customers%'")

with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
    my.up()
    my.sql("CREATE DATABASE shop", flags=())
    my.mysql("lab_schema.sql", db="shop", flags=("-t", "--skip-comments"))

    reporting = my.client("reporting")
    reporting.run("USE shop")
    dba = my.client("dba")
    dba.run("USE shop")

    reporting.start(SLOW)
    deadline = time.monotonic() + 60
    while dba._value(EXECUTING) != "1":
        if time.monotonic() > deadline:
            sys.exit("the join was never seen executing")
        time.sleep(0.01)
    dba.run(f"EXPLAIN FOR CONNECTION {reporting.conn_id}")
    reporting.finish(timeout=300)
    reporting.close()
    dba.close()

Output

$ mysql -e "CREATE DATABASE shop"

$ mysql -t --skip-comments shop < lab_schema.sql
+----------------+---------+----------+----------+
| Table          | Op      | Msg_type | Msg_text |
+----------------+---------+----------+----------+
| shop.customers | analyze | status   | OK       |
| shop.orders    | analyze | status   | OK       |
| shop.payments  | analyze | status   | OK       |
+----------------+---------+----------+----------+

-- reporting
mysql> USE shop;
Database changed

-- dba
mysql> USE shop;
Database changed

-- dba
mysql> EXPLAIN FOR CONNECTION 12;
+----+-------------+-------+------------+------+---------------+-------------+---------+------------------+-------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key         | key_len | ref              | rows  | filtered | Extra       |
+----+-------------+-------+------------+------+---------------+-------------+---------+------------------+-------+----------+-------------+
|  1 | SIMPLE      | o     | NULL       | ALL  | NULL          | NULL        | NULL    | NULL             | 50000 |   100.00 | NULL        |
|  1 | SIMPLE      | c     | NULL       | ref  | city_signed   | city_signed | 162     | shop.o.ship_city |   150 |   100.00 | Using index |
+----+-------------+-------+------------+------+---------------+-------------+---------+------------------+-------+----------+-------------+
2 rows in set

-- reporting
mysql> SELECT COUNT(*) FROM customers c JOIN orders o ON o.ship_city = c.city;
+----------+
| COUNT(*) |
+----------+
|  7500000 |
+----------+
1 row in set

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

lab_schema.sql

-- The lesson lab: 1,200 customers, 50,000 orders and 40,000 payments, built without RAND()
-- so that every run on every machine holds exactly the same rows.
SET SESSION cte_max_recursion_depth = 100000;

CREATE TABLE customers (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  name          VARCHAR(60)  NOT NULL,
  city          VARCHAR(40)  NOT NULL,
  signed_up_on  DATE         NOT NULL,
  PRIMARY KEY (id),
  KEY city_signed (city, signed_up_on)
) ENGINE=InnoDB;

CREATE TABLE orders (
  id           INT UNSIGNED     NOT NULL AUTO_INCREMENT,
  customer_id  INT UNSIGNED     NOT NULL,
  created_at   DATETIME         NOT NULL,
  status       TINYINT UNSIGNED NOT NULL,
  total_paise  INT UNSIGNED     NOT NULL,
  ship_city    VARCHAR(40)      NOT NULL,
  note         VARCHAR(120)     NOT NULL DEFAULT '',
  PRIMARY KEY (id),
  KEY customer_created (customer_id, created_at)
) ENGINE=InnoDB;

-- payments has no index on order_id: an equi-join to orders has no index to use.
CREATE TABLE payments (
  id            INT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_id      INT UNSIGNED NOT NULL,
  amount_paise  INT UNSIGNED NOT NULL,
  method        VARCHAR(12)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB;

-- 1,200 customers in 8 cities (13 and 8 are coprime, so the cities cycle evenly).
INSERT INTO customers (name, city, signed_up_on)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 1200
)
SELECT CONCAT('Customer ', LPAD(n, 4, '0')),
       ELT(1 + (n * 13) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       DATE_ADD('2025-01-01', INTERVAL (n * 7) MOD 365 DAY)
FROM seq;

-- 50,000 orders over the first 180 days of 2026. customer_id spreads over all 1,200 customers
-- (7919 is prime, so n * 7919 MOD 1200 cycles); total_paise is skewed, with one order in fifty large.
INSERT INTO orders (customer_id, created_at, status, total_paise, ship_city, note)
WITH RECURSIVE seq (n) AS (
  SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 50000
)
SELECT 1 + (n * 7919) MOD 1200,
       DATE_ADD(DATE_ADD('2026-01-01 00:00:00', INTERVAL (n * 311) MOD 180 DAY),
                INTERVAL (n * 1013) MOD 86400 SECOND),
       ELT(1 + (n * 3) MOD 4, 1, 2, 2, 3),
       CASE WHEN n MOD 50 = 0 THEN 400000 + (n * 97) MOD 200000
            ELSE 5000 + (n * 89) MOD 95000 END,
       ELT(1 + (n * 17) MOD 8, 'Pune', 'Mumbai', 'Bengaluru', 'Chennai', 'Hyderabad', 'Jaipur', 'Kolkata', 'Kochi'),
       CONCAT('order note ', n MOD 997)
FROM seq;

-- 40,000 payments: the orders whose id is not a multiple of five are paid once.
INSERT INTO payments (order_id, amount_paise, method)
SELECT id, total_paise, ELT(1 + (id * 7) MOD 4, 'upi', 'card', 'netbanking', 'wallet')
FROM orders
WHERE id MOD 5 <> 0;

ANALYZE TABLE customers, orders, payments;

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, for sessions that compare versions.

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.
A session reads only this folder and the MySQL and Python programs it runs, and connects 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 = text.replace("\x07", "")   # the mysql client rings the terminal bell on an error; drop it
        text = re.sub(r" \(\d+\.\d{2,3} sec\)", "", text)  # 8.4 prints 2 decimals, 9.7 and 26.7 print 3
        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()


# ====================================================================================================================
# Concurrency for the MVCC and locking lessons (undo-logs-mvcc-and-purge, row-gap-next-key-locks).
# Two or more persistent `mysql` clients keep their own transactions open at the same time, so a session can show what
# one transaction sees of another's uncommitted or committed changes, and the record, gap and next-key locks a
# statement takes. One statement can be started without waiting (it blocks on a lock); an observer connection polls
# performance_schema until the wait is visible; the blocked statement then finishes (with its result or its
# ERROR 1205 / ERROR 1213). Output is masked so two runs are byte-identical.
# ====================================================================================================================
import threading
import pty
import termios
import struct
import fcntl

_PROMPT = "READYmk> "   # a distinctive mysql prompt we use as the boundary between statements


class Client:
    """A persistent interactive mysql client on a pseudo-terminal: one connection whose transaction lives across
    calls. Built by Server.client(name). run() sends one statement, waits for the next prompt and prints the result;
    start() sends a statement expected to block and returns at once; finish() waits for that blocked statement.
    A pseudo-terminal is used because the mysql client block-buffers its output to a plain pipe, which would hide the
    result until the process ends; on a terminal it flushes a prompt after every statement. Terminal echo is off (the
    statement is printed by the session, not by the terminal) and the window is made wide so tables never wrap."""

    def __init__(self, server, name):
        self.server = server
        self.name = name
        self.pty_fd, tty_fd = pty.openpty()
        attrs = termios.tcgetattr(tty_fd)
        attrs[3] = attrs[3] & ~termios.ECHO          # lflags: no echo of what we type
        termios.tcsetattr(tty_fd, termios.TCSANOW, attrs)
        fcntl.ioctl(tty_fd, termios.TIOCSWINSZ, struct.pack("HHHH", 100, 1000, 0, 0))  # 1000 columns, no wrap
        args = [str(server.bin / "mysql"), f"--defaults-file={server.client_cnf}",
                f"--prompt={_PROMPT}", "--skip-auto-rehash", "-t"]
        self.proc = subprocess.Popen(args, stdin=tty_fd, stdout=tty_fd, stderr=tty_fd,
                                     cwd=server.base, env=server.env, close_fds=True)
        os.close(tty_fd)
        self._buf = ""
        self._consumed = 0
        self._lock = threading.Lock()
        self._reader = threading.Thread(target=self._read, daemon=True)
        self._reader.start()
        self._read_until_prompt()          # discard the welcome banner up to the first prompt
        self.conn_id = self._value("SELECT CONNECTION_ID()")

    def _read(self):
        while True:
            try:
                data = os.read(self.pty_fd, 4096)
            except OSError:
                break
            if not data:
                break
            with self._lock:
                self._buf += data.decode("utf-8", "replace").replace("\r\n", "\n").replace("\r", "")

    def _send(self, sql):
        os.write(self.pty_fd, (sql.rstrip().rstrip(";") + ";\n").encode())

    def _read_until_prompt(self, timeout=60):
        deadline = time.time() + timeout
        while time.time() < deadline:
            with self._lock:
                idx = self._buf.find(_PROMPT, self._consumed)
                if idx != -1:
                    block = self._buf[self._consumed:idx]
                    self._consumed = idx + len(_PROMPT)
                    return block
            time.sleep(0.02)
        return None

    def _collect(self, sql, timeout=60):
        self._send(sql)
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: statement did not finish within {timeout}s: {sql}")
        return block

    def _value(self, sql):
        text = self.server.clean(self._collect(sql))
        cells = [l for l in text.splitlines() if l.startswith("|")]
        return cells[-1].split("|")[1].strip() if len(cells) >= 2 else ""

    def run(self, sql, note=None, errors=0):
        """Run a statement to completion and print it as the mysql client showed it."""
        out = self._collect(sql)
        self._print(sql, out, note)
        self._check(sql, out, errors)
        return out

    def start(self, sql, note=None):
        """Send a statement expected to block on a lock; return at once. finish() collects its output."""
        self._pending = (sql, note)
        self._send(sql)

    def finish(self, errors=0, timeout=60):
        """Wait for the statement start() sent and print it (its result, or its ERROR 1205/1213)."""
        sql, note = self._pending
        block = self._read_until_prompt(timeout=timeout)
        if block is None:
            sys.exit(f"{self.name}: blocked statement did not finish within {timeout}s: {sql}")
        self._print(sql, block, note)
        self._check(sql, block, errors)
        return block

    def _check(self, sql, out, errors):
        found = sum(1 for l in out.splitlines() if l.startswith("ERROR "))
        if found != errors:
            sys.exit(f"{self.name}: {found} errors on {sql!r}, expected {errors}")

    def _print(self, sql, out, note):
        print(f"-- {self.name}" + (f": {note}" if note else ""))
        print(f"mysql> {sql.rstrip().rstrip(';')};")
        text = self.server.clean(out).strip("\n")
        if text:
            print(text)
        print()

    def close(self):
        try:
            os.write(self.pty_fd, b"\\q\n")
        except OSError:
            pass
        try:
            self.proc.wait(timeout=10)
        except subprocess.TimeoutExpired:
            self.proc.terminate()
        try:
            os.close(self.pty_fd)
        except OSError:
            pass


# ---- Server helpers for the concurrency lessons --------------------------------------------------------------------
def _server_client(self, name):
    """A new persistent connection whose transaction lives across statements."""
    if not hasattr(self, "_clients"):
        self._clients = []
    c = Client(self, name)
    self._clients.append(c)
    return c


def _server_wait_for(self, sql, want, timeout=20):
    """Poll a single-value query (through a short-lived connection) until it returns `want` (as text)."""
    want = str(want)
    deadline = time.time() + timeout
    while time.time() < deadline:
        if self.query(sql) == want:
            return True
        time.sleep(0.05)
    sys.exit(f"wait_for timed out: {sql} never became {want}")


def _server_locks(self, note=None, where="1=1"):
    """Print performance_schema.data_locks, only the columns that do not change between runs, ordered stably."""
    sql = ("SELECT INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA "
           "FROM performance_schema.data_locks "
           f"WHERE OBJECT_SCHEMA='test' AND ({where}) "
           "ORDER BY INDEX_NAME, FIELD(LOCK_MODE,'X','X,GAP','X,REC_NOT_GAP','X,GAP,INSERT_INTENTION','S','S,GAP'), "
           "LOCK_DATA")
    self.sql(sql, note=note)


def _server_status_deadlock(self, note=None):
    """The LATEST DETECTED DEADLOCK section of SHOW ENGINE INNODB STATUS, trimmed to the parts that do not change
    between runs: the two transactions, the locks each holds and waits for, and which one was rolled back. The
    per-record hex dumps and the run-varying ids, times and thread handles are left out or masked."""
    out = self.output("mysql", "-E", "-e", "SHOW ENGINE INNODB STATUS", connect=True)
    lines = out.splitlines()
    try:
        a = next(i for i, l in enumerate(lines) if "LATEST DETECTED DEADLOCK" in l)
        b = next(i for i in range(a + 2, len(lines)) if lines[i].startswith("-----------"))
    except StopIteration:
        a, b = 1, len(lines)
    kept, i = [], a - 1
    while i < b:
        l = lines[i]
        if l.startswith("Record lock, heap no"):      # skip the record's hex field dump up to the blank line
            while i < b and lines[i].strip():
                i += 1
            continue
        kept.append(l)
        i += 1
    text = "\n".join(kept)
    text = re.sub(r"^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d .*$", "<date and time> <thread>", text, flags=re.M)
    text = re.sub(r"TRANSACTION \d+,", "TRANSACTION <id>,", text)
    text = re.sub(r"ACTIVE \d+ sec", "ACTIVE <n> sec", text)
    text = re.sub(r"heap size \d+", "heap size <n>", text)
    text = re.sub(r"MySQL thread id \d+, OS thread handle \w+, query id \d+",
                  "MySQL thread id <tid>, OS thread handle <h>, query id <q>", text)
    text = re.sub(r"trx id \d+", "trx id <id>", text)
    text = re.sub(r"\n{3,}", "\n\n", text)
    if note:
        print(f"-- {note}")
    print("mysql> SHOW ENGINE INNODB STATUS\\G   (LATEST DETECTED DEADLOCK section)")
    print(self.clean(text).strip("\n"))
    print()


Server.client = _server_client
Server.wait_for = _server_wait_for
Server.locks = _server_locks
Server.status_deadlock = _server_status_deadlock

The reporting client starts a COUNT(*) FROM customers JOIN orders ... that pairs every order with the customers of its city and runs for a few seconds; the dba client waits until performance_schema.threads shows that statement in the executing state and then reads its plan with EXPLAIN FOR CONNECTION: A statement that returns the plan of a query already running in another session, read from a second connection without stopping the first or running the query again; needs the privileges to run the statement, plus PROCESS when the connection belongs to another user.. The wait comes first because a connection that is not executing a statement gives an empty result. The output is the plan the server is using for that statement, not a new one: a two-row traditional plan with type ALL on orders and type ref on customers through city_signed. The statement needs the same privileges the explained statement would need (for a SELECT, the SELECT privilege on its tables) and, additionally, the PROCESS privilege when the running statement belongs to another user.

Catch a slow query's plan on your own server

Open a second connection, run SELECT CONNECTION_ID(); in the slow one, keep that id, then run EXPLAIN FOR CONNECTION <id>; in the second one while the slow query is still active. The plan comes back in milliseconds; no index is read, no row is fetched by the EXPLAIN itself.

Checkpoint

Check: Warnings and FOR CONNECTION

  1. Question 1 of 2 Right after EXPLAIN SELECT c.name FROM customers c WHERE c.id IN (SELECT ... FROM orders), SHOW WARNINGS prints Level Note, Code 1003, Message /* select#1 */ select ... from customers c semi join (orders) .... What does that message hold?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The query as the optimizer rewrote it before costing

    SHOW WARNINGS right after EXPLAIN prints Note 1003, the rewritten query. It is the only way to see the shape the optimizer actually priced; a plan surprises stop making sense when the rewrite does not read as you expected.

  2. Question 2 of 2 A reporting query has already been running for ten minutes on connection 12 and is still active. You want its plan without stopping it. Which statement reads it, and from where?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: EXPLAIN FOR CONNECTION 12, from another connection with the right privilege

    EXPLAIN FOR CONNECTION returns the plan of the statement already running in the named connection, read from another session. It needs the same privileges the explained statement would need (SELECT on the tables for a SELECT) and, additionally, the PROCESS privilege when the running statement belongs to another user.

Practise: read the columns

Questions on the six recorded sessions, one column of EXPLAIN at a time: type, key and key_len, rows and filtered, Extra, with SHOW WARNINGS and EXPLAIN FOR CONNECTION for the plan of a statement already running elsewhere.

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 What does EXPLAIN do that EXPLAIN ANALYZE does not?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: Return the plan without running the query, so no rows are read

    EXPLAIN is the plan without the run: it is cheap and safe on any query. Use EXPLAIN ANALYZE when the run's cost is affordable and the actual times matter.

  2. Question 2 of 13 A deployment runs MySQL 8.4 in one region and MySQL 9.7 in another. A script captures EXPLAIN output and expects a stable shape. What should the script do?

    Choose every answer that is right.

    Show the answer: Question 2 of 13

    Answer:

    • Store EXPLAIN FORMAT=JSON output and parse the plan from it
    • Name the format in each EXPLAIN, for example EXPLAIN FORMAT=TRADITIONAL ...

    The default format is TRADITIONAL on MySQL 8.4 and TREE on MySQL 9.7 and 26.x. A script that captures plans names its format or asks for FORMAT=JSON, which has a stable shape across versions.

  3. Question 3 of 13 Which plan matches a join row to one row of the other table, guaranteed by the index?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: eq_ref

    eq_ref is the best join access type. ref is the equivalent for a non-unique index: the seek can reach several rows, not one.

  4. Question 4 of 13 A dashboard query runs type ALL on a 5,000-row configuration table and takes 1 ms. Is that worth fixing?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: Not here. type ALL on a small table that fits in memory is fast; keep the plan and move on

    The access type ladder names shapes, not quality. A small table scan is fine; a large table scan is not. A careful reading of a plan starts with the row counts and the service's budget.

  5. Question 5 of 13 An index is on (status INT NULL). EXPLAIN of WHERE status = 2 reports type ref, key_len X. What is X in bytes?

    Type a number.

    Show the answer: Question 5 of 13

    Answer: 5

    INT alone is 4 bytes. The column is nullable, so the key carries one more byte, the NULL flag, before the value. 4 + 1 is 5 bytes.

  6. Question 6 of 13 An index is on (a INT NOT NULL, b DATE NOT NULL, c INT NOT NULL). A query tests a = 1, b BETWEEN two dates and c = 7. What does EXPLAIN report for key_len?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: 7 bytes

    key_len is the length of the key the plan uses. A column after a range cannot narrow the seek, so its bytes are not counted; MySQL can still test it on each entry, but the seek itself stops at the range column.

  7. Question 7 of 13 Which gap between EXPLAIN and the real run is a signal to tune the server, as opposed to a signal to accept?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: rows times filtered reports 16,665 and the run reads 544 rows for a filter the index does not serve

    A row-count gap on an unindexed filter is the usual cue for a histogram or a new index. Equal estimates mean the optimizer already has the information it needs, so the gap you see is small.

  8. Question 8 of 13 SELECT ship_city, COUNT(*) c FROM orders GROUP BY ship_city ORDER BY c DESC runs on a table with no index on ship_city. Which Extra values appear in EXPLAIN?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: Using temporary; Using filesort

    A GROUP BY on an unindexed column builds a temporary table of groups and sorts it by the ORDER BY. Both Using temporary and Using filesort appear; a covering index on (ship_city) would remove both.

  9. Question 9 of 13 A plan runs faster than you expected but reads type range with key_len 9 against a composite index you believed matched only key_len 4. What do you read next, in one minute, before you touch anything?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: SHOW WARNINGS right after the EXPLAIN: Note 1003 holds the rewritten query

    A surprising plan starts with the rewritten query. Note 1003 after EXPLAIN says what the optimizer actually cost; EXPLAIN FOR CONNECTION reads the plan of a running statement; EXPLAIN ANALYZE is the heaviest tool and the last to reach for.

  10. Question 10 of 13 Two recorded plans on the same 50,000-row orders table both report rows 50000, one with type index and the other with type ALL. What is the difference between them?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: type index scans the whole index; type ALL scans the whole table

    The ladder's two scan values differ in what they scan. type index walks every entry of one index, which is cheaper than the table when the index is narrow and covers the query; type ALL walks the rows themselves.

  11. Question 11 of 13 Where does the number in the rows column of a plan come from?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: From the index statistics, or the table's stored row count when the step is a scan

    rows and filtered are both estimates formed before the query runs. Reading them as measurements is the first mistake; the lesson's recorded scan says 50,000 and 33.33 % where the true answer is 544 rows.

  12. Question 12 of 13 A hot query's plan reports type ref with Extra Using where, and the service's latency budget is nearly spent on it. Which Extra value is worth aiming for, and what change earns it?

    Choose one answer.

    Show the answer: Question 12 of 13

    Answer: Using index: add the columns the query reads to the index, so no row has to be fetched

    Read Extra as the work left after the seek. Using where is a server-side filter over fetched rows; the cheaper shape is a covering read, and the change that earns it is putting every column the query reads into the index.

  13. Question 13 of 13 You want to read the plan of a statement that another user is running, with EXPLAIN FOR CONNECTION. Which privilege does the server require beyond access to the tables?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: PROCESS

    EXPLAIN FOR CONNECTION reads the plan the server already built for a running statement. Your own connections need only the usual table access; another user's statement needs PROCESS as well.

Then a short Python exercise: given the columns of an index and the number of leading columns the plan uses, write a function that returns the number of bytes EXPLAIN would print under key_len. The sample tests mirror the four plans the lesson recorded (4, 9, 162, 165), plus a nullable INT and a short VARCHAR, whose length prefix is two bytes like every other VARCHAR key part.

Exercise · Easy · Python

Work out key_len from a key's columns

Write one function in key_len.py:

  • key_len(parts, used) returns the number of bytes EXPLAIN would print under key_len for a plan that uses the first used columns of an index whose columns are parts.

Each item of parts is a dictionary that names one column of the index. Its keys are:

  • "type": one of "TINYINT", "SMALLINT", "INT", "BIGINT", "DATE", "DATETIME", "CHAR", "VARCHAR".
  • "length": the character count, only for "CHAR" and "VARCHAR".
  • "bytes_per_char": the maximum bytes a character takes (1 for latin1, 4 for utf8mb4), only for "CHAR" and "VARCHAR".
  • "nullable": True for a column declared without NOT NULL, else False.

Rules the server uses, from the manual:

  • Fixed-width columns add their byte count: TINYINT 1, SMALLINT 2, INT 4, BIGINT 8, DATE 3, DATETIME 5 (zero fractional seconds, which this exercise assumes).
  • CHAR(N) in a character set adds N * bytes_per_char bytes.
  • VARCHAR(N) in a character set adds N * bytes_per_char bytes plus a 2-byte length prefix. The prefix is two bytes for every VARCHAR key part, whatever that product is: the lesson's session reports key_len 12 for VARCHAR(10) in latin1 and 302 for VARCHAR(300). (A stored row is different: there a value that needs at most 255 bytes carries a one-byte prefix.)
  • A nullable column adds one more byte, a NULL flag the server reads before the value.
  • used tells the function how many of the leading columns of the index the plan uses: 0 for a scan that does not seek, up to len(parts).

Four test cases match the plans this lesson recorded (4 for customer_id alone, 9 for customer_id with created_at, 162 for city alone, 165 for city with signed_up_on); two more cover a nullable INT and the short VARCHAR whose prefix is two bytes all the same.

Starter code · key_len.py

def key_len(parts, used):
    """Return the key_len EXPLAIN would print in bytes.

    parts  list of column descriptions (dict); see prompt.md for the keys.
    used   number of leading columns of the index the plan uses.
    """
    return 0
The sample tests · test_key_len.py
from key_len import key_len


def test_customer_id_only():
    """INT NOT NULL alone adds 4 bytes, as the lab's customer_created plan reports."""
    parts = [
        {"type": "INT", "nullable": False},
        {"type": "DATETIME", "nullable": False},
    ]
    assert key_len(parts, 1) == 4


def test_customer_id_and_created_at():
    """INT NOT NULL plus DATETIME NOT NULL adds 4 + 5 = 9 bytes."""
    parts = [
        {"type": "INT", "nullable": False},
        {"type": "DATETIME", "nullable": False},
    ]
    assert key_len(parts, 2) == 9


def test_city_only_varchar40_utf8mb4():
    """VARCHAR(40) NOT NULL in utf8mb4 adds 40 * 4 + 2 = 162 bytes (the 2-byte length prefix)."""
    parts = [
        {"type": "VARCHAR", "length": 40, "bytes_per_char": 4, "nullable": False},
        {"type": "DATE", "nullable": False},
    ]
    assert key_len(parts, 1) == 162


def test_city_and_signed_up_on():
    """VARCHAR(40) + DATE adds 162 + 3 = 165 bytes."""
    parts = [
        {"type": "VARCHAR", "length": 40, "bytes_per_char": 4, "nullable": False},
        {"type": "DATE", "nullable": False},
    ]
    assert key_len(parts, 2) == 165


def test_nullable_int():
    """INT NULL adds 4 bytes for the value plus 1 byte for the NULL flag."""
    parts = [{"type": "INT", "nullable": True}]
    assert key_len(parts, 1) == 5


def test_short_varchar_two_byte_prefix():
    """VARCHAR(10) in latin1 adds 10 bytes plus the 2-byte prefix every VARCHAR key part carries: 12 bytes."""
    parts = [{"type": "VARCHAR", "length": 10, "bytes_per_char": 1, "nullable": False}]
    assert key_len(parts, 1) == 12


def test_no_columns_used():
    """A plan that does not seek has key_len 0 even when the index has columns."""
    parts = [{"type": "INT", "nullable": False}]
    assert key_len(parts, 0) == 0
A hint

Sum the bytes column by column, up to used. For each column, start from a per-type size: a dictionary {"TINYINT": 1, "SMALLINT": 2, ...} keeps the fixed widths; CHAR and VARCHAR pick their width from length * bytes_per_char. VARCHAR adds its length prefix on top, and in a key part that prefix is always 2. If nullable is true for a column, add one more byte.

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

Interview questions

Four questions on reading a plan. The first two are answered here; the two under the questions block go deeper on their own page.

Warm-up: a plan reports type ALL and rows 10. Is that worth worrying about, and when?

Not here, usually. type ALL is the manual’s worst value, but it names the shape, not the cost. Reading 10 rows of a small table from memory is a handful of microseconds and cheaper than any index on it, because the index pays its own cost on every write. The sign the plan matters is the row count next to the type and the time the statement takes against the service’s budget: a type ALL with rows 10 on a configuration table is fine; a type ALL with rows 50,000,000 on a hot query is not. Measure the time before you change anything, and compare it with the write cost of a new index.

customer_id is INT NOT NULL and created_at is DATETIME NOT NULL. EXPLAIN prints key_len 9 for a plan on an index that leads with those two columns. Which columns did the server really use?

Both of them. INT NOT NULL is 4 bytes and DATETIME NOT NULL (no fractional seconds) is 5 bytes, so 9 is the sum: the seek narrowed on customer_id and on created_at. If the WHERE had used customer_id alone, the server would have printed key_len 4; a nullable column would have added one more byte, and a VARCHAR would have added its length prefix on top. The exercise of this lesson works that sum out for a few other shapes.

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

  1. Mid to senior

    EXPLAIN of SELECT COUNT() FROM orders WHERE total_paise > 490000 on a 50,000-row table reports type ALL, rows 50000 and filtered 33.33, but SELECT COUNT() returns 544. What does rows times filtered mean, and when is the gap big enough to force a change to the schema or the plan?

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

Flashcards · 6 cards

Recall it, then check

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

  1. Which format does EXPLAIN print by default on MySQL 8.4, and on 9.7?

    Whatever @@explain_format says: TRADITIONAL on 8.4, TREE on 9.7 and 26.x, so a script that parses plans names its format. EXPLAIN alone never runs the query.

  2. What does EXPLAIN's type column rank, and is ALL always wrong?

    The access path, from const (a single row resolved up front) to ALL (every row of the table). ALL is the right plan when the statement needs most of a small table; the row count beside it, not the word, shows how heavy a plan is.

  3. How is EXPLAIN's key_len added up?

    Over the index columns of the leftmost prefix the plan reaches: each adds the bytes of its type, a nullable column one byte more, and a VARCHAR two bytes of length prefix whatever its maximum length.

  4. Are EXPLAIN's rows and filtered columns counts from a run?

    No, both are estimates formed before anything runs. The lab's full scan reported 50,000 rows and filtered 33.33 % where the true share was 1.1 %, a thirty-fold gap that a histogram would close.

  5. What do Using where, Using filesort and Using temporary mean in EXPLAIN's Extra column?

    Using where: the server filters rows after reading them. Using filesort: the matched rows are sorted. Using temporary: a temporary table is built. Using index means a covering read, and Using index condition means pushdown.

  6. What do SHOW WARNINGS after EXPLAIN and EXPLAIN FOR CONNECTION add?

    SHOW WARNINGS prints Note 1003, the rewritten query the optimizer actually costed. EXPLAIN FOR CONNECTION with a connection id shows the plan of a statement that another session is running right now.

Key takeaways

  • EXPLAIN returns the plan without running the query; the format follows @@explain_format, which is TRADITIONAL on MySQL 8.4 and TREE on 9.7 and 26.x, so a stable script names its format.
  • The type column ranks the access path from const (a resolved single row) to ALL (every row of the table), and the row count next to it, not the word, says how heavy the plan really is.
  • key_len sums the bytes of the index columns the plan reaches, from the leftmost one; a nullable column adds one more byte, and a VARCHAR adds a two-byte length prefix whether its maximum is 10 bytes or 300.
  • rows and filtered are estimates: the lab’s full scan reports 50,000 and 33.33 % against a true share of 1.1 %, a thirty-fold gap that a histogram would close.
  • The Extra column names what runs after the seek: Using index is a covering read with no row fetch, Using index condition is pushdown, Using where is a server-side filter, Using filesort is a sort of the matched rows, Using temporary is a temporary table.
  • SHOW WARNINGS right after EXPLAIN prints Note 1003, the rewritten query the optimizer costed, and EXPLAIN FOR CONNECTION <id> reads the plan of a statement already running in another session.

Terms in this lesson

access type
The value in the type column of EXPLAIN, naming how MySQL reaches a table: const for one resolved row, eq_ref/ref/range for an index seek, index for an index scan and ALL for a table scan, best to worst.
EXPLAIN FOR CONNECTION
A statement that returns the plan of a query already running in another session, read from a second connection without stopping the first or running the query again; needs the privileges to run the statement, plus PROCESS when the connection belongs to another user.
filtered estimate
The percentage in the filtered column of EXPLAIN, the fraction of rows the optimizer expects the WHERE to keep after the step. rows times filtered divided by 100 is the row count the optimizer compares between candidate plans.
index condition pushdown
An optimization where the server hands a part of the WHERE clause that uses only index columns to the storage engine, which tests it on each index entry and fetches the full row only for the entries that pass; shown in EXPLAIN as Using index condition.
key_len
The number of bytes of the chosen index EXPLAIN says a plan uses. Each column of the leftmost prefix the WHERE fixes adds its own bytes; a nullable column adds one more byte, and a VARCHAR key part adds a two-byte length prefix.

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.