MySQL & MariaDB in Depth Module 3 – Schema design, indexes and server-side objects
Partitioning in MySQL: pruning and retention
InnoDB partitioning in MySQL 8.4: RANGE, LIST, HASH and KEY, pruning proved in EXPLAIN, and retiring old months with DROP and EXCHANGE PARTITION.
What you will learn
- Choose RANGE, LIST, HASH or KEY partitioning for a stated access pattern
- Verify partition pruning in EXPLAIN and rewrite the conditions that defeat it
- Manage monthly retention with REORGANIZE, EXCHANGE and DROP PARTITION
- Explain the limits partitioning puts on unique keys, foreign keys and partition counts
Before you start
What you’ll learn · Before you start
What you will learn
- Choose RANGE, LIST, HASH or KEY partitioning for a stated access pattern
- Verify partition pruning in EXPLAIN and rewrite the conditions that defeat it
- Manage monthly retention with REORGANIZE, EXCHANGE and DROP PARTITION
- Explain the limits partitioning puts on unique keys, foreign keys and partition counts
Before you start
On this page
Removing the oldest month from a table of 18,100 events took a DELETE that deleted 3,100 rows one at a time and wrote 275,891 bytes to the binary log: The server's log of committed changes, a numbered series of files plus an index file, written as events in commit order. Replicas apply it, and a restore replays it to bring a backup up to date. More in “The binary log and mysqlbinlog”. Dropping the same month as a partition wrote 237 bytes and deleted no row one by one, and only the first figure grows with the size of a month.
Partitioning splits one InnoDB table into several tablespace: One or more files holding InnoDB pages. A table's data lives in a file-per-table, general or system tablespace; undo and temporary tablespaces belong to the server itself and hold no user tables. More in “InnoDB architecture: pages, tablespaces, threads” behind one name and sends each row to one of them by a partitioning expression: The column list or integer expression of a table's PARTITION BY clause, such as RANGE COLUMNS (created_on) or HASH (account_id), whose value for a row decides which partition stores that row., here the date column. Nine sessions recorded on MySQL 8.4.11 show how RANGE, LIST, HASH and KEY place rows, how EXPLAIN’s partitions column proves which partitions a query reads and what defeats it, which keys a partitioned table refuses, and a monthly retention runbook built from REORGANIZE, EXCHANGE and DROP PARTITION. Plans in general are the subject of reading EXPLAIN in MySQL.
One table name, one tablespace per partition
The lesson’s table holds 100 events a day for the first 181 days of 2025, 18,100 rows. It is declared PARTITION BY RANGE COLUMNS (created_on) with one partition a month, p2025_01 to p2025_06, and a seventh, pmax, whose bound is MAXVALUE. A row goes to the first partition whose VALUES LESS THAN bound is greater than its created_on, so a bound never belongs to its own partition: it opens the next one.
| Partition | VALUES LESS THAN |
Holds | Rows |
|---|---|---|---|
p2025_01 |
'2025-02-01' |
every date before February | 3,100 |
p2025_02 |
'2025-03-01' |
February | 2,800 |
p2025_03 |
'2025-04-01' |
March | 3,100 |
p2025_04 |
'2025-05-01' |
April | 3,000 |
p2025_05 |
'2025-06-01' |
May | 3,100 |
p2025_06 |
'2025-07-01' |
June | 3,000 |
pmax |
MAXVALUE |
July onward: the catch-all | 0 |
The primary key is (id, created_on), not id alone, for a reason the section on keys gives. The first session asks the server what it keeps for the table, and where a new row goes:
layout_session.py
"""One table, seven partitions: what the server keeps for each, and where a new row goes.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, runs layout.sql through the mysql
client, and then lists the files of the app database: every partition is a tablespace with an .ibd file of its own.
"""
from mysql_session import Server
# More sample pages than any partition has leaf pages, so the statistics are the same on every run.
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("layout.sql", errors=1)
my.listing("data/app") Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments --force < layout.sql
--------------
SELECT PARTITION_NAME AS name, PARTITION_METHOD AS method, PARTITION_EXPRESSION AS expr,
PARTITION_DESCRIPTION AS less_than, TABLE_ROWS AS estimate
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'app' AND TABLE_NAME = 'events'
ORDER BY PARTITION_ORDINAL_POSITION
--------------
+----------+---------------+--------------+--------------+----------+
| name | method | expr | less_than | estimate |
+----------+---------------+--------------+--------------+----------+
| p2025_01 | RANGE COLUMNS | `created_on` | '2025-02-01' | 3100 |
| p2025_02 | RANGE COLUMNS | `created_on` | '2025-03-01' | 2800 |
| p2025_03 | RANGE COLUMNS | `created_on` | '2025-04-01' | 3100 |
| p2025_04 | RANGE COLUMNS | `created_on` | '2025-05-01' | 3000 |
| p2025_05 | RANGE COLUMNS | `created_on` | '2025-06-01' | 3100 |
| p2025_06 | RANGE COLUMNS | `created_on` | '2025-07-01' | 3000 |
| pmax | RANGE COLUMNS | `created_on` | MAXVALUE | 0 |
+----------+---------------+--------------+--------------+----------+
7 rows in set
--------------
SELECT COUNT(*) FROM events PARTITION (p2025_01)
--------------
+----------+
| COUNT(*) |
+----------+
| 3100 |
+----------+
1 row in set
--------------
SELECT COUNT(*) FROM events PARTITION (p2025_02, p2025_03)
--------------
+----------+
| COUNT(*) |
+----------+
| 5900 |
+----------+
1 row in set
--------------
INSERT INTO events PARTITION (p2025_01) VALUES (900001, '2025-02-14', 7, 'view', SHA2(900001, 256))
--------------
ERROR 1748 (HY000) at line 18: Found a row not matching the given partition set
--------------
INSERT INTO events VALUES (900001, '2025-02-14', 7, 'view', SHA2(900001, 256))
--------------
Query OK, 1 row affected
--------------
SELECT id, created_on FROM events PARTITION (p2025_02) WHERE id = 900001
--------------
+--------+------------+
| id | created_on |
+--------+------------+
| 900001 | 2025-02-14 |
+--------+------------+
1 row in set
Bye
$ ls -p data/app
events#p#p2025_01.ibd events#p#p2025_02.ibd events#p#p2025_03.ibd
events#p#p2025_04.ibd events#p#p2025_05.ibd events#p#p2025_06.ibd
events#p#pmax.ibd
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 layout_session.py
layout.sql
-- The events table of events_load.sql: one name over seven partitions.
USE app;
-- information_schema.PARTITIONS lists each partition and the bound that routes rows to it. TABLE_ROWS is an
-- estimate InnoDB keeps; ANALYZE TABLE has just read every page, so here it matches the exact counts.
SELECT PARTITION_NAME AS name, PARTITION_METHOD AS method, PARTITION_EXPRESSION AS expr,
PARTITION_DESCRIPTION AS less_than, TABLE_ROWS AS estimate
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'app' AND TABLE_NAME = 'events'
ORDER BY PARTITION_ORDINAL_POSITION;
-- Partition selection reads only the partitions it names, and counts their rows exactly.
SELECT COUNT(*) FROM events PARTITION (p2025_01);
SELECT COUNT(*) FROM events PARTITION (p2025_02, p2025_03);
-- A row goes where its created_on value says. Naming another partition for it is refused; without a name, the
-- server routes it to p2025_02 itself.
INSERT INTO events PARTITION (p2025_01) VALUES (900001, '2025-02-14', 7, 'view', SHA2(900001, 256));
INSERT INTO events VALUES (900001, '2025-02-14', 7, 'view', SHA2(900001, 256));
SELECT id, created_on FROM events PARTITION (p2025_02) WHERE id = 900001; events_load.sql
-- The lesson's events table: six months of 2025, January to June, one partition a month on created_on, and a
-- catch-all partition for later dates. 100 events a day, made by arithmetic, so every run loads the same 18,100 rows.
CREATE DATABASE app;
USE app;
-- Every PRIMARY KEY and UNIQUE KEY of a partitioned table must contain the partitioning column, so the key is
-- (id, created_on), not id alone.
CREATE TABLE events (
id BIGINT NOT NULL,
created_on DATE NOT NULL,
account_id INT NOT NULL,
kind VARCHAR(10) NOT NULL,
payload CHAR(64) NOT NULL,
PRIMARY KEY (id, created_on)
)
PARTITION BY RANGE COLUMNS (created_on) (
PARTITION p2025_01 VALUES LESS THAN ('2025-02-01'),
PARTITION p2025_02 VALUES LESS THAN ('2025-03-01'),
PARTITION p2025_03 VALUES LESS THAN ('2025-04-01'),
PARTITION p2025_04 VALUES LESS THAN ('2025-05-01'),
PARTITION p2025_05 VALUES LESS THAN ('2025-06-01'),
PARTITION p2025_06 VALUES LESS THAN ('2025-07-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
-- Row i (0 to 18,099) falls on day i DIV 100 after the first of January: 100 rows a day for 181 days.
SET SESSION cte_max_recursion_depth = 20000;
INSERT INTO events
WITH RECURSIVE n (i) AS (SELECT 0 UNION ALL SELECT i + 1 FROM n WHERE i < 18099)
SELECT i + 1, DATE '2025-01-01' + INTERVAL (i DIV 100) DAY, i MOD 500 + 1,
ELT(i MOD 4 + 1, 'login', 'view', 'click', 'purchase'), SHA2(i, 256)
FROM n;
-- Fresh statistics for every partition, so EXPLAIN's estimates are the same on every run.
ANALYZE TABLE events; mysql_session.py
"""Throwaway MySQL 8.4.11 servers for the examples of this lesson.
Every session creates a server of its own: `mysqld --initialize-insecure` makes a new data directory inside a new
folder in the examples folder, the server starts on it, the session runs `mysql` client scripts and MySQL's programs
against it and prints what they printed, and then the server is stopped and the folder deleted, also when something
fails. The server is the pinned MySQL 8.4.11 from the mise installations next to the Python that runs this file;
Server(basedir=...) runs the programs of another unpacked MySQL tarball instead (dump_defaults.py uses it).
Safety: the server listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system
reports as unused; the X Protocol listener is off (--mysqlx=OFF); its Unix socket, temporary files, error log and
import/export folder all stay inside the run folder. Right after the first start the session gives root a password made
up for the run (never printed) and connects only with a client option file that holds it, so no password ever appears
on a command line. Every program starts with --no-defaults or --defaults-file, so no option file of the computer
(/etc/my.cnf, ~/.my.cnf) can change a result, and HOME points at the run folder, so ~/.mylogin.cnf is not read either.
A printed mysqld command shows the options that matter to the lesson; the session also passes, on the command line,
the folders it keeps inside the run folder (--basedir, --tmpdir, --log-error, --pid-file, --secure-file-priv) and
the port, which is why performance_schema.variables_info reports those variables as COMMAND_LINE.
learn:verify's run sandbox cannot read the home folder, where mise keeps MySQL, so these sessions are recorded with
LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.
The output is exactly what the programs printed, with these substitutions so that a record never shows the computer it
ran on and is the same on every run: the run folder is shown as ".", the folder of MySQL's programs is left out of
their paths and MySQL's own folder is shown as <basedir>, the computer's name is shown as "lab-host" and the operating-system user's name as "learner", the time a
statement took (the client's "(0.01 sec)") is left out, the time stamp SET PERSIST writes into mysqld-auto.cnf is
shown as <when it was set>, error-log lines lose their timestamp and thread number, and a session can name more values
that change between runs with my.mask(pattern, replacement), which then prints in their place. A client table whose cells
change is drawn again so that its borders still line up.
The sessions never print the password, and the run's port prints as <port>. A session stops with status 1 when a command that should have worked
fails, or when a script reports a different number of errors than the session expects, so a run that went wrong is
never recorded as if it had worked.
"""
import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path
MYSQL_VERSION = "8.4.11"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
PINNED = INSTALLS / "mysql" / MYSQL_VERSION
OS_USER = pwd.getpwuid(os.geteuid()).pw_name
HOST = socket.gethostname().split(".")[0]
# 2026-10-17T16:50:01.123456Z 0 [ERROR] ... -> [ERROR] ...
LOG_PREFIX = re.compile(r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d+Z \d+ ", re.M)
def free_port():
"""A TCP port on 127.0.0.1 that nothing listens on now."""
with socket.socket() as probe:
probe.bind(("127.0.0.1", 0))
return probe.getsockname()[1]
class Server:
"""One throwaway server: initialise, start; stopped and deleted when the with block ends.
options: extra mysqld options for every start (for example "--sql-mode=" or "--character-set-server=latin1").
basedir: the folder of an unpacked MySQL tarball to run instead of the pinned 8.4.11.
masks: (pattern, replacement) pairs, as for mask().
"""
def __init__(self, options=(), redo=None, basedir=None, masks=()):
self.options = [str(o) for o in options]
self.redo = redo
self.basedir = Path(basedir).resolve() if basedir else PINNED
self.bin = self.basedir / "bin"
self.masks = []
for pattern, replacement in masks:
self.mask(pattern, replacement)
self.proc = None
def mask(self, pattern, replacement):
"""From now on every printed line shows text matching the regular expression as the replacement: for a value
that changes from run to run (an uptime, a time stamp), so that the recorded output stays the same."""
self.masks.append((re.compile(pattern), replacement))
# ---- the folder ------------------------------------------------------------------------------------------------
def __enter__(self):
if not (self.bin / "mysqld").exists():
sys.exit(f"no MySQL server program in {self.basedir}" if self.basedir != PINNED
else f"MySQL {MYSQL_VERSION} was not found next to this Python")
self.base = Path(tempfile.mkdtemp(prefix="my", dir=".")).resolve()
for sub in ("tmp", "files"):
(self.base / sub).mkdir(mode=0o700)
self.port = free_port()
self.password = secrets.token_urlsafe(24)
self.client_cnf = self.base / "client.cnf"
self._write_client_cnf(password=None)
self.env = {key: value for key, value in os.environ.items()
if not key.startswith("MYSQL") and key not in ("TZ", "HOME", "LANG", "LC_ALL")}
self.env.update({"HOME": str(self.base), "TZ": "UTC", "LANG": "en_US.UTF-8",
"MYSQL_TEST_LOGIN_FILE": str(self.base / "no-login-paths.cnf")})
for pattern in ("*.sql", "*.cnf", "*.csv", "*.txt"):
for f in Path(".").glob(pattern):
shutil.copy(f, self.base / f.name)
return self
def __exit__(self, *exc):
if self.proc and self.proc.poll() is None:
self.stop(quiet=True)
shutil.rmtree(self.base, ignore_errors=True)
def _write_client_cnf(self, password):
lines = ["[client]", "user=root", "host=127.0.0.1", f"port={self.port}", "protocol=TCP"]
if password:
lines.append(f"password={password}")
self.client_cnf.write_text("\n".join(lines) + "\n")
self.client_cnf.chmod(0o600)
def server_options(self):
b = self.base
return ["--no-defaults", f"--basedir={self.basedir}", f"--datadir={b / 'data'}", f"--tmpdir={b / 'tmp'}",
f"--log-error={b / 'mysqld.err'}", f"--pid-file={b / 'mysqld.pid'}",
f"--secure-file-priv={b / 'files'}",
*([f"--innodb-redo-log-capacity={self.redo}"] if self.redo else [])]
# ---- the server ------------------------------------------------------------------------------------------------
def initialize(self, show=False):
"""mysqld --initialize-insecure: a new data directory whose root account has no password yet."""
args = ["mysqld", *self.server_options(), "--initialize-insecure"]
code, out = self._call(args)
if show:
_show(["mysqld", "--no-defaults", "--initialize-insecure", "--datadir=./data"], out)
if code != 0:
print(self.log())
sys.exit(f"mysqld --initialize-insecure ended with status {code}")
def start(self, extra=(), defaults_file=None, expect_fail=False, show=False):
"""Starts mysqld on 127.0.0.1 and waits until it answers. With expect_fail, prints the error-log lines of a
start that must fail, and returns them."""
log = self.base / "mysqld.err"
before = log.read_text() if log.exists() else ""
opts = self.server_options()
if defaults_file:
opts[0] = f"--defaults-file={self.base / defaults_file}"
args = [str(self.bin / "mysqld"), *opts, "--bind-address=127.0.0.1", f"--port={self.port}",
"--socket=mysql.sock", "--mysqlx=OFF", *self.options, *[str(e) for e in extra]]
if show:
shown = ["mysqld", opts[0].replace(str(self.base) + "/", ""), "--datadir=./data", "--bind-address=127.0.0.1",
"--port=$PORT", "--socket=mysql.sock", "--mysqlx=OFF", *self.options, *extra]
print("$ " + " ".join(_arg(a) if a != "--port=$PORT" else a for a in shown) + " &")
print()
with open(self.base / "mysqld.out", "a") as out:
self.proc = subprocess.Popen(args, stdout=out, stderr=subprocess.STDOUT, cwd=self.base, env=self.env)
for _ in range(600):
if self.proc.poll() is not None:
break
if self._ping():
break
time.sleep(0.1)
if self.proc.poll() is not None:
new = (log.read_text() if log.exists() else "")[len(before):]
lines = [l for l in self.clean(LOG_PREFIX.sub("", new)).splitlines() if "[ERROR]" in l]
if expect_fail:
print("\n".join(lines))
print(f"(mysqld ended with status {self.proc.returncode})")
print()
self.proc = None
return lines
print("\n".join(lines))
sys.exit(f"mysqld ended with status {self.proc.returncode} while starting")
if expect_fail:
sys.exit("mysqld started, but the session expected it to fail")
if not self._ping():
sys.exit("mysqld did not answer within 60 seconds")
return []
def _ping(self):
code, _ = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "--connect-timeout=2", "ping"])
return code == 0
def up(self, extra=(), defaults_file=None, show=False):
"""Initialise, start (with start()'s options), and give root the run's password, quietly."""
self.initialize()
self.start(extra=extra, defaults_file=defaults_file, show=show)
return self.login()
def login(self):
"""Gives root the run's password (never printed) on a server started from a new data directory, and makes
every later client of the session use it."""
self.silent(f"ALTER USER 'root'@'localhost' IDENTIFIED BY '{self.password}'")
self._write_client_cnf(self.password)
return self
def stop(self, quiet=False):
"""A clean shutdown (mysqladmin shutdown), then waits until the process has ended."""
code, out = self._call(["mysqladmin", f"--defaults-file={self.client_cnf}", "shutdown"])
if code != 0 and not quiet:
sys.exit(f"mysqladmin shutdown failed: {out}")
try:
self.proc.wait(timeout=120)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=60)
self.proc = None
def restart(self, extra=(), defaults_file=None, show=False):
self.stop()
self.start(extra=extra, defaults_file=defaults_file, show=show)
# ---- running things --------------------------------------------------------------------------------------------
def _call(self, args, stdin=None, merge=True):
"""Runs one of MySQL's programs in the run folder: (status, cleaned output). merge=False keeps only what it
wrote to standard output (its warnings on standard error are dropped)."""
args = [str(a) for a in args]
done = subprocess.run([str(self.bin / args[0]), *args[1:]], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT if merge else subprocess.DEVNULL,
text=True, cwd=self.base, env=self.env, input=stdin)
return done.returncode, self.clean(done.stdout)
def output(self, *args, connect=False, status=0):
"""Runs one of MySQL's programs without printing anything and returns its standard output, for example the
SQL that mysql_tzinfo_to_sql writes, which the session saves with write_file(..., show=False) and runs next."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run, merge=False)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def clean(self, text):
"""The substitutions of the docstring. A table of the client whose cells change is drawn again with the
new column widths, so its borders still line up."""
lines, out, i = text.split("\n"), [], 0
while i < len(lines):
if lines[i].startswith("+-"):
j = i
while j < len(lines) and lines[j][:1] in ("+", "|"):
j += 1
block = lines[i:j]
done = [self._clean(l) for l in block]
out.extend(done if done == block else _redraw(block, self._clean))
i = j
else:
out.append(self._clean(lines[i]))
i += 1
return "\n".join(out)
def _clean(self, text):
text = text.replace(str(self.bin) + "/", "").replace(str(self.basedir), "<basedir>")
text = text.replace(str(self.base) + "/", "./").replace(str(self.base), ".")
text = re.sub(r" \(\d+\.\d\d sec\)", "", text)
text = re.sub(r'"Timestamp": \d+', '"Timestamp": <when it was set>', text)
text = re.sub(rf"\b{re.escape(HOST)}\b", "lab-host", text)
text = re.sub(rf"\b{re.escape(OS_USER)}\b", "learner", text)
text = re.sub(rf"(?<![\d.]){self.port}(?![\d.])", "<port>", text)
for pattern, replacement in self.masks:
text = pattern.sub(replacement, text)
return text
def log(self, pattern=None):
"""The error log without timestamps and thread numbers, optionally only the lines matching a pattern."""
text = self.clean(LOG_PREFIX.sub("", (self.base / "mysqld.err").read_text()))
if pattern:
text = "\n".join(l for l in text.splitlines() if re.search(pattern, l))
return text
def show_log(self, pattern, note=None):
"""Prints the error-log lines that match a pattern, as grep would."""
print(f"$ grep -E {_arg(pattern)} mysqld.err" + (f" # {note}" if note else ""))
print(self.log(pattern))
print()
def query(self, sql, db=None):
"""One value from one query, unprinted (the session uses it to wait or to decide)."""
args = ["mysql", f"--defaults-file={self.client_cnf}", "-N", "-B", *([db] if db else [])]
code, out = self._call(args, stdin=sql)
if code != 0:
sys.exit(f"the query failed: {out}")
return out.strip()
def silent(self, sql):
"""Runs a statement without printing it (used only for the run's password)."""
self.query(sql)
def mysql(self, script, errors=0, status=None, db=None, flags=("-t", "-vvv", "--skip-comments"), note=None, force=None):
"""Runs a client script from standard input and prints the command and its output. `errors` is how many
ERROR lines the script must print; with errors > 0 the client runs on past an error (--force) and then ends
with status 0, without --force it stops at the first error with status 1. The default flags print each
statement (-vvv) as a table (-t) without the script's comments (--skip-comments)."""
force = errors > 0 if force is None else force
shown = ["mysql", *flags, *(["--force"] if force else []), *([db] if db else [])]
args = ["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]]
code, out = self._call(args, stdin=(self.base / script).read_text())
if note:
print(f"-- {note}")
print("$ " + " ".join(_arg(a) for a in shown) + f" < {script}")
if out.strip():
print(out.rstrip("\n"))
print()
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
expected_status = (1 if errors and not force else 0) if status is None else status
if found != errors or code != expected_status:
sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")
return out
def shell(self, *args, status=0, shown=None, connect=False):
"""Runs one of MySQL's programs and prints the command and its output. connect=True gives a client program
(mysqladmin, mysqlshow, mysql) the run's client option file, which the printed command leaves out."""
args = [str(a) for a in args]
run = [args[0], f"--defaults-file={self.client_cnf}", *args[1:]] if connect else args
code, out = self._call(run)
_show(shown or args, out)
if code != status:
sys.exit(f"{args[0]} ended with status {code}, not {status}")
return out
def sql(self, statement, flags=("-t",), errors=0, db=None, note=None):
"""Runs one statement with mysql -e and prints the command and its output."""
shown = ["mysql", *flags, *([db] if db else []), "-e", statement]
code, out = self._call(["mysql", f"--defaults-file={self.client_cnf}", *shown[1:]])
if note:
print(f"-- {note}")
_show(shown, out)
found = sum(1 for line in out.splitlines() if line.startswith("ERROR "))
if found != errors or (code != 0) != (errors > 0):
sys.exit(f"{statement}: {found} errors and status {code}; the session expects {errors}")
return out
def write_file(self, path, text, show=True):
"""Writes a text file into the run folder (an option file, a script) and prints it, as cat would."""
(self.base / path).write_text(textwrap.dedent(text).lstrip("\n"))
if show:
self.show_file(path)
def show_file(self, path):
print(f"$ cat {path}")
print(self.clean((self.base / path).read_text()).rstrip("\n"))
print()
def listing(self, folder, prefix=""):
"""Prints the names in a folder of the run (sorted, folders with a slash), as `ls -p` would list them."""
path = self.base / folder
names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
print(f"$ ls -p {folder}" + (f" | grep '^{prefix}'" if prefix else ""))
print(textwrap.fill(" ".join(names), width=78, break_on_hyphens=False))
print()
return names
def _redraw(block, clean):
"""Draws a client table again after its cells were cleaned. The columns come from the first border line; the row
after it is the header (left-aligned); a column whose data cells were padded on the left (numbers) stays
right-aligned (an empty cell says nothing about its column)."""
cuts = [k for k, ch in enumerate(block[0]) if ch == "+"]
rows = [None if line.startswith("+") else [line[x + 1:y] for x, y in zip(cuts, cuts[1:])] for line in block]
header = next(k for k, r in enumerate(rows) if r is not None)
ncol = len(cuts) - 1
right = [any(r is not None and k != header and r[c].strip() and r[c].startswith(" ") for k, r in enumerate(rows))
for c in range(ncol)]
cells = [None if r is None else [clean(x.strip()) for x in r] for r in rows]
widths = [max(len(r[c]) for r in cells if r is not None) for c in range(ncol)]
drawn = []
for k, r in enumerate(cells):
if r is None:
drawn.append("+" + "+".join("-" * (w + 2) for w in widths) + "+")
else:
drawn.append("|" + "|".join(" " + (t.rjust(w) if right[c] and k != header else t.ljust(w)) + " "
for c, (t, w) in enumerate(zip(r, widths))) + "|")
return drawn
def _arg(a):
"""A command-line argument as a reader would type it: quotes only where the shell needs them."""
a = str(a)
if re.fullmatch(r"[\w@%+=:,./-]+", a):
return a
if not re.search(r"[\"$`\\!]", a):
return f'"{a}"'
return shlex.quote(a)
def _show(args, out):
print("$ " + " ".join(_arg(a) for a in args))
if out.strip():
print(out.rstrip("\n"))
print() The output answers three questions about any partitioned table:
- What is there.
information_schema.PARTITIONSlists each partition with its method, expression and bound. Naming partitions in a query reads only those:FROM events PARTITION (p2025_01)counts 3,100 rows, andp2025_02andp2025_03together count 5,900. That is partition selection: Naming partitions after a table in a statement, as in FROM events PARTITION (p2025_03), so that it reads or writes only those partitions; a row whose value belongs to another partition is refused., andINSERT,UPDATEandDELETEaccept it as well. - Where a row goes. The value decides, never the name. An
INSERTthat namesp2025_01for a February date fails with ERROR 1748,Found a row not matching the given partition set; the same row without the clause lands inp2025_02. - What it is on disk. The listing of
data/appshows seven files, fromevents#p#p2025_01.ibdtoevents#p#pmax.ibd. Each partition is a tablespace in which every index of the table, the clustered index included, is built over that partition’s rows alone: MySQL never partitions the rows without their indexes.
TABLE_ROWS is not a row count
The estimate column of the first query reads TABLE_ROWS, which the manual calls an estimate kept for the optimizer. It matches the real counts here only because the table had just been analyzed. When the number must be right, before a partition is dropped for example, count it: SELECT COUNT(*) FROM events PARTITION (p2025_03).
RANGE, LIST, HASH and KEY place a row by different rules
The monthly table uses RANGE partitioning: Partitioning in which each partition holds the values from the previous partition's bound up to its own VALUES LESS THAN bound; RANGE COLUMNS compares column values such as dates directly, and a last partition bounded by MAXVALUE takes everything above. in its COLUMNS form. MySQL 8.4 places rows in four ways, and RANGE and LIST each also come in a COLUMNS form that compares column values instead of one integer, which makes five rows in this table:
| Type | A row goes to | The expression | Fits |
|---|---|---|---|
RANGE |
the first partition whose bound is above the value | an integer expression, such as YEAR(created_on) |
values that grow, such as dates |
RANGE COLUMNS |
the same, comparing the column values themselves | one or more columns, dates and strings included | the same, with no function: the events table |
LIST, LIST COLUMNS |
the partition whose list names the value | an integer expression, or columns | a fixed set of groups, such as regions |
HASH |
the value modulo the number of partitions | an integer expression | spreading rows evenly by an integer |
KEY |
a hash the server computes itself | columns of almost any type; the primary key by default | spreading rows by a string or another key |
The second session places the rows of the events table three more ways: by state code into a LIST partitioning: Partitioning in which each partition names the exact values it holds with VALUES IN; LIST COLUMNS takes column values such as strings, and a row whose value no list names is refused. table of three regions, by account_id into a HASH partitioning: Partitioning in which a row goes to the partition numbered by an integer expression modulo the number of partitions, which spreads rows evenly but scatters any range of values over every partition. table of four partitions, and by a 16-character token into a KEY partitioning: Partitioning like HASH, except that the server hashes the listed columns with its own function, so they may be strings or dates; with no column listed, the primary key is used. table of four:
types_session.py
"""LIST COLUMNS, HASH and KEY partitioning on the rows of the events table: the rows each partition received, and
which ranges a HASH table can prune.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, then runs types.sql through the mysql
client. The one error the script prints is expected: a state code that no LIST partition names.
"""
from mysql_session import Server
# More sample pages than any partition has leaf pages, so the EXPLAIN rows estimates are the same on every run.
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("types.sql", errors=1) Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments --force < types.sql
--------------
CREATE TABLE orders_by_region (
id BIGINT NOT NULL,
region CHAR(2) NOT NULL,
PRIMARY KEY (id, region)
)
PARTITION BY LIST COLUMNS (region) (
PARTITION p_west VALUES IN ('MH', 'GJ', 'GA'),
PARTITION p_south VALUES IN ('KA', 'TN', 'KL', 'TG'),
PARTITION p_north VALUES IN ('DL', 'UP', 'HR')
)
--------------
Query OK, 0 rows affected
--------------
INSERT INTO orders_by_region VALUES (1, 'MH'), (2, 'KA'), (3, 'DL'), (4, 'TN')
--------------
Query OK, 4 rows affected
Records: 4 Duplicates: 0 Warnings: 0
--------------
INSERT INTO orders_by_region VALUES (5, 'RJ')
--------------
ERROR 1526 (HY000) at line 18: Table has no partition for value from column_list
--------------
CREATE TABLE events_by_account (
id BIGINT NOT NULL,
account_id INT NOT NULL,
PRIMARY KEY (id, account_id)
)
PARTITION BY HASH (account_id) PARTITIONS 4
--------------
Query OK, 0 rows affected
--------------
INSERT INTO events_by_account SELECT id, account_id FROM events
--------------
Query OK, 18100 rows affected
Records: 18100 Duplicates: 0 Warnings: 0
--------------
SELECT 'p0' AS part, COUNT(*) AS row_count FROM events_by_account PARTITION (p0)
UNION ALL SELECT 'p1', COUNT(*) FROM events_by_account PARTITION (p1)
UNION ALL SELECT 'p2', COUNT(*) FROM events_by_account PARTITION (p2)
UNION ALL SELECT 'p3', COUNT(*) FROM events_by_account PARTITION (p3)
--------------
+------+-----------+
| part | row_count |
+------+-----------+
| p0 | 4525 |
| p1 | 4525 |
| p2 | 4525 |
| p3 | 4525 |
+------+-----------+
4 rows in set
--------------
EXPLAIN SELECT COUNT(*) FROM events_by_account WHERE account_id BETWEEN 3 AND 5
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events_by_account
partitions: p0,p1,p3
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 12
ref: NULL
rows: 13575
filtered: 11.11
Extra: Using where; Using index
1 row in set, 1 warning
--------------
EXPLAIN SELECT COUNT(*) FROM events_by_account WHERE account_id BETWEEN 3 AND 6
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events_by_account
partitions: p0,p1,p2,p3
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 12
ref: NULL
rows: 18100
filtered: 11.11
Extra: Using where; Using index
1 row in set, 1 warning
--------------
CREATE TABLE sessions (
token CHAR(16) NOT NULL PRIMARY KEY,
account_id INT NOT NULL
)
PARTITION BY KEY () PARTITIONS 4
--------------
Query OK, 0 rows affected
--------------
INSERT INTO sessions SELECT LEFT(payload, 16), account_id FROM events
--------------
Query OK, 18100 rows affected
Records: 18100 Duplicates: 0 Warnings: 0
--------------
SELECT 'p0' AS part, COUNT(*) AS row_count FROM sessions PARTITION (p0)
UNION ALL SELECT 'p1', COUNT(*) FROM sessions PARTITION (p1)
UNION ALL SELECT 'p2', COUNT(*) FROM sessions PARTITION (p2)
UNION ALL SELECT 'p3', COUNT(*) FROM sessions PARTITION (p3)
--------------
+------+-----------+
| part | row_count |
+------+-----------+
| p0 | 4547 |
| p1 | 4451 |
| p2 | 4528 |
| p3 | 4574 |
+------+-----------+
4 rows in set
Bye
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 types_session.py
types.sql
-- Three more partitioning types on the same rows: LIST COLUMNS by an exact value, HASH by an integer modulo the
-- partition count, KEY by the server's own hash of any column type.
USE app;
-- LIST COLUMNS: each partition names the values it holds, here state codes grouped by region.
CREATE TABLE orders_by_region (
id BIGINT NOT NULL,
region CHAR(2) NOT NULL,
PRIMARY KEY (id, region)
)
PARTITION BY LIST COLUMNS (region) (
PARTITION p_west VALUES IN ('MH', 'GJ', 'GA'),
PARTITION p_south VALUES IN ('KA', 'TN', 'KL', 'TG'),
PARTITION p_north VALUES IN ('DL', 'UP', 'HR')
);
INSERT INTO orders_by_region VALUES (1, 'MH'), (2, 'KA'), (3, 'DL'), (4, 'TN');
-- A value no list names has no partition to go to.
INSERT INTO orders_by_region VALUES (5, 'RJ');
-- HASH: account_id MOD 4 picks the partition, so 500 accounts spread evenly over four partitions.
CREATE TABLE events_by_account (
id BIGINT NOT NULL,
account_id INT NOT NULL,
PRIMARY KEY (id, account_id)
)
PARTITION BY HASH (account_id) PARTITIONS 4;
INSERT INTO events_by_account SELECT id, account_id FROM events;
SELECT 'p0' AS part, COUNT(*) AS row_count FROM events_by_account PARTITION (p0)
UNION ALL SELECT 'p1', COUNT(*) FROM events_by_account PARTITION (p1)
UNION ALL SELECT 'p2', COUNT(*) FROM events_by_account PARTITION (p2)
UNION ALL SELECT 'p3', COUNT(*) FROM events_by_account PARTITION (p3);
-- HASH prunes a range only when it holds fewer values than there are partitions: 3 values of 4, then 4 of 4.
EXPLAIN SELECT COUNT(*) FROM events_by_account WHERE account_id BETWEEN 3 AND 5\G
EXPLAIN SELECT COUNT(*) FROM events_by_account WHERE account_id BETWEEN 3 AND 6\G
-- KEY: the server hashes the primary key (KEY () with no column), a 16-character token that is no integer.
CREATE TABLE sessions (
token CHAR(16) NOT NULL PRIMARY KEY,
account_id INT NOT NULL
)
PARTITION BY KEY () PARTITIONS 4;
INSERT INTO sessions SELECT LEFT(payload, 16), account_id FROM events;
SELECT 'p0' AS part, COUNT(*) AS row_count FROM sessions PARTITION (p0)
UNION ALL SELECT 'p1', COUNT(*) FROM sessions PARTITION (p1)
UNION ALL SELECT 'p2', COUNT(*) FROM sessions PARTITION (p2)
UNION ALL SELECT 'p3', COUNT(*) FROM sessions PARTITION (p3); The counts show each rule at work. HASH computes account_id MOD 4, and with account ids 1 to 500 each partition receives exactly 4,525 rows. KEY runs the token through the server’s own hash function, and the four partitions receive 4,547, 4,451, 4,528 and 4,574 rows: even, though not exactly. LIST places a row by the value it names and refuses any other: the code RJ, which no list names, fails with ERROR 1526, Table has no partition for value from column_list. A RANGE table without a MAXVALUE partition refuses a row past its last bound with the same error, which is why the events table keeps pmax. The two EXPLAIN plans in the middle of the run come back two sections later.
Two variants change only the arithmetic. LINEAR HASH and LINEAR KEY use a powers-of-two algorithm that makes adding, merging and splitting partitions much faster, at the price of a less even spread. And a RANGE or LIST partition can be split again into HASH or KEY subpartitions, each of which counts as one partition against the limit of 8,192.
Checkpoint
Check: Partitions, bounds and partition selection · RANGE, LIST, HASH and KEY
EXPLAIN’s partitions column names the partitions a query reads
partition pruning: The optimizer skipping every partition that cannot hold a matching row, worked out from WHERE conditions on the bare partitioning column. The partitions column of EXPLAIN lists the partitions a plan still reads. is the optimizer working out from the WHERE clause which partitions can hold a matching row, and reading only those. EXPLAIN’s partitions column lists the partitions the plan will read, so a list shorter than the table is the proof that pruning happened. The third session asks for the plans of five queries on the events table:
events_by_month_session.py
"""Partition pruning in EXPLAIN on a table partitioned by month: five queries and the partitions each one reads.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, then runs events_by_month.sql
through the mysql client.
"""
from mysql_session import Server
# More sample pages than any partition has leaf pages, so the EXPLAIN rows estimates are the same on every run.
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("events_by_month.sql") Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments < events_by_month.sql
--------------
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on >= '2025-03-01' AND created_on < '2025-04-01'
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p2025_03
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 11
ref: NULL
rows: 3100
filtered: 11.11
Extra: Using where; Using index
1 row in set, 1 warning
--------------
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on BETWEEN '2025-03-01' AND '2025-04-01'
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p2025_03,p2025_04
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 11
ref: NULL
rows: 6100
filtered: 11.11
Extra: Using where; Using index
1 row in set, 1 warning
--------------
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on BETWEEN '2025-02-15' AND '2025-04-10'
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p2025_02,p2025_03,p2025_04
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 11
ref: NULL
rows: 8900
filtered: 11.11
Extra: Using where; Using index
1 row in set, 1 warning
--------------
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on >= '2025-06-15'
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p2025_06,pmax
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 11
ref: NULL
rows: 3000
filtered: 33.33
Extra: Using where; Using index
1 row in set, 1 warning
--------------
EXPLAIN SELECT * FROM events WHERE id = 1234
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p2025_01,p2025_02,p2025_03,p2025_04,p2025_05,p2025_06,pmax
type: ref
possible_keys: PRIMARY
key: PRIMARY
key_len: 8
ref: const
rows: 1
filtered: 100.00
Extra: NULL
1 row in set, 1 warning
Bye
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 events_by_month_session.py
events_by_month.sql
-- Pruning on the monthly partitions of events: the partitions column of EXPLAIN names every partition the query
-- will read, and every partition it leaves out was pruned.
USE app;
-- One month as a half-open range: only March's partition.
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on >= '2025-03-01' AND created_on < '2025-04-01'\G
-- BETWEEN includes its upper end, and the first of April lives in the next partition.
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on BETWEEN '2025-03-01' AND '2025-04-01'\G
-- A range from mid-February to mid-April reads the three partitions it touches.
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on BETWEEN '2025-02-15' AND '2025-04-10'\G
-- A range with no upper end always includes the catch-all partition.
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on >= '2025-06-15'\G
-- A lookup by id alone says nothing about created_on: every partition is read, one index dive in each.
EXPLAIN SELECT * FROM events WHERE id = 1234\G The partitions and rows lines of the five plans, collected in one table:
WHERE |
partitions |
rows |
|---|---|---|
created_on >= '2025-03-01' AND created_on < '2025-04-01' |
p2025_03 |
3100 |
created_on BETWEEN '2025-03-01' AND '2025-04-01' |
p2025_03,p2025_04 |
6100 |
created_on BETWEEN '2025-02-15' AND '2025-04-10' |
p2025_02,p2025_03,p2025_04 |
8900 |
created_on >= '2025-06-15' |
p2025_06,pmax |
3000 |
id = 1234 |
all seven | 1 |
Step through the same five queries and watch which partitions each one opens:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Seven partitions, one table. The events table keeps one partition a month, p2025_01 to p2025_06, and pmax for every later date. A row goes to the first partition whose bound is above its created_on.
- A half-open month. From the first day of March up to, but not including, the first day of April. Both ends fall in p2025_03, so EXPLAIN lists that partition alone and estimates 3,100 rows. The other six are never opened.
- BETWEEN keeps its upper end. BETWEEN the first days of March and April includes the first day of April, and that day lives in p2025_04. One day more costs a whole partition, so EXPLAIN lists two and estimates 6,100 rows.
- A range across three months. A range from the middle of February into April reads every partition between its two ends, p2025_02 to p2025_04, with 8,900 rows estimated. January, May, June and pmax stay closed.
- No upper end. A range from the middle of June onward has no upper end, so the catch-all pmax is always read, even while it holds no rows. EXPLAIN lists p2025_06 and pmax.
- No partitioning column at all. WHERE id = 1234 says nothing about created_on, so any partition may hold the row. The plan dives into all seven primary keys, one dive each, and nothing is pruned.
Three rules come out of the runs. A range reads every partition from the one that holds its low end to the one that holds its high end, and a range without an upper end always reaches pmax, even while it is empty. BETWEEN keeps its upper value, so a month that ends on the first day of the next month reaches into the next partition. And a query that does not name the partitioning column reads all of them: id = 1234 finds one row, with one index dive into each of the seven primary keys.
A month written with BETWEEN reads two partitions
BETWEEN '2025-03-01' AND '2025-04-01' includes the first day of April, which lives in p2025_04: the plan lists two partitions, and the count takes in April’s first day as well. Write a month as a half-open range, created_on >= '2025-03-01' AND created_on < '2025-04-01', and it reads March alone.
Pruning chooses partitions, not rows. Inside p2025_03 the first plan still reads the partition’s whole primary key, type: index over 3,100 rows, because that key starts with id; an index that starts with created_on would also narrow the read inside the partition. The manual adds that SELECT, UPDATE and DELETE are pruned alike, so the EXPLAIN DELETE of a retention job can be checked the same way.
Key idea
A query is pruned when its WHERE compares the bare partitioning column with constants, and EXPLAIN’s partitions column is the proof: a partition it does not list is never opened.
A function or a conversion on the column turns pruning off
Pruning needs a condition the optimizer can map to partitions without reading any row, the same sargable: Describes a condition an index can use to find rows directly, with the bare indexed column compared against a value. A function or calculation on the column makes a condition non-sargable. shape an index seek needs. The fourth session breaks that shape twice and keeps it twice:
defeats_session.py
"""What defeats partition pruning: a function on the column, and a comparison that converts the column's values.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, then runs defeats.sql through the
mysql client.
"""
from mysql_session import Server
# More sample pages than any partition has leaf pages, so the EXPLAIN rows estimates are the same on every run.
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("defeats.sql") Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments < defeats.sql
--------------
EXPLAIN SELECT COUNT(*) FROM events WHERE YEAR(created_on) = 2025 AND MONTH(created_on) = 3
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p2025_01,p2025_02,p2025_03,p2025_04,p2025_05,p2025_06,pmax
type: index
possible_keys: NULL
key: PRIMARY
key_len: 11
ref: NULL
rows: 18100
filtered: 100.00
Extra: Using where; Using index
1 row in set, 1 warning
--------------
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on = 20250315
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: events
partitions: p2025_03
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 11
ref: NULL
rows: 3100
filtered: 10.00
Extra: Using where; Using index
1 row in set, 1 warning
--------------
CREATE TABLE deliveries (id BIGINT NOT NULL, postal_code VARCHAR(6) NOT NULL, PRIMARY KEY (id, postal_code))
PARTITION BY KEY (postal_code) PARTITIONS 4
--------------
Query OK, 0 rows affected
--------------
INSERT INTO deliveries VALUES (1, '411001'), (2, '560001'), (3, '110001'), (4, '400001')
--------------
Query OK, 4 rows affected
Records: 4 Duplicates: 0 Warnings: 0
--------------
EXPLAIN SELECT id FROM deliveries WHERE postal_code = 411001
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: deliveries
partitions: p0,p1,p2,p3
type: index
possible_keys: NULL
key: PRIMARY
key_len: 34
ref: NULL
rows: 4
filtered: 25.00
Extra: Using where; Using index
1 row in set, 2 warnings
--------------
SHOW WARNINGS
--------------
*************************** 1. row ***************************
Level: Warning
Code: 1739
Message: Cannot use range access on index 'PRIMARY' due to type or collation conversion on field 'postal_code'
*************************** 2. row ***************************
Level: Note
Code: 1003
Message: /* select#1 */ select `app`.`deliveries`.`id` AS `id` from `app`.`deliveries` where (`app`.`deliveries`.`postal_code` = 411001)
2 rows in set
--------------
EXPLAIN SELECT id FROM deliveries WHERE postal_code = '411001'
--------------
*************************** 1. row ***************************
id: 1
select_type: SIMPLE
table: deliveries
partitions: p1
type: index
possible_keys: PRIMARY
key: PRIMARY
key_len: 34
ref: NULL
rows: 2
filtered: 50.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 defeats_session.py
defeats.sql
-- Conditions that defeat pruning, each next to one that keeps it.
USE app;
-- A function around the partitioning column: the optimizer cannot tell which partitions hold March.
EXPLAIN SELECT COUNT(*) FROM events WHERE YEAR(created_on) = 2025 AND MONTH(created_on) = 3\G
-- A constant of another type is converted once, before pruning, so this one still reads one partition.
EXPLAIN SELECT COUNT(*) FROM events WHERE created_on = 20250315\G
-- A VARCHAR partitioning column compared with a number: MySQL compares them as numbers, so every row's value would
-- have to be converted, and no partition can be ruled out.
CREATE TABLE deliveries (id BIGINT NOT NULL, postal_code VARCHAR(6) NOT NULL, PRIMARY KEY (id, postal_code))
PARTITION BY KEY (postal_code) PARTITIONS 4;
INSERT INTO deliveries VALUES (1, '411001'), (2, '560001'), (3, '110001'), (4, '400001');
EXPLAIN SELECT id FROM deliveries WHERE postal_code = 411001\G
SHOW WARNINGS\G
-- The same value written as a string: one partition.
EXPLAIN SELECT id FROM deliveries WHERE postal_code = '411001'\G YEAR(created_on) = 2025 AND MONTH(created_on) = 3asks for March, but the optimizer cannot tell which partitions hold the dates for which a function returns 3. The plan lists all seven partitions and estimates 18,100 rows: the whole table.created_on = 20250315compares the date column with a number. The server converts the constant once and leaves the column bare, so the plan readsp2025_03alone.postal_code = 411001compares aVARCHARpartitioning column with a number. MySQL compares a string with a number as two floating-point values, and many strings convert to 411001, so every row’s value would need converting: the plan reads all four partitions, with warning 1739,Cannot use range access on index 'PRIMARY' due to type or collation conversion on field 'postal_code'. Written as the string'411001', the same lookup readsp1alone.
HASH and KEY add a limit of their own. They prune an equality, and a range only when it can be turned into a list of values shorter than the number of partitions, on an integer column. The two plans in the middle of the types session sit on that edge: account_id BETWEEN 3 AND 5, three values, reads p0,p1,p3, while BETWEEN 3 AND 6, four values for four partitions, reads all four.
Functions are not banned from partitioning, only from wrapping the column in the WHERE clause. A RANGE table may be partitioned by YEAR(created_on), TO_DAYS(created_on) or TO_SECONDS(created_on), and the optimizer still prunes ranges of created_on on it, because those functions only ever grow with the date. RANGE COLUMNS sidesteps the question by comparing the dates themselves.
A report that filters with YEAR() and MONTH()
WHERE YEAR(created_on) = 2025 AND MONTH(created_on) = 3 reads every partition, the catch-all included. Give the report the month’s bounds instead, created_on >= '2025-03-01' AND created_on < '2025-04-01', and EXPLAIN lists p2025_03 alone.
Checkpoint
Check: Pruning in the partitions column · What turns pruning off
Every unique key must hold the partitioning column, and no foreign key may touch the table
Each partition keeps its own B+trees, so InnoDB can check a unique index only inside one partition at a time. Two rows with the same key can be stopped only if they are sure to land in the same partition, and that is guaranteed only when the key contains every column of the partitioning expression. MySQL holds the primary key and every unique key to this rule, and it refuses foreign keys on partitioned InnoDB tables altogether. The fifth session tries each forbidden definition, then measures what 256 partitions cost:
keys_rule_session.py
"""The rules of a partitioned InnoDB table, as errors, and the cost of 256 partitions, as counters.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, then runs keys_rule.sql through the
mysql client. The five errors the script prints are expected: a primary key and a unique key without the
partitioning column, two foreign keys, and one partition over the limit.
"""
from mysql_session import Server
# Building and loading 256 partitions can take over a minute on a loaded machine, and the client then prints a
# "(1 min 28.17 sec)" timing that the driver's own mask (which strips only "(N.NN sec)") leaves in place. Strip the
# minute form too, so two runs stay byte-identical whatever the load (the track forbids timings in printed output).
with Server(
options=["--innodb-stats-persistent-sample-pages=300"],
masks=[(r" \(\d+ min \d+\.\d+ sec\)", "")],
) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("keys_rule.sql", errors=5) Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments --force < keys_rule.sql
--------------
CREATE TABLE events_by_id (id BIGINT NOT NULL PRIMARY KEY, created_on DATE NOT NULL)
PARTITION BY RANGE COLUMNS (created_on) (PARTITION p0 VALUES LESS THAN ('2025-02-01'), PARTITION p1 VALUES LESS THAN (MAXVALUE))
--------------
ERROR 1503 (HY000) at line 5: A PRIMARY KEY must include all columns in the table's partitioning function (prefixed columns are not considered).
--------------
ALTER TABLE events ADD UNIQUE KEY uk_payload (payload)
--------------
ERROR 1503 (HY000) at line 9: A UNIQUE INDEX must include all columns in the table's partitioning function (prefixed columns are not considered).
--------------
CREATE TABLE accounts (id INT NOT NULL PRIMARY KEY)
--------------
Query OK, 0 rows affected
--------------
ALTER TABLE events ADD FOREIGN KEY (account_id) REFERENCES accounts (id)
--------------
ERROR 1506 (HY000) at line 13: Foreign keys are not yet supported in conjunction with partitioning
--------------
CREATE TABLE event_notes (id INT NOT NULL PRIMARY KEY, event_id BIGINT NOT NULL, created_on DATE NOT NULL,
FOREIGN KEY (event_id, created_on) REFERENCES events (id, created_on))
--------------
ERROR 1506 (HY000) at line 14: Foreign keys are not yet supported in conjunction with partitioning
--------------
CREATE TABLE too_many (id INT NOT NULL) PARTITION BY HASH (id) PARTITIONS 8193
--------------
ERROR 1499 (HY000) at line 18: Too many partitions (including subpartitions) were defined
--------------
CREATE TABLE plain (id BIGINT NOT NULL PRIMARY KEY, v INT NOT NULL, KEY (v))
--------------
Query OK, 0 rows affected
--------------
CREATE TABLE hashed (id BIGINT NOT NULL PRIMARY KEY, v INT NOT NULL, KEY (v)) PARTITION BY HASH (id) PARTITIONS 256
--------------
Query OK, 0 rows affected
--------------
INSERT INTO plain SELECT id, id MOD 1000 FROM events
--------------
Query OK, 18100 rows affected
Records: 18100 Duplicates: 0 Warnings: 0
--------------
INSERT INTO hashed SELECT id, id MOD 1000 FROM events
--------------
Query OK, 18100 rows affected
Records: 18100 Duplicates: 0 Warnings: 0
--------------
SELECT COUNT(*) AS tablespaces, MIN(FILE_SIZE) AS smallest, MAX(FILE_SIZE) AS largest, SUM(FILE_SIZE) AS all_files
FROM information_schema.INNODB_TABLESPACES WHERE NAME LIKE 'app/hashed#p#%'
--------------
+-------------+----------+---------+-----------+
| tablespaces | smallest | largest | all_files |
+-------------+----------+---------+-----------+
| 256 | 131072 | 131072 | 33554432 |
+-------------+----------+---------+-----------+
1 row in set
--------------
FLUSH STATUS
--------------
Query OK, 0 rows affected
--------------
SELECT COUNT(*) FROM plain WHERE v = 5
--------------
+----------+
| COUNT(*) |
+----------+
| 19 |
+----------+
1 row in set
--------------
SHOW SESSION STATUS LIKE 'Handler_read_key'
--------------
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| Handler_read_key | 1 |
+------------------+-------+
1 row in set
--------------
FLUSH STATUS
--------------
Query OK, 0 rows affected
--------------
SELECT COUNT(*) FROM hashed WHERE v = 5
--------------
+----------+
| COUNT(*) |
+----------+
| 19 |
+----------+
1 row in set
--------------
SHOW SESSION STATUS LIKE 'Handler_read_key'
--------------
+------------------+-------+
| Variable_name | Value |
+------------------+-------+
| Handler_read_key | 256 |
+------------------+-------+
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 keys_rule_session.py
keys_rule.sql
-- What a partitioned InnoDB table refuses, and what its partitions cost.
USE app;
-- A primary key must contain every column of the partitioning expression.
CREATE TABLE events_by_id (id BIGINT NOT NULL PRIMARY KEY, created_on DATE NOT NULL)
PARTITION BY RANGE COLUMNS (created_on) (PARTITION p0 VALUES LESS THAN ('2025-02-01'), PARTITION p1 VALUES LESS THAN (MAXVALUE));
-- So must every unique key: payload unique across all partitions would need created_on in the key.
ALTER TABLE events ADD UNIQUE KEY uk_payload (payload);
-- No foreign key may start from a partitioned InnoDB table, or point at one.
CREATE TABLE accounts (id INT NOT NULL PRIMARY KEY);
ALTER TABLE events ADD FOREIGN KEY (account_id) REFERENCES accounts (id);
CREATE TABLE event_notes (id INT NOT NULL PRIMARY KEY, event_id BIGINT NOT NULL, created_on DATE NOT NULL,
FOREIGN KEY (event_id, created_on) REFERENCES events (id, created_on));
-- 8,192 partitions, subpartitions included, is the limit.
CREATE TABLE too_many (id INT NOT NULL) PARTITION BY HASH (id) PARTITIONS 8193;
-- The same 18,100 rows in one tablespace and in 256: each partition is a file and a set of B+trees of its own.
CREATE TABLE plain (id BIGINT NOT NULL PRIMARY KEY, v INT NOT NULL, KEY (v));
CREATE TABLE hashed (id BIGINT NOT NULL PRIMARY KEY, v INT NOT NULL, KEY (v)) PARTITION BY HASH (id) PARTITIONS 256;
INSERT INTO plain SELECT id, id MOD 1000 FROM events;
INSERT INTO hashed SELECT id, id MOD 1000 FROM events;
SELECT COUNT(*) AS tablespaces, MIN(FILE_SIZE) AS smallest, MAX(FILE_SIZE) AS largest, SUM(FILE_SIZE) AS all_files
FROM information_schema.INNODB_TABLESPACES WHERE NAME LIKE 'app/hashed#p#%';
-- A lookup by the secondary index v cannot be pruned (v is not the partitioning column): one index dive per
-- partition, counted by Handler_read_key in one client session.
FLUSH STATUS;
SELECT COUNT(*) FROM plain WHERE v = 5;
SHOW SESSION STATUS LIKE 'Handler_read_key';
FLUSH STATUS;
SELECT COUNT(*) FROM hashed WHERE v = 5;
SHOW SESSION STATUS LIKE 'Handler_read_key'; The first half of the output is five refusals, each with a rule behind it:
| What the session tried | The rule | Error |
|---|---|---|
PRIMARY KEY (id) on a table partitioned by created_on |
the primary key holds every partitioning column | 1503 |
UNIQUE KEY (payload) added to events |
so does every unique key | 1503 |
a foreign key from events to accounts |
no foreign key from a partitioned table | 1506 |
a foreign key from event_notes to events |
and none that points at one | 1506 |
PARTITIONS 8193 |
at most 8,192 partitions, subpartitions included | 1499 |
That is why the events table’s key is (id, created_on): id alone stops with ERROR 1503, A PRIMARY KEY must include all columns in the table's partitioning function. The price is a weaker promise. (id, created_on) lets two rows share an id as long as their dates differ, and a column that must be unique on its own, such as payload here, cannot be declared unique at all. ERROR 1506, Foreign keys are not yet supported in conjunction with partitioning, works in both directions: a partitioned InnoDB table neither references another table nor is referenced by one.
The second half of the run puts the same 18,100 rows of two integers into a table without partitions and into one with 256 HASH partitions. The partitioned copy holds 256 tablespaces of 131,072 bytes each, 33,554,432 bytes in all, because every partition is a file with pages of its own. A lookup by the secondary index v, which is not the partitioning column, cannot be pruned: Handler_read_key counts 1 index lookup in the plain table and 256 in the partitioned one, one per partition. The manual cautions that hundreds of partitions can be unwise for reasons of their own, and that very many may need a higher open_files_limit.
Key idea
A partitioned InnoDB table gives up keys that span partitions: every primary and unique key carries the partitioning columns, no foreign key touches the table, and each of up to 8,192 partitions is a file with indexes of its own.
Dropping a partition retires a month that DELETE removes row by row
Retention is where partitioning earns its place. The sixth session makes three copies of the events table and removes January from each: with DELETE ... WHERE created_on < '2025-02-01', with ALTER TABLE ... DROP PARTITION p2025_01 and with ALTER TABLE ... TRUNCATE PARTITION p2025_01. The server writes its binary log in the ROW format, MySQL 8.4’s default, and the session reads the log’s position before and after each statement:
retention_session.py
"""Removing the oldest month: DELETE, DROP PARTITION and TRUNCATE PARTITION, measured in rows and binary log bytes.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, runs retention.sql through the mysql
client, then lists the tables' files: the DELETE and the TRUNCATE left January's .ibd file in place, the DROP removed
it. The session prints no timings: how long each statement takes depends on the machine, while the rows touched and
the bytes logged do not.
"""
from mysql_session import Server
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("retention.sql")
my.listing("data/app") Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments < retention.sql
--------------
CREATE TABLE events_b LIKE events
--------------
Query OK, 0 rows affected
--------------
INSERT INTO events_b SELECT * FROM events
--------------
Query OK, 18100 rows affected
Records: 18100 Duplicates: 0 Warnings: 0
--------------
CREATE TABLE events_c LIKE events
--------------
Query OK, 0 rows affected
--------------
INSERT INTO events_c SELECT * FROM events
--------------
Query OK, 18100 rows affected
Records: 18100 Duplicates: 0 Warnings: 0
--------------
SELECT LOCAL->>'$.binary_log_position' INTO @before FROM performance_schema.log_status
--------------
Query OK, 1 row affected
--------------
DELETE FROM events WHERE created_on < '2025-02-01'
--------------
Query OK, 3100 rows affected
--------------
SELECT LOCAL->>'$.binary_log_position' - @before AS binlog_bytes FROM performance_schema.log_status
--------------
+--------------+
| binlog_bytes |
+--------------+
| 275891 |
+--------------+
1 row in set
--------------
SELECT LOCAL->>'$.binary_log_position' INTO @before FROM performance_schema.log_status
--------------
Query OK, 1 row affected
--------------
ALTER TABLE events_b ALGORITHM=INPLACE, LOCK=NONE, DROP PARTITION p2025_01
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
SELECT LOCAL->>'$.binary_log_position' - @before AS binlog_bytes FROM performance_schema.log_status
--------------
+--------------+
| binlog_bytes |
+--------------+
| 237 |
+--------------+
1 row in set
--------------
SELECT LOCAL->>'$.binary_log_position' INTO @before FROM performance_schema.log_status
--------------
Query OK, 1 row affected
--------------
ALTER TABLE events_c TRUNCATE PARTITION p2025_01
--------------
Query OK, 0 rows affected
--------------
SELECT LOCAL->>'$.binary_log_position' - @before AS binlog_bytes FROM performance_schema.log_status
--------------
+--------------+
| binlog_bytes |
+--------------+
| 205 |
+--------------+
1 row in set
--------------
SELECT OBJECT_NAME, COUNT_DELETE FROM performance_schema.table_io_waits_summary_by_table
WHERE OBJECT_SCHEMA = 'app' ORDER BY OBJECT_NAME
--------------
+-------------+--------------+
| OBJECT_NAME | COUNT_DELETE |
+-------------+--------------+
| events | 3100 |
| events_b | 0 |
| events_c | 0 |
+-------------+--------------+
3 rows in set
--------------
SELECT (SELECT COUNT(*) FROM events) AS events_rows, (SELECT COUNT(*) FROM events_b) AS events_b_rows,
(SELECT COUNT(*) FROM events_c) AS events_c_rows
--------------
+-------------+---------------+---------------+
| events_rows | events_b_rows | events_c_rows |
+-------------+---------------+---------------+
| 15000 | 15000 | 15000 |
+-------------+---------------+---------------+
1 row in set
Bye
$ ls -p data/app
events#p#p2025_01.ibd events#p#p2025_02.ibd events#p#p2025_03.ibd
events#p#p2025_04.ibd events#p#p2025_05.ibd events#p#p2025_06.ibd
events#p#pmax.ibd events_b#p#p2025_02.ibd events_b#p#p2025_03.ibd
events_b#p#p2025_04.ibd events_b#p#p2025_05.ibd events_b#p#p2025_06.ibd
events_b#p#pmax.ibd events_c#p#p2025_01.ibd events_c#p#p2025_02.ibd
events_c#p#p2025_03.ibd events_c#p#p2025_04.ibd events_c#p#p2025_05.ibd
events_c#p#p2025_06.ibd events_c#p#pmax.ibd
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 retention_session.py
retention.sql
-- Retention: the oldest month removed from three identical copies of the events table, by DELETE from events, by
-- DROP PARTITION from events_b and by TRUNCATE PARTITION from events_c. The server writes its binary log in ROW
-- format, the 8.4 default.
USE app;
CREATE TABLE events_b LIKE events;
INSERT INTO events_b SELECT * FROM events;
CREATE TABLE events_c LIKE events;
INSERT INTO events_c SELECT * FROM events;
-- The binary log position before and after a statement is the number of bytes that statement logged.
SELECT LOCAL->>'$.binary_log_position' INTO @before FROM performance_schema.log_status;
DELETE FROM events WHERE created_on < '2025-02-01';
SELECT LOCAL->>'$.binary_log_position' - @before AS binlog_bytes FROM performance_schema.log_status;
SELECT LOCAL->>'$.binary_log_position' INTO @before FROM performance_schema.log_status;
ALTER TABLE events_b ALGORITHM=INPLACE, LOCK=NONE, DROP PARTITION p2025_01;
SELECT LOCAL->>'$.binary_log_position' - @before AS binlog_bytes FROM performance_schema.log_status;
SELECT LOCAL->>'$.binary_log_position' INTO @before FROM performance_schema.log_status;
ALTER TABLE events_c TRUNCATE PARTITION p2025_01;
SELECT LOCAL->>'$.binary_log_position' - @before AS binlog_bytes FROM performance_schema.log_status;
-- The rows each table had deleted, row by row, as the Performance Schema counted them.
SELECT OBJECT_NAME, COUNT_DELETE FROM performance_schema.table_io_waits_summary_by_table
WHERE OBJECT_SCHEMA = 'app' ORDER BY OBJECT_NAME;
-- The same rows are gone from all three tables.
SELECT (SELECT COUNT(*) FROM events) AS events_rows, (SELECT COUNT(*) FROM events_b) AS events_b_rows,
(SELECT COUNT(*) FROM events_c) AS events_c_rows; Read the three binlog_bytes results, the COUNT_DELETE column and the file listing at the end of the output together:
| Statement | Rows deleted one by one | Binary log bytes | January’s file |
|---|---|---|---|
DELETE |
3,100 | 275,891 | kept |
DROP PARTITION |
0 | 237 | removed |
TRUNCATE PARTITION |
0 | 205 | kept, empty |
All three leave 15,000 rows; they differ in how. With row-based logging: The ROW format of the binary log: each change is written as row events holding the rows a statement changed, so a replica changes exactly those rows whatever the statement would compute when run again. More in “The binary log and mysqlbinlog” the DELETE writes every row it deletes into the binary log, and the Performance Schema counted 3,100 row deletes against events. DROP PARTITION and TRUNCATE PARTITION are DDL: each is logged as one statement of a few hundred bytes, whatever the partition held, and neither deletes rows one at a time. A month ten times larger grows only the DELETE line of the table. The session prints no timings, since how long a statement takes depends on the machine, while the rows touched and the bytes logged do not. Every replica and every point-in-time recovery reads those bytes, as the binary log lesson shows.
DROP PARTITION ran with ALGORITHM=INPLACE, LOCK=NONE, which MySQL 8.4 accepts for RANGE and LIST tables, so the change runs online DDL: A schema change, such as ALTER TABLE, that lets other sessions keep reading and writing the table while it runs; InnoDB does it with the INSTANT or INPLACE algorithm and LOCK=NONE, with only brief exclusive metadata locks.: reads and writes of the other months go on. It exists for RANGE and LIST partitions only; a HASH or KEY table can merge partitions with COALESCE PARTITION, which keeps every row. TRUNCATE PARTITION empties a partition and keeps its definition, for a slot that will be filled again.
Keep a catch-all partition, and split it while it is empty
The next month needs its partition before its first row arrives, and with pmax in place no partition can be added: ADD PARTITION appends after the last partition, and nothing comes after MAXVALUE. The seventh session works through the choices with two early July rows already sitting in pmax:
catchall_session.py
"""The catch-all partition: why ADD PARTITION fails while it exists, and what REORGANIZE PARTITION copies.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, then runs catchall.sql through the
mysql client. The three errors the script prints are expected: ADD PARTITION after MAXVALUE, REORGANIZE with
LOCK=NONE, and a row past the last partition of a table without a catch-all.
"""
from mysql_session import Server
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("catchall.sql", errors=3) Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments --force < catchall.sql
--------------
INSERT INTO events VALUES (900001, '2025-07-01', 7, 'view', SHA2(900001, 256)),
(900002, '2025-07-01', 8, 'click', SHA2(900002, 256))
--------------
Query OK, 2 rows affected
Records: 2 Duplicates: 0 Warnings: 0
--------------
ALTER TABLE events ADD PARTITION (PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'))
--------------
ERROR 1493 (HY000) at line 9: VALUES LESS THAN value must be strictly increasing for each partition
--------------
ALTER TABLE events ALGORITHM=INPLACE, LOCK=NONE, REORGANIZE PARTITION pmax INTO (
PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE))
--------------
ERROR 1845 (0A000) at line 12: LOCK=NONE is not supported for this operation. Try LOCK=SHARED.
--------------
SELECT VARIABLE_VALUE INTO @inserted FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted'
--------------
Query OK, 1 row affected
--------------
ALTER TABLE events REORGANIZE PARTITION pmax INTO (
PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE))
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
SELECT VARIABLE_VALUE - @inserted AS rows_copied FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted'
--------------
+-------------+
| rows_copied |
+-------------+
| 2 |
+-------------+
1 row in set
--------------
SELECT VARIABLE_VALUE INTO @inserted FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted'
--------------
Query OK, 1 row affected
--------------
ALTER TABLE events REORGANIZE PARTITION pmax INTO (
PARTITION p2025_08 VALUES LESS THAN ('2025-09-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE))
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
SELECT VARIABLE_VALUE - @inserted AS rows_copied FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted'
--------------
+-------------+
| rows_copied |
+-------------+
| 0 |
+-------------+
1 row in set
--------------
CREATE TABLE events_no_max LIKE events
--------------
Query OK, 0 rows affected
--------------
ALTER TABLE events_no_max REMOVE PARTITIONING
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
ALTER TABLE events_no_max PARTITION BY RANGE COLUMNS (created_on) (PARTITION p2025_06 VALUES LESS THAN ('2025-07-01'))
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
INSERT INTO events_no_max VALUES (1, '2025-07-01', 7, 'view', SHA2(1, 256))
--------------
ERROR 1526 (HY000) at line 31: Table has no partition for value from column_list
--------------
ALTER TABLE events_no_max ALGORITHM=INPLACE, LOCK=NONE, ADD PARTITION (PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'))
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
INSERT INTO events_no_max VALUES (1, '2025-07-01', 7, 'view', SHA2(1, 256))
--------------
Query OK, 1 row affected
Bye
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 catchall_session.py
catchall.sql
-- Making room for July: ADD PARTITION and REORGANIZE PARTITION against the catch-all partition pmax.
USE app;
-- Two early rows for July arrive before July has a partition, so they land in pmax.
INSERT INTO events VALUES (900001, '2025-07-01', 7, 'view', SHA2(900001, 256)),
(900002, '2025-07-01', 8, 'click', SHA2(900002, 256));
-- ADD PARTITION can only append after the last partition, and nothing comes after MAXVALUE.
ALTER TABLE events ADD PARTITION (PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'));
-- REORGANIZE splits pmax in two. It copies the rows it moves, and it permits no concurrent writes: LOCK=NONE is refused.
ALTER TABLE events ALGORITHM=INPLACE, LOCK=NONE, REORGANIZE PARTITION pmax INTO (
PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE));
-- Innodb_rows_inserted counts the rows the split copies into the new partitions.
SELECT VARIABLE_VALUE INTO @inserted FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted';
ALTER TABLE events REORGANIZE PARTITION pmax INTO (
PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE));
SELECT VARIABLE_VALUE - @inserted AS rows_copied FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted';
-- Split a month ahead, while pmax is still empty, and there is nothing to copy.
SELECT VARIABLE_VALUE INTO @inserted FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted';
ALTER TABLE events REORGANIZE PARTITION pmax INTO (
PARTITION p2025_08 VALUES LESS THAN ('2025-09-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE));
SELECT VARIABLE_VALUE - @inserted AS rows_copied FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted';
-- Without a catch-all, ADD PARTITION works in place, but a row past the last bound has nowhere to go.
CREATE TABLE events_no_max LIKE events;
ALTER TABLE events_no_max REMOVE PARTITIONING;
ALTER TABLE events_no_max PARTITION BY RANGE COLUMNS (created_on) (PARTITION p2025_06 VALUES LESS THAN ('2025-07-01'));
INSERT INTO events_no_max VALUES (1, '2025-07-01', 7, 'view', SHA2(1, 256));
ALTER TABLE events_no_max ALGORITHM=INPLACE, LOCK=NONE, ADD PARTITION (PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'));
INSERT INTO events_no_max VALUES (1, '2025-07-01', 7, 'view', SHA2(1, 256)); ADD PARTITION fails with ERROR 1493, VALUES LESS THAN value must be strictly increasing for each partition. REORGANIZE PARTITION pmax INTO (p2025_07, pmax) splits the catch-all instead, and the session counts what that costs: Innodb_rows_inserted grew by 2, the two July rows copied into the new partition. The split also permits no concurrent writes: it refuses LOCK=NONE with ERROR 1845, Try LOCK=SHARED, so writes to the table wait until it ends. The same split one month ahead, while pmax was empty, copied 0 rows. So the runbook splits the catch-all a month before it is needed, when the shared lock lasts as long as an empty split.
A table without a catch-all trades the other way. A row past its last bound fails with ERROR 1526, so a forgotten month stops the application’s inserts, but ADD PARTITION then runs with ALGORITHM=INPLACE, LOCK=NONE and copies nothing.
Splitting a catch-all that has filled up
A pmax that collected a month of rows because nobody split it in time makes the split a copy under a shared lock: every one of those rows moves, and writes wait for all of them. Schedule the split ahead, and check that SELECT COUNT(*) FROM events PARTITION (pmax) returns 0 just before it runs.
Checkpoint
Check: Unique keys, foreign keys and partition counts · DROP, TRUNCATE and the catch-all partition
EXCHANGE PARTITION swaps a month into an archive table
Dropping a partition deletes its rows. When the oldest month must be kept somewhere, EXCHANGE PARTITION: An ALTER TABLE operation that swaps the contents of one partition with an unpartitioned table of exactly the same structure without copying rows; by default it first checks every row of that table against the partition's bounds. first moves it into an ordinary table: the partition and the table trade their tablespaces, so the rows change tables without being copied. The eighth session archives January this way, then tries two archive tables that do not qualify:
exchange_session.py
"""EXCHANGE PARTITION into an archive table, and what the exchange requires of that table.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, then runs exchange.sql through the
mysql client. The two errors the script prints are expected: an archive table with an extra index, and an exchange
after a column was added with ALGORITHM=INSTANT.
"""
from mysql_session import Server
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("exchange.sql", errors=2) Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments --force < exchange.sql
--------------
CREATE TABLE events_2025_01 LIKE events
--------------
Query OK, 0 rows affected
--------------
ALTER TABLE events_2025_01 REMOVE PARTITIONING
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
SELECT VARIABLE_VALUE INTO @inserted FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted'
--------------
Query OK, 1 row affected
--------------
ALTER TABLE events ALGORITHM=INPLACE, LOCK=NONE, EXCHANGE PARTITION p2025_01 WITH TABLE events_2025_01
--------------
Query OK, 0 rows affected
--------------
SELECT (SELECT COUNT(*) FROM events PARTITION (p2025_01)) AS in_partition,
(SELECT COUNT(*) FROM events_2025_01) AS in_archive,
(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted') - @inserted AS rows_copied
--------------
+--------------+------------+-------------+
| in_partition | in_archive | rows_copied |
+--------------+------------+-------------+
| 0 | 3100 | 0 |
+--------------+------------+-------------+
1 row in set
--------------
ALTER TABLE events DROP PARTITION p2025_01
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
SELECT PARTITION_NAME FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'app' AND TABLE_NAME = 'events' ORDER BY PARTITION_ORDINAL_POSITION
--------------
+----------------+
| PARTITION_NAME |
+----------------+
| p2025_02 |
| p2025_03 |
| p2025_04 |
| p2025_05 |
| p2025_06 |
| pmax |
+----------------+
6 rows in set
--------------
CREATE TABLE events_2025_02 LIKE events_2025_01
--------------
Query OK, 0 rows affected
--------------
ALTER TABLE events_2025_02 ADD INDEX (account_id)
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
ALTER TABLE events EXCHANGE PARTITION p2025_02 WITH TABLE events_2025_02
--------------
ERROR 1736 (HY000) at line 23: Tables have different definitions
--------------
ALTER TABLE events ADD COLUMN note VARCHAR(20), ALGORITHM=INSTANT
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
CREATE TABLE events_2025_03 LIKE events
--------------
Query OK, 0 rows affected
--------------
ALTER TABLE events_2025_03 REMOVE PARTITIONING
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
ALTER TABLE events EXCHANGE PARTITION p2025_03 WITH TABLE events_2025_03
--------------
ERROR 1731 (HY000) at line 29: Non matching attribute 'INSTANT COLUMN(s)' between partition and table
Bye
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exchange_session.py
exchange.sql
-- Archiving the oldest month: EXCHANGE PARTITION swaps January's partition with an empty table of the same shape.
USE app;
-- The archive table: the same columns, keys and row format as events, without the partitioning.
CREATE TABLE events_2025_01 LIKE events;
ALTER TABLE events_2025_01 REMOVE PARTITIONING;
-- The swap copies no rows: Innodb_rows_inserted, which counts every row InnoDB inserts, does not move.
SELECT VARIABLE_VALUE INTO @inserted FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted';
ALTER TABLE events ALGORITHM=INPLACE, LOCK=NONE, EXCHANGE PARTITION p2025_01 WITH TABLE events_2025_01;
SELECT (SELECT COUNT(*) FROM events PARTITION (p2025_01)) AS in_partition,
(SELECT COUNT(*) FROM events_2025_01) AS in_archive,
(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_rows_inserted') - @inserted AS rows_copied;
-- The partition is empty now, so dropping it deletes nothing.
ALTER TABLE events DROP PARTITION p2025_01;
SELECT PARTITION_NAME FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = 'app' AND TABLE_NAME = 'events' ORDER BY PARTITION_ORDINAL_POSITION;
-- The archive must match exactly: one extra index is a different definition.
CREATE TABLE events_2025_02 LIKE events_2025_01;
ALTER TABLE events_2025_02 ADD INDEX (account_id);
ALTER TABLE events EXCHANGE PARTITION p2025_02 WITH TABLE events_2025_02;
-- A column added to events with ALGORITHM=INSTANT ends every later exchange with this table.
ALTER TABLE events ADD COLUMN note VARCHAR(20), ALGORITHM=INSTANT;
CREATE TABLE events_2025_03 LIKE events;
ALTER TABLE events_2025_03 REMOVE PARTITIONING;
ALTER TABLE events EXCHANGE PARTITION p2025_03 WITH TABLE events_2025_03; The archive comes from CREATE TABLE events_2025_01 LIKE events and ALTER TABLE events_2025_01 REMOVE PARTITIONING, which give it the same columns, keys and row format without the partitions. After EXCHANGE PARTITION p2025_01 WITH TABLE events_2025_01, the partition holds 0 rows and the archive 3,100, and Innodb_rows_inserted did not move: no row was copied, and the statement ran with ALGORITHM=INPLACE, LOCK=NONE. The empty partition then drops without deleting anything.
The swap asks for an exact match, and the server checks it before anything moves:
- the table is not partitioned, not temporary, and otherwise identical: one extra index gave ERROR 1736,
Tables have different definitions; - both sides have the same character set, collation and row format, and neither has foreign keys;
- once a column has been added to
eventswith INSTANT DDL: MySQL's INSTANT algorithm for ALTER TABLE: a change made in the data dictionary alone, such as adding, dropping or renaming a column, that leaves the table's rows untouched and so finishes almost at once., no table can be exchanged with it at all: ERROR 1731,Non matching attribute 'INSTANT COLUMN(s)' between partition and table; - the user holds the
ALTER,INSERTandCREATEprivileges thatALTER TABLEneeds, andDROPas well.
The exchange fires no trigger on either table, and an AUTO_INCREMENT column of the exchanged table is reset. Here is one month of the runbook, with the archive step in its place:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Make room before the month. Split the empty catch-all a month ahead with REORGANIZE PARTITION. While pmax holds no rows the split copies none; once rows arrive, it copies them under a shared lock that holds writes back.
- Create the archive table. CREATE TABLE events_2025_01 LIKE events, then REMOVE PARTITIONING. The archive gets the same columns, keys and row format, which EXCHANGE requires.
- Swap the oldest month out. ALTER TABLE events EXCHANGE PARTITION p2025_01 WITH TABLE events_2025_01. The partition's rows become the table's rows without being copied, and reads and writes go on, since the exchange runs in place.
- Definitions differ. An archive with one extra index, or a column added with ALGORITHM=INSTANT on events, is refused before anything moves. Rebuild the archive table to match, then exchange again.
- Check both sides. Count the partition and the archive before dropping anything. In the recorded run the partition held 0 rows and the archive 3,100, every row of January.
- Drop the empty partition. DROP PARTITION p2025_01 now removes an empty tablespace, so no row is deleted. The archive table can be dumped, moved or kept, and the next month repeats the runbook.
The order of the stages carries the safety. The split runs first, so the new month never collects rows in pmax, and the count of both sides runs before the drop, because it is the last point at which a wrong exchange can still be swapped back: DROP PARTITION deletes whatever the partition holds.
WITHOUT VALIDATION trusts you with the range
The swap also runs the other way, to load a month built in a staging table. By default it runs WITH VALIDATION: the server checks the rows of the table one by one against the partition’s range and refuses the swap when one falls outside. The ninth session stages July with one row dated in August by mistake:
validation_session.py
"""EXCHANGE PARTITION WITH VALIDATION and WITHOUT VALIDATION, on a staging table that holds one row too late.
A throwaway MySQL 8.4.11 server (mysql_session.py) loads events_load.sql quietly, then runs validation.sql through the
mysql client. The one error the script prints is expected: the validated exchange refusing the August row.
"""
from mysql_session import Server
with Server(options=["--innodb-stats-persistent-sample-pages=300"]) as my:
my.up()
my.mysql("events_load.sql", flags=("--skip-comments",))
my.mysql("validation.sql", errors=1) Output
$ mysql --skip-comments < events_load.sql
Table Op Msg_type Msg_text
app.events analyze status OK
$ mysql -t -vvv --skip-comments --force < validation.sql
--------------
ALTER TABLE events REORGANIZE PARTITION pmax INTO (
PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE))
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
CREATE TABLE july_load LIKE events
--------------
Query OK, 0 rows affected
--------------
ALTER TABLE july_load REMOVE PARTITIONING
--------------
Query OK, 0 rows affected
Records: 0 Duplicates: 0 Warnings: 0
--------------
INSERT INTO july_load VALUES (900001, '2025-07-01', 7, 'view', SHA2(900001, 256)),
(900002, '2025-07-02', 8, 'click', SHA2(900002, 256)),
(900003, '2025-08-02', 9, 'view', SHA2(900003, 256))
--------------
Query OK, 3 rows affected
Records: 3 Duplicates: 0 Warnings: 0
--------------
ALTER TABLE events EXCHANGE PARTITION p2025_07 WITH TABLE july_load
--------------
ERROR 1737 (HY000) at line 14: Found a row that does not match the partition
--------------
ALTER TABLE events EXCHANGE PARTITION p2025_07 WITH TABLE july_load WITHOUT VALIDATION
--------------
Query OK, 0 rows affected
--------------
SELECT COUNT(*) FROM events WHERE created_on >= '2025-08-01'
--------------
+----------+
| COUNT(*) |
+----------+
| 0 |
+----------+
1 row in set
--------------
SELECT id, created_on FROM events PARTITION (p2025_07) ORDER BY id
--------------
+--------+------------+
| id | created_on |
+--------+------------+
| 900001 | 2025-07-01 |
| 900002 | 2025-07-02 |
| 900003 | 2025-08-02 |
+--------+------------+
3 rows in set
--------------
ALTER TABLE events CHECK PARTITION p2025_07
--------------
+------------+-------+----------+-------------------------------------------------------------------------------------+
| Table | Op | Msg_type | Msg_text |
+------------+-------+----------+-------------------------------------------------------------------------------------+
| app.events | check | error | Found a misplaced row in part 6 should be in part 7:
created_on:2025-08-02 |
| app.events | check | error | Partition p2025_07 returned error |
| app.events | check | error | Table upgrade required. Please do "REPAIR TABLE `events`" or dump/reload to fix it! |
+------------+-------+----------+-------------------------------------------------------------------------------------+
3 rows in set
--------------
ALTER TABLE events REPAIR PARTITION p2025_07
--------------
+------------+--------+----------+------------------------+
| Table | Op | Msg_type | Msg_text |
+------------+--------+----------+------------------------+
| app.events | repair | warning | Moved 1 misplaced rows |
| app.events | repair | status | OK |
+------------+--------+----------+------------------------+
2 rows in set
--------------
SELECT COUNT(*) FROM events WHERE created_on >= '2025-08-01'
--------------
+----------+
| COUNT(*) |
+----------+
| 1 |
+----------+
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 validation_session.py
validation.sql
-- Loading a month by exchange: July is prepared in a staging table and swapped into an empty partition. One row of
-- the staging table carries an August date by mistake.
USE app;
ALTER TABLE events REORGANIZE PARTITION pmax INTO (
PARTITION p2025_07 VALUES LESS THAN ('2025-08-01'), PARTITION pmax VALUES LESS THAN (MAXVALUE));
CREATE TABLE july_load LIKE events;
ALTER TABLE july_load REMOVE PARTITIONING;
INSERT INTO july_load VALUES (900001, '2025-07-01', 7, 'view', SHA2(900001, 256)),
(900002, '2025-07-02', 8, 'click', SHA2(900002, 256)),
(900003, '2025-08-02', 9, 'view', SHA2(900003, 256));
-- WITH VALIDATION, the default, checks every row against the partition's range and refuses the swap.
ALTER TABLE events EXCHANGE PARTITION p2025_07 WITH TABLE july_load;
-- WITHOUT VALIDATION skips the check, and the August row now sits in July's partition.
ALTER TABLE events EXCHANGE PARTITION p2025_07 WITH TABLE july_load WITHOUT VALIDATION;
-- A query for August prunes to pmax and misses the row; reading July's partition whole shows it there.
SELECT COUNT(*) FROM events WHERE created_on >= '2025-08-01';
SELECT id, created_on FROM events PARTITION (p2025_07) ORDER BY id;
-- CHECK PARTITION reports the misplaced row, and REPAIR PARTITION moves it where it belongs.
ALTER TABLE events CHECK PARTITION p2025_07;
ALTER TABLE events REPAIR PARTITION p2025_07;
SELECT COUNT(*) FROM events WHERE created_on >= '2025-08-01'; The validated exchange fails with ERROR 1737, Found a row that does not match the partition. WITHOUT VALIDATION skips the check, and the August row now sits in July’s partition. Pruning trusts the bounds, so WHERE created_on >= '2025-08-01' reads only pmax and counts 0, while reading p2025_07 whole shows the row with its August date. CHECK PARTITION finds it, Found a misplaced row in part 6 should be in part 7 (partitions count from 0, so p2025_07 is part 6 and pmax part 7), and REPAIR PARTITION moves it: Moved 1 misplaced rows, after which the same query counts 1.
WITHOUT VALIDATION moves the check to you
Skip validation only when the staging table was filled by a query on the partition’s own bounds, and run CHECK PARTITION afterwards. A misplaced row raises no error: the queries that prune past it simply never see it.
Choose the type from how rows leave the table and what queries filter on
Partitioning pays for a table that is retired one slice at a time and queried by the column that slices it. Every other query pays a little, as the 256 index lookups showed, and the table gives up unique keys without that column and all foreign keys. Two questions about your table settle most of the choice. Answer them below; each cell carries a number from the sessions above:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Partitioning | Retire old rows by partition | Prune a range of the column | Spread rows evenly | Lookups by other columns |
|---|---|---|---|---|
| RANGE or RANGE COLUMNS Picks the partition by: bands of ordered values | Good DROP PARTITION of a month logged 237 bytes; DELETE of the same rows logged 275,891 | Good a month read 1 of 7 partitions, a range across three months 3 of 7 | Mixed as even as the bands: 2,800 to 3,100 rows a month here | Mixed a lookup without the column reads every partition: all 7 for id = 1234 |
| LIST or LIST COLUMNS Picks the partition by: named lists of values | Mixed drops a whole list, such as a region that leaves; ages only if the lists name periods | Mixed prunes a short range that it can turn into a list of values | Mixed as even as the lists; a value no list names is refused with ERROR 1526 | Mixed a lookup without the column reads every partition |
| HASH Picks the partition by: an integer expression modulo N | Poor a hash of an id scatters each month over every partition; DROP PARTITION is for RANGE and LIST | Poor only ranges shorter than the partition count: 3 values read 3 of 4, 4 values all 4 | Good 4,525 rows in each of 4 partitions, account_id MOD 4 | Mixed one index dive per partition: 256 dives at 256 partitions in the run |
| KEY Picks the partition by: the server's hash of columns | Poor a hash scatters each month over every partition; DROP PARTITION is for RANGE and LIST | Poor prunes equalities; a range on a column that is no integer reads every partition | Good 4,451 to 4,574 rows in 4 partitions, on a 16-character token | Mixed a lookup without the column reads every partition |
| No partitioning Picks the partition by: one B+tree and its indexes | Poor DELETE removes and logs each row: 3,100 rows and 275,891 bytes for one month | Mixed no partitions to skip; an index on the column narrows the range instead | Mixed one tablespace, so there is nothing to spread | Good one index dive for a lookup by a secondary index |
How the answers decide
- “How do rows leave the table?” By age, a period at a time: rules out HASH and KEY. HASH and KEY place rows by a hash, not by age, so no partition holds one period to drop, and DROP PARTITION works only on RANGE and LIST.
- “How do rows leave the table?” By age, a period at a time: adds a caveat to No partitioning. Without partitions, removing a period is a DELETE that touches and logs each row: 3,100 rows and 275,891 binary log bytes for one month.
- “How do rows leave the table?” By group, such as a region: rules out HASH and KEY. A hash mixes the groups inside every partition, so no partition holds one group to drop.
- “How do rows leave the table?” By group, such as a region: adds a caveat to RANGE or RANGE COLUMNS. RANGE drops a group only when the groups are bands of an ordered value, such as blocks of tenant ids; LIST names each group.
- “How do rows leave the table?” By group, such as a region: adds a caveat to No partitioning. Without partitions, a group that leaves is a DELETE that touches and logs each of its rows, as a month did: 3,100 rows and 275,891 bytes.
- “What do most of your queries filter on?” A range of one column: rules out HASH and KEY. HASH and KEY prune a range only on an integer column and only while it holds fewer values than there are partitions.
- “What do most of your queries filter on?” One exact value of it: adds a caveat to RANGE or RANGE COLUMNS, LIST or LIST COLUMNS, HASH and KEY. An equality on the partitioning column prunes under every type; choose by how rows leave the table, not by the lookup.
- “What do most of your queries filter on?” Other columns: adds a caveat to RANGE or RANGE COLUMNS, LIST or LIST COLUMNS, HASH and KEY. A query that does not name the partitioning column reads every partition, one index dive each: 256 dives instead of 1 in the run.
- “How do rows leave the table?” Rarely or one by one and “What do most of your queries filter on?” Other columns: rules out RANGE or RANGE COLUMNS, LIST or LIST COLUMNS, HASH and KEY. Nothing leaves by partition and no query names the column: partitions would only add an index dive per partition and limit the keys.
In short: RANGE or RANGE COLUMNS for data that ages out a period at a time, LIST when whole groups leave or must stay apart, HASH or KEY only to spread rows evenly by a column that every lookup names, and no partitioning when nothing leaves by partition and few queries filter on the column. Partitioning stays on one server; spreading a table over several servers is sharding, the subject of this track’s scaling module. PostgreSQL makes the same choices with its own syntax and rules, in declarative partitioning and pruning.
Checkpoint
Check: EXCHANGE PARTITION and validation · Choosing a partitioning type
Practise: list the partitions a range must read
The exercise turns the pruning rule into a function: from the bounds of a RANGE COLUMNS table and a date range, return the partitions EXPLAIN would list. Four of its sample tests are plans the third session recorded.
Exercise · Medium · Python
Work out which partitions a date range reads
Write one function in pruned.py:
pruned(bounds, low, high)returns the names of the partitions, in table order, that a query withlow <= created_on <= highmust read on a table partitioned byRANGE COLUMNS (created_on). Every other partition is pruned.
The arguments:
boundslists the table's partitions in order, as(name, less_than)pairs.less_thanis the partition'sVALUES LESS THANbound, written as an ISO date string such as"2025-02-01", orNonefor a catch-all partition declaredVALUES LESS THAN (MAXVALUE). Only the last partition can be the catch-all.lowis the smallest date the query accepts, as an ISO date string, orNonewhen the range has no lower end.highis the largest date the query accepts, included asBETWEENincludes it, orNonewhen the range has no upper end.
Rules the server uses, from the lesson:
- A partition holds every value from the bound of the partition before it (included) up to its own bound (not included). The first partition has no lower bound: it holds every value below its own bound. The catch-all holds every value from the last bound up.
- A partition is read when the range and the partition share at least one date.
- A range whose
lowis after itshighholds no date, and reads no partition.
ISO date strings sort as the dates they name, so you can compare them with < and >= as they are.
Four of the test cases are queries whose EXPLAIN plans the lesson recorded on the six monthly partitions and pmax: a half-open month written as an inclusive range, BETWEEN the first days of two months, a range across three months, and a range with no upper end. The others cover a single day, a range with no lower end, a range that starts exactly on a bound, an empty range, and a table without a catch-all.
Starter code · pruned.py
def pruned(bounds, low, high):
"""Return the names of the partitions a query with low <= created_on <= high must read.
bounds (name, less_than) pairs in table order; less_than is an ISO date string, or None for MAXVALUE.
low the smallest date of the range (ISO string), or None for no lower end.
high the largest date of the range (ISO string, included), or None for no upper end.
"""
return [] The sample tests · test_pruned.py
from pruned import pruned
# The lesson's events table: one partition a month, January to June, and the catch-all pmax.
MONTHS = [
("p2025_01", "2025-02-01"),
("p2025_02", "2025-03-01"),
("p2025_03", "2025-04-01"),
("p2025_04", "2025-05-01"),
("p2025_05", "2025-06-01"),
("p2025_06", "2025-07-01"),
("pmax", None),
]
def test_one_day():
"""A single day, '2025-05-20' to '2025-05-20': both ends fall in May, so p2025_05 alone."""
assert pruned(MONTHS, "2025-05-20", "2025-05-20") == ["p2025_05"]
def test_one_month_inclusive():
""">= '2025-03-01' AND < '2025-04-01' is the inclusive range up to the last day of March: p2025_03 only."""
assert pruned(MONTHS, "2025-03-01", "2025-03-31") == ["p2025_03"]
def test_between_first_days():
"""BETWEEN '2025-03-01' AND '2025-04-01' includes the first of April: EXPLAIN recorded p2025_03,p2025_04."""
assert pruned(MONTHS, "2025-03-01", "2025-04-01") == ["p2025_03", "p2025_04"]
def test_three_months():
"""BETWEEN '2025-02-15' AND '2025-04-10': EXPLAIN recorded p2025_02,p2025_03,p2025_04."""
assert pruned(MONTHS, "2025-02-15", "2025-04-10") == ["p2025_02", "p2025_03", "p2025_04"]
def test_no_upper_end():
"""created_on >= '2025-06-15': EXPLAIN recorded p2025_06,pmax; an open range always reaches the catch-all."""
assert pruned(MONTHS, "2025-06-15", None) == ["p2025_06", "pmax"]
def test_no_lower_end():
"""created_on <= '2025-01-31': the first partition has no lower bound, so it alone holds the range."""
assert pruned(MONTHS, None, "2025-01-31") == ["p2025_01"]
def test_starts_on_a_bound():
"""A range that starts on '2025-03-01' never reads p2025_02, whose bound excludes that date."""
assert pruned(MONTHS, "2025-03-01", "2025-05-15") == ["p2025_03", "p2025_04", "p2025_05"]
def test_empty_range():
"""A range whose low is after its high holds no date, so it reads nothing."""
assert pruned(MONTHS, "2025-04-10", "2025-02-15") == []
def test_without_catch_all():
"""With no MAXVALUE partition, a range past the last bound has no partition to read."""
assert pruned(MONTHS[:6], "2025-07-01", None) == [] A hint
Walk the partitions in order and keep the bound of the one before (None before the first). A partition holds [previous, less_than), with an open end where a bound is None. It overlaps the range [low, high] when the range starts before the partition ends (low < less_than) and ends at or after the partition starts (high >= previous); an open end of either side always passes its half of the test. Check for low > high first.
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.
The questions below cover all eight sections, from where a row lands to which type fits a table, and each wrong choice says what it misses:
Practice that adapts to you
Practise this lesson
18 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Partitioning questions in an interview usually open with how you know it works, then move to a table where it was the wrong tool. The warm-up is answered in full here; the two harder ones open on this module’s question page, each with a first step to try.
Warm-up: How do you check that a query on a partitioned table is pruned?
Run EXPLAIN on the query with the constants the application really sends, and read the partitions column: it lists every partition the plan will read, so a pruned query lists fewer partitions than the table has. On a table partitioned by month, a half-open range over one month lists one partition, while the same month written with YEAR() and MONTH() around the column lists all of them. Two slips widen the list: a BETWEEN whose upper end is the first day of the next month, which reads that partition too, and a comparison that converts the column, such as a string column against a number. A query that does not filter on the partitioning column reads every partition. EXPLAIN DELETE and EXPLAIN UPDATE are checked the same way.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to staff
An orders table of 10 million rows a month is partitioned by month on created_at, so that orders older than 24 months leave by partition. Now order_ref must be unique across all orders, and a new payments table needs a foreign key to orders. How do you meet both requirements, and what does each option cost?
-
Mid to staff
An app_logs table takes 20 million rows a day and is partitioned BY HASH(id) PARTITIONS 64. The team must now keep only 90 days, and the nightly DELETE of the oldest day makes the replicas fall hours behind. What do you change, and how do you get there without stopping writes?
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do RANGE, LIST, HASH and KEY partitioning each place a row?
RANGE by intervals of the partitioning expression, LIST by sets of values, HASH by an integer modulo the partition count, KEY by the server's own hash. One table name covers a separate InnoDB tablespace per partition.
-
How do you prove that a query on a partitioned table was pruned?
Read EXPLAIN's partitions column: a partition it does not list is never opened. A half-open month read one of seven partitions in the run, and a lookup without the partitioning column read all seven.
-
Why write a month filter as a half-open range on a table partitioned by month?
A BETWEEN that ends on the next month's first day includes that day, so the plan opens two partitions and the count takes in an extra day. From the first day inclusive to the next month's first day exclusive reads one partition.
-
What switches partition pruning off?
Wrapping the partitioning column in a function such as YEAR() or MONTH(), or comparing it in a way that converts it. HASH and KEY prune a range only when it covers fewer values than there are partitions.
-
Which keys does a partitioned InnoDB table give up?
Keys that span partitions: every primary and unique key must include the partitioning columns, and no foreign key may point to or from the table. Each of up to 8,192 partitions is a file with its own indexes.
-
Why retire a month with DROP PARTITION instead of DELETE?
It removes the month as a whole: 237 bytes in the binary log against 275,891 for a DELETE of the same 3,100 rows. Split the catch-all partition a month ahead, while it is still empty, so the split moves no rows.
-
What does EXCHANGE PARTITION do, and what does WITHOUT VALIDATION change?
It swaps a partition with an archive table of exactly the same shape, without copying any row. WITHOUT VALIDATION skips the check that every row fits the partition's range, leaving that check to you.
-
Can you trust TABLE_ROWS before dropping a partition?
No, it is an estimate kept for the optimizer. When the number must be right, count the partition itself with SELECT COUNT(*) FROM t PARTITION (p).
Key takeaways
- A partitioned table keeps one name over several InnoDB tablespaces, and its partitioning expression sends each row to one of them: by range, by list, by an integer modulo the partition count, or by the server’s hash.
- EXPLAIN’s
partitionscolumn proves pruning: a half-open month read one of seven partitions, aBETWEENthat ended on the next month’s first day read two, and a lookup without the partitioning column read all seven. - A function around the partitioning column, or a comparison that converts it, turns pruning off, and HASH or KEY prune a range only when it holds fewer values than there are partitions.
- Every primary and unique key must contain the partitioning columns, no foreign key may touch a partitioned InnoDB table, and each of up to 8,192 partitions is a file with indexes of its own.
- Retire data by partition:
DROP PARTITIONlogged 237 bytes where aDELETEof the same 3,100 rows logged 275,891, and the catch-all is split a month ahead, while it is still empty. EXCHANGE PARTITIONswaps a month into an archive table of exactly the same shape without copying a row, andWITHOUT VALIDATIONleaves the range check to you.
Terms in this lesson
- EXCHANGE PARTITION
- An ALTER TABLE operation that swaps the contents of one partition with an unpartitioned table of exactly the same structure without copying rows; by default it first checks every row of that table against the partition's bounds.
- HASH partitioning
- Partitioning in which a row goes to the partition numbered by an integer expression modulo the number of partitions, which spreads rows evenly but scatters any range of values over every partition.
- KEY partitioning
- Partitioning like HASH, except that the server hashes the listed columns with its own function, so they may be strings or dates; with no column listed, the primary key is used.
- LIST partitioning
- Partitioning in which each partition names the exact values it holds with VALUES IN; LIST COLUMNS takes column values such as strings, and a row whose value no list names is refused.
- partition pruning
- The optimizer skipping every partition that cannot hold a matching row, worked out from WHERE conditions on the bare partitioning column. The partitions column of EXPLAIN lists the partitions a plan still reads.
- partition selection
- Naming partitions after a table in a statement, as in FROM events PARTITION (p2025_03), so that it reads or writes only those partitions; a row whose value belongs to another partition is refused.
- partitioning expression
- The column list or integer expression of a table's PARTITION BY clause, such as RANGE COLUMNS (created_on) or HASH (account_id), whose value for a row decides which partition stores that row.
- RANGE partitioning
- Partitioning in which each partition holds the values from the previous partition's bound up to its own VALUES LESS THAN bound; RANGE COLUMNS compares column values such as dates directly, and a last partition bounded by MAXVALUE takes everything above.
References
- MySQL 8.4 Reference Manual: 26.1 Overview of Partitioning in MySQL (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.2 Partitioning Types (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.2.1 RANGE Partitioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.2.2 LIST Partitioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.2.3.1 RANGE COLUMNS partitioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.2.4 HASH Partitioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.2.4.1 LINEAR HASH Partitioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.2.5 KEY Partitioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.3.1 Management of RANGE and LIST Partitions (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.3.2 Management of HASH and KEY Partitions (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.3.3 Exchanging Partitions and Subpartitions with Tables (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.3.4 Maintenance of Partitions (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.4 Partition Pruning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.5 Partition Selection (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.6 Restrictions and Limitations on Partitioning (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.6.1 Partitioning Keys, Primary Keys, and Unique Keys (Oracle Corporation)
- MySQL 8.4 Reference Manual: 26.6.2 Partitioning Limitations Relating to Storage Engines (Oracle Corporation)
- MySQL 8.4 Reference Manual: 15.1.9.1 ALTER TABLE Partition Operations (Oracle Corporation)
- MySQL 8.4 Reference Manual: 17.12.1 Online DDL Operations (Oracle Corporation)
- MySQL 8.4 Reference Manual: 10.8.2 EXPLAIN Output Format (Oracle Corporation)
- MySQL 8.4 Reference Manual: 28.3.21 The INFORMATION_SCHEMA PARTITIONS Table (Oracle Corporation)
- MySQL 8.4 Reference Manual: 14.3 Type Conversion in Expression Evaluation (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.4.4.1 Binary Logging Formats (Oracle Corporation)
- MySQL 8.4 Reference Manual: 29.12.22.5 The log_status Table (Oracle Corporation)
- MySQL 8.4 Reference Manual: 29.12.20.8 Table I/O and Lock Wait Summary Tables (Oracle Corporation)
- MySQL 8.4 Reference Manual: 7.1.10 Server Status Variables (Oracle Corporation)
Report a problem with this lesson
Kept only in this browser. Your Learn progress