Partitioning Large Postgres Tables

PostgreSQLBackendDatabasePerformance
Share on LinkedIn

The events table hit 180 GB. Vacuum ran six hours. Index rebuild required a maintenance window. Queries filtered by created_at last 7 days but Postgres scanned indexes built for all time. Declarative partitioning by month cut typical query time 40× and let us drop September data with DROP TABLE events_2025_09 instead of DELETE that locked the table for hours.

Declarative partitioning (Postgres 10+)

CREATE TABLE events (
  id BIGSERIAL,
  created_at TIMESTAMPTZ NOT NULL,
  user_id UUID NOT NULL,
  payload JSONB
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_03 PARTITION OF events
  FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');

CREATE TABLE events_2026_04 PARTITION OF events
  FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');

Inserts route automatically by created_at. Parent table holds schema; data lives in partitions.

Partition key selection

Must appear in WHERE for partition pruning:

EXPLAIN SELECT * FROM events WHERE created_at >= '2026-03-15';
-- Append -> Seq Scan on events_2026_03 only

Without partition key in query — scans all partitions (defeats purpose).

Range (time-series): monthly or weekly partitions for logs, events, metrics.

List (multi-tenant):

PARTITION BY LIST (region)
-- partitions: us, eu, apac

Hash:

PARTITION BY HASH (user_id);
-- 8 partitions for even spread — rarely pruned, helps parallel vacuum/maintenance

Indexes on partitioned tables

Create index on parent — propagates to all partitions:

CREATE INDEX ON events (user_id, created_at);

Each partition gets own index — smaller, faster to rebuild per partition.

Unique constraints require partition key inclusion:

UNIQUE (id, created_at)  -- OK
UNIQUE (id)              -- ERROR unless id globally unique with constraint on each partition

Use BIGSERIAL per partition carefully — global uniqueness needs UUID or composite.

Maintenance wins

Retention:

DROP TABLE events_2025_01;  -- instant vs DELETE millions of rows

Vacuum: autovacuum per partition — recent hot partition vacuums frequently; old partitions frozen.

Archival: detach partition, move to cheap storage:

ALTER TABLE events DETACH PARTITION events_2024_01;
-- export, attach to archive DB

Creating future partitions

Automate with pg_partman extension or cron job:

SELECT partman.create_parent(
  p_parent_table => 'public.events',
  p_control => 'created_at',
  p_type => 'native',
  p_interval => 'monthly'
);

Alert if next month's partition missing — INSERT fails hard if no matching partition.

Migration from non-partitioned table

Low-downtime approach:

  1. Create partitioned table events_new with partitions
  2. Copy data in batches or logical replication
  3. Rename swap in maintenance window:
BEGIN;
ALTER TABLE events RENAME TO events_old;
ALTER TABLE events_new RENAME TO events;
COMMIT;

Or use pg_rewrite / dual-write period for stricter SLAs.

Test query plans on staging — ORMs may need partition key hints in queries.

Common mistakes

Query planner and partitionwise operations

Postgres 11+ partitionwise joins and aggregates help when querying across few partitions — verify enable_partitionwise_join and enable_partitionwise_aggregate settings. For many partitions, default may still choose suboptimal plans — test EXPLAIN on representative queries.

Operational notes

Automated partition creation should alert if run fails — missing next month partition causes production INSERT failures at midnight on the first, a classic cron oversight incident.

Include partition key in primary key or unique constraints where global uniqueness required — planner pruning and constraint enforcement both depend on consistent key design.

Test ORM-generated SQL against partitioned parent table in staging — some ORMs generate INSERT without partition key when defaults exist, routing rows to DEFAULT partition and hiding data from expected partition scans.

Document partition attachment procedure in runbook for on-call — midnight INSERT failures from missing partition are fixed in minutes when runbook exists, hours when it does not.

When attaching DEFAULT partition catches overflow rows, monitor its size weekly — a growing DEFAULT partition signals boundary planning drift or application inserts missing partition key values.

Declarative partitioning

CREATE TABLE events (
  id BIGSERIAL,
  created_at TIMESTAMPTZ NOT NULL,
  payload JSONB
) PARTITION BY RANGE (created_at);

CREATE TABLE events_2026_01 PARTITION OF events
  FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');

Partition pruning requires created_at in WHERE clause. Drop old partitions instead of DELETE for retention.

Common production mistakes

Teams get partitioning large tables wrong in predictable ways:

Postgres work on partitioning large tables causes outages when migrations run without lock_timeout, connection pools are sized for app servers not PgBouncer modes, and EXPLAIN plans from staging are assumed to match production statistics.

Debugging and triage workflow

When partitioning large tables misbehaves in production, work top-down instead of guessing:

  1. Confirm scope — one tenant, region, or deployment stage? Narrow blast radius before deep diving.
  2. Check recent changes — deploys, flag flips, config pushes, and schema migrations in the last 24 hours.
  3. Compare golden signals — latency, error rate, saturation, and traffic for the affected surface vs. baseline.
  4. Reproduce minimally — smallest input or scenario that triggers the failure; capture traces/logs with correlation IDs.
  5. Fix forward or rollback — if rollback is faster than root-cause during incident, rollback first, postmortem second.
  6. Add a guard — alert, integration test, or circuit breaker so the same class of failure is caught earlier next time.

Document the timeline during triage. Future you (and on-call) will need timestamps, not just conclusions.

Resources

Frequently asked questions

When should you partition a Postgres table?

When a single table exceeds manageable size for vacuum, index rebuilds, and backups — often 50–100 GB+ depending on SLA — and queries consistently filter on the partition key (date, tenant region, status). Partitioning without prune-friendly queries adds complexity without benefit.

What is the difference between range and list partitioning?

Range partitions split on intervals — dates, numeric ranges. List partitions split on explicit values — country codes, tenant tiers. Hash partitioning spreads rows evenly when no natural range exists but maintenance benefits are limited.

Can you add partitions without locking the table?

CREATE TABLE ... PARTITION OF attaches a new partition with minimal lock on Postgres 11+. Dropping old partitions (DROP TABLE partition_name) is instant compared to DELETE millions of rows. Plan partition boundaries ahead to avoid runtime CREATE during peak.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →