Designing partitions DB partitions you don't have to babysit

rtolkachev1 pts0 comments

Designing Partitioning You Don't Have to Babysit

Home

About

Contact

Dark Mode

Table of contents

Designing Partitioning You Don't Have to Babysit<br>Six months in, p_future holds 800M rows because the growth projection didn't survive the workload, and every ALTER to fix it needs a maintenance window nobody wants to schedule. The boundary management is two lines of DDL; the harder part is picking a partition key that doesn't leak into application code.

By Ruslan Tolkachev

TL;DR Partition by the primary key, not by created_at, and let a background service manage boundaries based on observed growth. Queries keep using the keys they already have, partition pruning works automatically, and the partition column never leaks into application code. The same &ldquo;service watches and adjusts&rdquo; pattern applies to hash and list partitioning with different operations.

The orders dashboard started loading slowly the week after the partitioning deploy, and the team&rsquo;s first instinct is to blame the new index strategy. The actual culprit shows up in EXPLAIN: thirty-six lines of Partitions: orders_p2025_01, orders_p2025_02, ... on a query that&rsquo;s just SELECT * FROM orders WHERE id = 12345. The plan reads every partition because the WHERE clause doesn&rsquo;t include created_at, and created_at is the partition key. The lookup that used to be one index probe is now thirty-six.<br>The proposed fix is the one that always gets proposed: add created_at >= '2024-11-01' to the dashboard query. It works. The plan drops to one partition. Then the audit page does the same thing, then the admin tool, then the migration script. Three months later there&rsquo;s an internal lint rule that flags any SELECT FROM orders without a date filter, and code reviews include &ldquo;did you add the partition filter?&rdquo; as a standard check. The partition key has stopped being a storage decision and become a contract every query has to honor. Forgetting still produces no error. Just slowness.<br>The partition key problem<br>Both PostgreSQL and MySQL require the partition key to be part of any primary key or unique constraint on the table. That rule exists for correctness: if the primary key didn&rsquo;t include the partition key, the database couldn&rsquo;t enforce uniqueness without scanning every partition.<br>The consequence is that if you want to partition by created_at, you can&rsquo;t just have PRIMARY KEY (id) anymore. You need PRIMARY KEY (id, created_at). The date column is now part of the primary key whether your application needed it to be or not.<br>The more subtle cost is that id is no longer unique in the eyes of the database. Uniqueness is enforced on the tuple (id, created_at): the database will cheerfully accept two rows with the same id as long as they have different timestamps. The application probably still treats id as unique, but nothing in the schema guarantees it. And you can&rsquo;t recover the guarantee with a separate UNIQUE (id) constraint: both MySQL and PostgreSQL require every unique constraint on a partitioned table to include the partition key columns. The uniqueness property has effectively been traded away.<br>This isn&rsquo;t purely cosmetic; it changes the query plans the optimizer is willing to generate:<br>With PRIMARY KEY (id), WHERE id = 1 is a constant-time lookup. MySQL&rsquo;s EXPLAIN shows this as the const access type; the optimizer knows exactly one row matches and the executor stops after finding it. Joins on id are eq_ref, the fastest join access type.<br>With PRIMARY KEY (id, created_at), the same query becomes a ref lookup: a prefix scan on the leftmost index column that could, as far as the database is concerned, return multiple rows. Joins that used to be eq_ref become ref. Cardinality estimates fall back to index statistics instead of the guaranteed &ldquo;one row&rdquo; assumption, which can push the optimizer toward worse plans further up the query tree.<br>To get the old const plan back, every lookup has to spell out the full primary key:<br>-- Was a const lookup, now a ref lookup (one of potentially many rows)<br>SELECT * FROM orders WHERE id = 1;

-- Back to const, but only if the caller knows the created_at<br>SELECT * FROM orders WHERE id = 1 AND created_at = '2026-04-01 12:34:56';

That&rsquo;s the same leakage as partition pruning, from a different angle: the partition key has forced its way into queries that had nothing to do with dates, first to get pruning and now to get single-row access.<br>10<br>11<br>12<br>13<br>14<br>15<br>16<br>17<br>18<br>19<br>20<br>21<br>-- Before partitioning<br>CREATE TABLE orders (<br>id BIGINT AUTO_INCREMENT PRIMARY KEY,<br>customer_id BIGINT NOT NULL,<br>total_cents INT NOT NULL,<br>created_at DATETIME NOT NULL<br>);

-- After partitioning by month<br>CREATE TABLE orders (<br>id BIGINT AUTO_INCREMENT,<br>customer_id BIGINT NOT NULL,<br>total_cents INT NOT NULL,<br>created_at DATETIME NOT NULL,<br>PRIMARY KEY (id, created_at) -- created_at forced into the PK<br>PARTITION BY RANGE (TO_DAYS(created_at)) (<br>PARTITION p202601 VALUES LESS THAN...

partition created_at primary rsquo orders partitioning

Related Articles