Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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

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.

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

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

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:

Seven partitions behind one table name, 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.PARTITIONS lists 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, and p2025_02 and p2025_03 together 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., and INSERT, UPDATE and DELETE accept it as well.
  • Where a row goes. The value decides, never the name. An INSERT that names p2025_01 for a February date fails with ERROR 1748, Found a row not matching the given partition set; the same row without the clause lands in p2025_02.
  • What it is on disk. The listing of data/app shows seven files, from events#p#p2025_01.ibd to events#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:

LIST COLUMNS, HASH and KEY partitioning of the same rows

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

  1. Question 1 of 3 A row whose created_on is the last day of February is inserted into the events table without a PARTITION clause. Which partition holds it?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: p2025_02

    A RANGE COLUMNS row goes to the first partition whose VALUES LESS THAN bound is greater than its value. For every February date that is p2025_02, whose bound is the first day of March.

  2. Question 2 of 3 You need the exact number of rows in p2025_03 before you drop it. Which query gives it?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: SELECT COUNT(*) FROM events PARTITION (p2025_03)

    Only a query that reads the rows counts them exactly. PARTITION (p2025_03) limits the read to that one partition, while TABLE_ROWS in information_schema.PARTITIONS is the estimate InnoDB keeps.

  3. Question 3 of 3 A sessions table is looked up by a 16-character token, and its rows must spread evenly over 8 partitions. Which partitioning fits?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: PARTITION BY KEY (token) PARTITIONS 8

    Spreading rows by a key that is not an integer is what KEY is for: the server hashes the column itself. HASH would need an integer expression, and RANGE or LIST bounds or lists nobody can know in advance.

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:

Five queries on the monthly table and the partitions each plan reads

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:

Interactive · step by step Which monthly partitions five queries read Step through five WHERE clauses on the events table. Each step lights the partitions EXPLAIN listed for it; every other partition is pruned.
Which monthly partitions five queries read, step 6 of 6. Showing p2025_01, p2025_02, p2025_03, p2025_04, p2025_05, p2025_06, pmax, id = 1234 (7 of 7 read). In focus: id = 1234, p2025_01, p2025_02, p2025_03, p2025_04, p2025_05, p2025_06, pmax.p2025_013,100 rowsp2025_022,800 rowsp2025_033,100 rowsp2025_043,000 rowsp2025_053,100 rowsp2025_063,000 rowspmaxMAXVALUE, 0 rowsid = 12347 of 7 read

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

Conditions that defeat pruning, next to one that keeps it

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) = 3 asks 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 = 20250315 compares the date column with a number. The server converts the constant once and leaves the column bare, so the plan reads p2025_03 alone.
  • postal_code = 411001 compares a VARCHAR partitioning 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 reads p1 alone.

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

  1. Question 1 of 3 A query on the events table filters created_on BETWEEN the first day of May and the first day of June. Which partitions does EXPLAIN list?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: p2025_05,p2025_06

    A range reads every partition from the one that holds its low end to the one that holds its high end. The high end here is the first day of June, in p2025_06, the same pattern as the recorded March plan that listed p2025_03,p2025_04.

  2. Question 2 of 3 EXPLAIN SELECT * FROM events WHERE id = 1234 reports rows: 1. How many partitions does its partitions column list?

    Type a number.

    Show the answer: Question 2 of 3

    Answer: 7 partitions

    The query does not filter on created_on, the partitioning column, so any partition may hold id 1234. The recorded plan listed all seven, p2025_01 to pmax: one index dive into each primary key, for one row.

  3. Question 3 of 3 A monthly report on the events table filters WHERE MONTH(created_on) = 5. What does EXPLAIN show, and what is the fix?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: All seven partitions; give the report the month's bounds as a half-open range on the bare column

    Pruning needs the bare partitioning column compared with constants. Rewritten as created_on at or after the first day of May and before the first day of June, the plan lists p2025_05 alone.

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 a partitioned table refuses, and the cost of 256 partitions

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:

DELETE, DROP PARTITION and TRUNCATE PARTITION of the same month, measured

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:

ADD PARTITION and REORGANIZE PARTITION against the catch-all

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

  1. Question 1 of 3 ALTER TABLE events ADD UNIQUE KEY (account_id, kind) fails on the partitioned events table. Why?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: The key does not contain created_on, the partitioning column, so ERROR 1503 refuses it

    ERROR 1503 names the rule: a unique index must include all columns of the table's partitioning function. (account_id, kind, created_on) would be accepted, but it promises less, since the same pair could then appear once per date.

  2. Question 2 of 3 The session removed January from three copies of the events table. Which statement deleted rows one at a time and wrote the most to the binary log?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: DELETE

    In the ROW format the binary log holds every row a DELETE removes, so its size follows the rows of the month. DROP PARTITION and TRUNCATE PARTITION are logged as statements, whatever the partition held.

  3. Question 3 of 3 July needs a partition, and the events table ends with pmax. ADD PARTITION fails with ERROR 1493. What do you run, and when?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: REORGANIZE PARTITION pmax INTO (p2025_07, pmax), a month ahead, while pmax is empty

    With a catch-all in place, a new month is made by splitting pmax. The split copies whatever pmax holds under a shared lock that holds writes back, so it runs before the month's first row arrives.

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 PARTITION into an archive table, and two tables it refuses

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 events with 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, INSERT and CREATE privileges that ALTER TABLE needs, and DROP as 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:

Interactive · stage by stage A monthly retention runbook for the events table Step through one month of the runbook: make room ahead, move the oldest month out by exchange, check it, then drop its empty partition.
A monthly retention runbook for the events table, step 6 of 6. Showing Split pmax ahead, Make archive table, EXCHANGE the oldest, Count both sides, DROP the partition. In focus: DROP the partition.shapes differSplit pmax aheadcopies 0 rows if emptyMake archive tableLIKE, no partitioningEXCHANGE the oldestno row is copiedCount both sidesempty, 3,100 archivedDROP the partitionit is empty now

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

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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:

EXCHANGE PARTITION with and without validation, on a row in the wrong month

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:

Interactive · answer and compare Which partitioning fits your table? Answer two questions about how rows leave your table and what your queries filter on. The table marks what fits and says why the rest does not.
How do rows leave the table?
What do most of your queries filter on?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four partitioning types and no partitioning, against what decides between them, from the lesson's runs
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

  1. Question 1 of 3 What does ALTER TABLE events EXCHANGE PARTITION p2025_01 WITH TABLE events_2025_01 do with January's 3,100 rows?

    Choose one answer.

    Show the answer: Question 1 of 3

    Answer: The partition and the table trade tablespaces, so the rows change tables without being copied

    EXCHANGE PARTITION swaps the contents of a partition and a table of the same structure. Nothing is copied, which is why it runs in place, and the empty partition then drops without deleting a row.

  2. Question 2 of 3 A staging table that held one August row was exchanged into p2025_07 WITHOUT VALIDATION. Which query still finds that row?

    Choose one answer.

    Show the answer: Question 2 of 3

    Answer: SELECT id, created_on FROM events PARTITION (p2025_07)

    WITHOUT VALIDATION skips the check of each row, and pruning trusts the bounds. CHECK PARTITION reported the misplaced row, REPAIR PARTITION moved it into pmax, and the August query then counted 1.

  3. Question 3 of 3 Audit events are kept for 13 months and removed one month at a time, and most queries ask for a range of created_at. Which partitioning fits?

    Choose one answer.

    Show the answer: Question 3 of 3

    Answer: RANGE COLUMNS (created_at) by month, with a catch-all split a month ahead

    Data that ages out a period at a time and is read by ranges of the same column is the case RANGE COLUMNS was made for: 13 monthly partitions plus pmax, one DROP PARTITION a month.

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 with low <= created_on <= high must read on a table partitioned by RANGE COLUMNS (created_on). Every other partition is pruned.

The arguments:

  • bounds lists the table's partitions in order, as (name, less_than) pairs. less_than is the partition's VALUES LESS THAN bound, written as an ISO date string such as "2025-02-01", or None for a catch-all partition declared VALUES LESS THAN (MAXVALUE). Only the last partition can be the catch-all.
  • low is the smallest date the query accepts, as an ISO date string, or None when the range has no lower end.
  • high is the largest date the query accepts, included as BETWEEN includes it, or None when 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 low is after its high holds 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.

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.

  1. Question 1 of 18 The first session lists the data/app folder after loading the events table. How many .ibd files does it hold for that one table?

    Type a number.

    Show the answer: Question 1 of 18

    Answer: 7 files

    Every partition is a tablespace of its own, so the six monthly partitions and pmax make seven files, from events#p#p2025_01.ibd to events#p#pmax.ibd.

  2. Question 2 of 18 An import job runs INSERT INTO events PARTITION (p2025_01) VALUES (...) for a row dated in February. What happens?

    Choose one answer.

    Show the answer: Question 2 of 18

    Answer: ERROR 1748, because the row does not belong to the partition the statement names

    The session printed Found a row not matching the given partition set for exactly this case, then inserted the same row without the clause, and it landed in p2025_02.

  3. Question 3 of 18 In the session's table PARTITION BY HASH (account_id) PARTITIONS 4, which partition holds the rows of account 7?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: p3

    A HASH partition number is the value of the expression modulo the partition count, so every row of account 7 lands in p3. With ids 1 to 500 each partition received exactly 4,525 rows.

  4. Question 4 of 18 A delivery company keeps orders by state code, and when it stops serving a region it removes all the orders of that region's states at once. Which partitioning fits?

    Choose one answer.

    Show the answer: Question 4 of 18

    Answer: LIST COLUMNS on the state code, one partition per region

    LIST places rows by named values, which matches groups that come and go as a whole. A code that no list names is refused, as RJ was with ERROR 1526, so a new region needs its list first.

  5. Question 5 of 18 A query on the events table filters created_on from the middle of February to the middle of April with BETWEEN. How many partitions does EXPLAIN list?

    Type a number.

    Show the answer: Question 5 of 18

    Answer: 3 partitions

    The range starts in p2025_02 and ends in p2025_04, and every partition between them is read: the recorded plan listed p2025_02,p2025_03,p2025_04 and estimated 8,900 rows.

  6. Question 6 of 18 A query for one day of March is pruned to p2025_03, yet its plan still reads every row of that partition. Why, and what would narrow the read?

    Choose one answer.

    Show the answer: Question 6 of 18

    Answer: The primary key starts with id, so nothing orders a partition by date; an index that starts with created_on narrows it

    The half-open March plan showed it: partitions: p2025_03, type: index, 3,100 rows, because the key (id, created_on) starts with id. Partitions narrow a query first, and indexes narrow it inside each partition.

  7. Question 7 of 18 A table partitioned BY KEY (postal_code), a VARCHAR column, is queried with WHERE postal_code = 560001. What does EXPLAIN show?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: Every partition, with warning 1739; written as the string '560001' the lookup reads one

    The defeats session showed both sides: postal_code = 411001 read p0,p1,p2,p3 with warning 1739, and postal_code = '411001' read p1 alone.

  8. Question 8 of 18 A table PARTITION BY HASH (region_id) PARTITIONS 8 on an integer column is queried with WHERE region_id BETWEEN 10 AND 20. Is the query pruned?

    Choose one answer.

    Show the answer: Question 8 of 18

    Answer: No, because the range holds 11 values, not fewer than the 8 partitions, so all 8 are read

    In the session, BETWEEN 3 AND 5 (three values) read p0,p1,p3 of four partitions, and BETWEEN 3 AND 6 (four values) read all four. Eleven values against eight partitions is past that edge.

  9. Question 9 of 18 A new payments table needs a foreign key that points at the partitioned events table. What does MySQL do?

    Choose one answer.

    Show the answer: Question 9 of 18

    Answer: It refuses with ERROR 1506, because no foreign key may point at a partitioned InnoDB table

    Foreign keys are not yet supported in conjunction with partitioning came back for a key from events and for one from event_notes to events.

  10. Question 10 of 18 What is the largest number of partitions a MySQL 8.4 InnoDB table may have, subpartitions included?

    Type a number.

    Show the answer: Question 10 of 18

    Answer: 8192 partitions

    The session's PARTITIONS 8193 failed with ERROR 1499, Too many partitions (including subpartitions) were defined; 8,192 is the limit, and each subpartition counts as one.

  11. Question 11 of 18 A table has 64 HASH partitions on id and a secondary index on email. How many index lookups does one WHERE email = ? make?

    Type a number.

    Show the answer: Question 11 of 18

    Answer: 64 lookups

    email is not the partitioning column, so the query cannot be pruned and dives into the index of every partition. The session counted Handler_read_key 1 without partitions and 256 with 256 of them.

  12. Question 12 of 18 Which statement empties January's partition of its rows but keeps the partition in the table's definition?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: ALTER TABLE events TRUNCATE PARTITION p2025_01

    TRUNCATE PARTITION was logged in 205 bytes and deleted no row one at a time, like DROP PARTITION, but the partition stays for rows that will arrive later.

  13. Question 13 of 18 Why did the DELETE of one month write 275,891 bytes to the binary log while DROP PARTITION wrote 237?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: Row-based logging writes every deleted row, while DDL is logged as one statement

    With binlog_format ROW, the MySQL 8.4 default, a DELETE is logged as the rows it removed, so a month ten times larger logs about ten times more. DROP PARTITION is logged as its statement text.

  14. Question 14 of 18 While ALTER TABLE events ALGORITHM=INPLACE, LOCK=NONE, DROP PARTITION p2025_01 runs, can another session insert a row for February?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: Yes, because DROP PARTITION on a RANGE table runs in place and accepts LOCK=NONE

    The online DDL table of the manual lists DROP PARTITION as in place and permitting writes, with no data copied for RANGE and LIST tables, which the session's statement confirmed.

  15. Question 15 of 18 Last week someone ran ALTER TABLE events ADD COLUMN note VARCHAR(20), ALGORITHM=INSTANT. Tonight's archive job builds a matching table and runs EXCHANGE PARTITION. What happens?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: ERROR 1731, and no table can be exchanged with events any more

    The session printed Non matching attribute 'INSTANT COLUMN(s)' between partition and table, and the manual states that after an instant column no partition of the table can be exchanged.

  16. Question 16 of 18 A nightly job fills a staging table for yesterday's partition with an INSERT ... SELECT filtered in UTC, while the partitions are cut at local midnight, five and a half hours away. Should the exchange skip validation?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: No; rows near midnight fall outside the partition, and a skipped check would hide them from pruned queries

    WITHOUT VALIDATION is safe only when the staging query uses the partition's own bounds. Here the validated exchange would stop with ERROR 1737, which is the signal to fix the job's filter, not to skip the check.

  17. Question 17 of 18 In the trade-off table, which options are ruled out when rows leave the table by age, a period at a time?

    Choose one answer.

    Show the answer: Question 17 of 18

    Answer: HASH and KEY

    Retiring a period by partition needs every row of the period in one partition. Only RANGE (and LIST with lists of periods) gives that; DROP PARTITION also works only on RANGE and LIST tables.

  18. Question 18 of 18 A users table of 50 million rows is looked up by id and by email, email must be unique, and rows leave only when one account is closed. Which choice fits?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: No partitioning

    Partitioning helps a table that is retired by partition or queried by the partitioning column. This one is neither, and it would lose the unique key on email that it needs.

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.

  1. 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?

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

  2. Mid to 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?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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 partitions column proves pruning: a half-open month read one of seven partitions, a BETWEEN that 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 PARTITION logged 237 bytes where a DELETE of the same 3,100 rows logged 275,891, and the catch-all is split a month ahead, while it is still empty.
  • EXCHANGE PARTITION swaps a month into an archive table of exactly the same shape without copying a row, and WITHOUT VALIDATION leaves 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.