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.
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.
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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):
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
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_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:
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
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.
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
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.
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
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:
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.
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
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.
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 underkey_lenfor a plan that uses the firstusedcolumns of an index whose columns areparts.
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 forlatin1, 4 forutf8mb4), only for"CHAR"and"VARCHAR"."nullable":Truefor a column declared withoutNOT NULL, elseFalse.
Rules the server uses, from the manual:
- Fixed-width columns add their byte count:
TINYINT1,SMALLINT2,INT4,BIGINT8,DATE3,DATETIME5 (zero fractional seconds, which this exercise assumes). CHAR(N)in a character set addsN * bytes_per_charbytes.VARCHAR(N)in a character set addsN * bytes_per_charbytes plus a 2-byte length prefix. The prefix is two bytes for everyVARCHARkey part, whatever that product is: the lesson's session reportskey_len12 forVARCHAR(10)inlatin1and 302 forVARCHAR(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.
usedtells the function how many of the leading columns of the index the plan uses: 0 for a scan that does not seek, up tolen(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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
-
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?
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
EXPLAINreturns the plan without running the query; the format follows@@explain_format, which isTRADITIONALon MySQL 8.4 andTREEon 9.7 and 26.x, so a stable script names its format.- The
typecolumn ranks the access path fromconst(a resolved single row) toALL(every row of the table), and the row count next to it, not the word, says how heavy the plan really is. key_lensums the bytes of the index columns the plan reaches, from the leftmost one; a nullable column adds one more byte, and aVARCHARadds a two-byte length prefix whether its maximum is 10 bytes or 300.rowsandfilteredare 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
Extracolumn names what runs after the seek:Using indexis a covering read with no row fetch,Using index conditionis pushdown,Using whereis a server-side filter,Using filesortis a sort of the matched rows,Using temporaryis a temporary table. SHOW WARNINGSright afterEXPLAINprints Note 1003, the rewritten query the optimizer costed, andEXPLAIN 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
- MySQL 8.4 Reference Manual: 10.8.1 Optimizing Queries with EXPLAIN (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.8.2 EXPLAIN Output Format (Oracle Corporation)
- MySQL 8.4 Reference Manual: 15.8.2 EXPLAIN Statement (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.2.1.6 Index Condition Pushdown Optimization (Oracle Corporation)
- MySQL 8.4 Reference Manual: 13.1 Data Type Storage Requirements (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.1.8 Server System Variables (explain_format) (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.1 Optimization Overview (Oracle Corporation)
- MySQL 9.7 Release Notes: Changes in MySQL 9.0.0 (explain_format default) (Oracle Corporation)
- MySQL 8.4 Reference Manual: 15.7.7.19 SHOW WARNINGS Statement (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.8.4 Obtaining Execution Plan Information for a Named Connection (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.14.3 General Thread States (Oracle Corporation)
- MySQL 8.4 Reference Manual: 29.12.22.8 The threads Table (Oracle Corporation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress