sqlpostgresqlpartitioningtime-series

Range Partitioning in PostgreSQL: Time-Series Tables Done Right

Split a huge table by date range, drop old data in milliseconds, and let the planner read only the partitions a query needs.

3 min readReferencesql · postgresql · partitioning · time-series · performance

When a table like orders or logs grows into hundreds of millions of rows, indexes bloat, VACUUM drags on for hours, and "delete everything older than a year" becomes a DELETE that takes production down. Range partitioning fixes this: one logical table is physically split into chunks by a range of values, almost always a date.

PARTITION BY RANGE: the parent and its partitions

Declarative partitioning arrived in PostgreSQL 10 and is now the standard tool. You declare a parent table with a partition key, then attach child partitions, each owning its own FROM ... TO range.

CREATE TABLE orders (
    id         bigint        GENERATED ALWAYS AS IDENTITY,
    user_id    bigint        NOT NULL,
    amount     numeric(12,2) NOT NULL,
    status     text          NOT NULL,
    created_at timestamptz   NOT NULL
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2024 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');

CREATE TABLE orders_2025 PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2026-01-01');

Things that matter:

  • FROM is inclusive, TO is exclusive. Ranges must not overlap.
  • The partition key must be part of every UNIQUE and PRIMARY KEY. That is why the primary key here is (id, created_at), not just id.
  • Indexes created on the parent are automatically created on every partition.

Partition pruning: the planner reads only what it needs

The big speed win is pruning: when the WHERE clause constrains the partition key, the planner discards partitions that cannot possibly match the range.

EXPLAIN
SELECT sum(amount)
FROM orders
WHERE created_at >= '2025-03-01'
  AND created_at <  '2025-04-01';

The plan will reference only orders_2025 — the 2024 partition is never opened. Pruning happens both at plan time and at execution time (for parameters and JOINs). But if the query never mentions created_at — say, it filters only on user_id — Postgres has to scan every partition. That is the classic reason "partitioning didn't help."

Dropping old data in milliseconds

Retention is the killer feature. Deleting a year of rows with DELETE means hours of work and a bloated WAL. With partitions it is a metadata operation:

-- Instant: drop a whole partition with its data and indexes
DROP TABLE orders_2024;

-- Cleaner: detach first so you can archive it
ALTER TABLE orders DETACH PARTITION orders_2024;
-- ... COPY orders_2024 TO '/archive/orders_2024.csv' ...
DROP TABLE orders_2024;

DETACH turns the partition back into an ordinary standalone table — you can archive it, move it to cheap storage, or just keep it around. The reverse, ATTACH PARTITION, plugs a ready-made table in as a new partition; use it for backfills or to bring historical data online.

ALTER TABLE orders ATTACH PARTITION orders_2026
    FOR VALUES FROM ('2026-01-01') TO ('2027-01-01');

The DEFAULT partition: a catch-all trap

If a row matches no range, the INSERT fails with an error. The safety net is a DEFAULT partition that absorbs everything else:

CREATE TABLE orders_default PARTITION OF orders DEFAULT;

Gotcha: the DEFAULT partition is convenient but dangerous. If it already holds rows for 2026, then ATTACH-ing a new orders_2026 partition forces a full scan of orders_default to prove there is no overlap — under a lock. For time-series, pre-create future partitions (with pg_partman or a cron job) and keep DEFAULT empty as an alarm for missing ranges.

MySQL and ClickHouse: where they differ

  • MySQL: PARTITION BY RANGE (...) exists too, but the key must be an integer or wrapped in a function like TO_DAYS(created_at); there is no DETACH — you remove an old partition with ALTER TABLE ... DROP PARTITION.
  • ClickHouse: partitions are defined with PARTITION BY toYYYYMM(created_at) on top of the MergeTree engine; they exist mainly for DROP PARTITION and whole-part manipulation, not to speed up filters — that job belongs to the ORDER BY sort key.

Bottom line: design the partition key around how you filter and how you delete. For logs and orders that is almost always a date, sliced by month or year, with future partitions created ahead of time.

Practice on real tasks

Solve tasks in the SQL trainer with instant grading and hints.

Open trainer