Module 9 / Growing with your data

Module 9 · lesson 3 of 8 · Read 8 min

Partitioning a table

Split one big table into smaller ones, inside one database.

Archiving took Tiffin's orders table from 200 million rows down to about 80 million, roughly the last year of orders at 200,000 to 250,000 a day. Good, but the business is still growing, and next year that number will be back over 100 million. More importantly, the nightly archive job is a slow, careful dance of batched deletes.

There is a way to keep one big table logically while storing it as many small ones underneath. Queries don't change. Archiving becomes one command. And it all happens inside the same database.

One table, many pieces

Table partitioning splits one large table into smaller physical tables, called partitions, based on the value of a column. The application still sees one table.

For Tiffin, the obvious column is created_at. Each month of orders gets its own partition:

%% caption: One logical table, stored as one physical table per month. Every row lives in exactly one partition.
flowchart TD
  O["orders<br/>the table the app sees"]:::hot --> A[("orders_2026_08<br/>August")]:::store & B[("orders_2026_09<br/>September")]:::store & C[("orders_2026_10<br/>October")]:::store
One logical table, stored as one physical table per month. Every row lives in exactly one partition.

When the app inserts an order on 18 October, the database puts the row in orders_2026_10. When it reads an order, the database figures out which partitions could possibly hold the answer and looks only there.

This is all one database on one machine. That's the key difference from sharding, which splits data across many databases. We'll get to sharding later in this module. Partitioning is the cheap, local version.

What it looks like in Postgres

Postgres has had built-in partitioning since version 10. You declare the parent table and say how it's split:

CREATE TABLE orders (
  id            bigint      NOT NULL,
  customer_id   bigint      NOT NULL,
  restaurant_id bigint      NOT NULL,
  status        text        NOT NULL,
  total_paise   integer     NOT NULL,
  created_at    timestamptz NOT NULL,
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2026_10 PARTITION OF orders
  FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');

CREATE TABLE orders_2026_11 PARTITION OF orders
  FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');

Two details are easy to miss:

  • The primary key includes created_at. In Postgres, a unique constraint on a partitioned table must include the partition column, because each partition only checks uniqueness inside itself. So the key is (id, created_at), not just id. If your IDs are generated uniquely anyway (and they should be), this is mostly harmless. The catch: a foreign key that points at orders (from order_items, say) now has to carry both columns too.
  • Partitions must exist before rows arrive. If 1 November comes and orders_2026_11 doesn't exist, inserts fail. So a scheduled job creates next month's partition a few weeks ahead. Tools like the pg_partman extension do this for you.

Indexes you create on the parent are created on every partition automatically. Each partition gets its own, smaller index.

Why it helps

Queries skip partitions they don't need

The restaurant tablet asks for today's orders:

SELECT id, status, total_paise
FROM orders
WHERE restaurant_id = 4812
  AND created_at >= '2026-10-18'
  AND created_at <  '2026-10-19';

Postgres sees the created_at filter and knows only orders_2026_10 can match. It never opens the other partitions. This is called partition pruning. Instead of searching one index over 80 million rows, it searches one over about 7.5 million.

The hot partition stays in memory

This month's partition and its indexes are a few gigabytes. They're read and written constantly, so they stay in RAM. Old partitions sit on disk and nobody disturbs them. The working-set problem from the first lesson mostly goes away, because hot and cold rows are no longer mixed in the same pages.

Archiving becomes almost free

Remember the 7-hour batched delete? With monthly partitions, archiving last year's October looks like this:

ALTER TABLE orders DETACH PARTITION orders_2025_10;
-- export orders_2025_10 to Parquet, verify it, then:
DROP TABLE orders_2025_10;

Dropping a table takes milliseconds, whether it holds 1 row or 10 million, because the database deletes its files instead of its rows. No dead rows, no huge log, no replica lag. Detaching needs a brief lock on the parent table, so on Postgres 14+ use DETACH PARTITION ... CONCURRENTLY to avoid stalling live queries. This alone is often the reason teams partition.

Maintenance gets smaller

VACUUM, index rebuilds and statistics updates now run on one partition at a time. Old partitions that never change hardly need any maintenance at all.

Will this query prune?

With orders partitioned by month on created_at, which of these queries can skip partitions?

  1. SELECT * FROM orders WHERE id = 99812345
  2. SELECT * FROM orders WHERE customer_id = 77 AND created_at > now() - interval '30 days'
  3. SELECT count(*) FROM orders WHERE status = 'placed'
Show one answer
  1. No. The database has no idea which month order 99812345 is in, so it checks the id index on every partition. With 12 partitions that's 12 small index lookups instead of one. Still fast, but not free. If the ID encodes its creation time, the app can add a created_at range to the query and get pruning back.
  2. Yes. The created_at filter limits it to one or two partitions.
  3. No. No created_at filter, so every partition is checked. If this is the kitchen screen's hot query, add a time filter ("placed in the last 6 hours"), since a placed order older than that is a bug anyway.

Choosing how to split

Range by time is the most common choice for data like orders, events and logs, because almost every query is about recent data and old data gets archived by age. But there are other kinds:

Kind Rows go to a partition by... Good for
Range A range of values, like a month Time-series data, archiving by age
List An explicit list of values, like city IN ('pune', 'nagpur') Data naturally grouped by a small set of values
Hash A hash of a column, spread over N partitions Spreading rows evenly when there's no useful range

The rule for picking the column: choose the column that most of your important queries filter on. If your hot queries all have a time filter, partition by time. If they all have a city filter and never a time filter, partitioning by time will make them slower, because every query now visits every partition.

The costs

Partitioning is cheap, not free.

  • Queries without the partition column touch every partition. One query scanning 36 partitions is slower than the same query on one table with a good index. Check your slow-query log before you partition.
  • Too many partitions hurt. Planning a query over thousands of partitions costs real time. Monthly or weekly is usually right. Daily partitions for ten years is 3,650 tables, which is too many for most workloads.
  • Unique constraints must include the partition key. You can't enforce "email is unique" across a table partitioned by time. Keep tables that need global uniqueness unpartitioned.
  • Migrating an existing table is work. You can't flip a switch on an 80-million-row table. The usual path is to create a new partitioned table, write new rows to it, copy old rows across in batches, then swap names. Do it before the table is huge, not after.

Partitioning versus sharding

People mix these up, so here's the difference side by side:

Partitioning Sharding
Where the pieces live One database server Many database servers
Helps with Big tables, archiving, maintenance, working set Write load or data size beyond one machine
App changes needed Usually none Routing, cross-shard queries, IDs
Transactions and joins Work as normal Hard across shards

Partitioning does not give you more CPU, more memory or more write throughput. If the primary can't keep up with writes, partitioning won't save it. What it gives you is a big table that behaves like a small one. For many apps, that's all they need for years.

Module 14 goes deep on splitting data across machines: range versus hash, hot spots and rebalancing. Many of the same ideas, like choosing a key that matches your queries, apply to partitions inside one database too.

Partition the rider pings

Tiffin keeps 30 days of rider location history, one point every 30 seconds per delivery: 250,000 deliveries x 30 points, about 7.5 million rows a day. Queries look up one delivery's path (WHERE order_id = ?) and support looks at a rider's pings for a given hour. How would you partition this table, and how would you delete old data?

Show one answer

Range partitions by day on the ping timestamp. That's 30 to 31 partitions at any time, each about 7.5 million rows, and a nightly job drops the oldest one in milliseconds instead of deleting 7.5 million rows.

To get pruning, both queries should include a time range. Support already has the hour. For a delivery's path, the app knows when the order was placed, so it can add AND recorded_at BETWEEN placed_at AND placed_at + interval '3 hours'. Index each partition on (order_id, recorded_at) and (rider_id, recorded_at).

What to remember

  1. Partitioning splits one big table into smaller physical tables inside one database. The app still sees one table.
  2. Partition by the column most important queries filter on. For orders and events, that's usually time.
  3. Partition pruning lets queries skip partitions they can't match, and keeps the hot partition in memory.
  4. Archiving becomes detach-and-drop: milliseconds instead of hours of batched deletes.
  5. Costs: queries without the partition column hit every partition, unique keys must include it, and too many partitions slow planning.
  6. Partitioning does not add capacity. It makes big tables behave like small ones. More capacity needs replicas or, later, sharding.