Cutting Query Costs on Huge Tables with PostgreSQL Range Partitioning
When a single table grows to billions of rows, every scan becomes costly. PostgreSQL’s range partitioning splits that table into smaller chunks that the planner can prune, keeping queries fast and maintenance light.
24 Jan 2026, 08:43 UTC

When a Table Turns Into a Data Monster
In many analytics or logging workloads the main table can grow to billions of rows. A naïve query that filters on a timestamp or user id still has to scan the whole table unless an index can be used. Even then, the index itself becomes huge and can cause cache pressure, slow down writes, and make VACUUM or REINDEX expensive.
Why Range Partitioning Helps
Range partitioning splits a parent table into child tables based on a value range of a column—most commonly a date or serial number. PostgreSQL automatically prunes partitions that don’t match the query’s predicate during the planning stage. The result is:
- Only the relevant partitions are touched, so I/O and CPU are reduced.
- Indexes can be defined per partition, keeping each index small and efficient.
- Maintenance commands like VACUUM, ANALYZE, and REINDEX run on each partition separately, lowering lock contention and making the database healthier.
Hands‑On Example: Partitioning a Log Table
Below is a minimal, reproducible example that demonstrates the key steps. The commands should be run as a role that owns the database, typically the postgres superuser. All placeholders are marked clearly.
- Create the parent table
CREATE TABLE logs ( id BIGSERIAL PRIMARY KEY, event_time TIMESTAMPTZ NOT NULL, event_type TEXT NOT NULL, payload JSONB NOT NULL ) PARTITION BY RANGE (event_time); - Create partitions for the last 12 months
-- Replace<YYYY-MM>with the actual year‑month. CREATE TABLE logs_2025_01 PARTITION OF logs FOR VALUES FROM ('2025-01-01') TO ('2025-02-01'); CREATE TABLE logs_2025_02 PARTITION OF logs FOR VALUES FROM ('2025-02-01') TO ('2025-03-01'); -- Repeat for each month up to the current month. - Define an index on the partition key
CREATE INDEX ON logs_2025_01 (event_time); -- Repeat for each partition. - Insert data
INSERT INTO logs (event_time, event_type, payload) VALUES ('2025-01-15 13:45:00+00', 'click', '{"page":"home"}'); - Run a query that only needs recent data
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM logs WHERE event_time >= '2025-01-01' AND event_time < '2025-02-01';The explain plan will show scans only on
logs_2025_01. You can verify that no other partitions are referenced by looking for the partition name in the plan text. - Check maintenance statistics
SELECT relname, n_dead_tup, last_vacuum, last_autovacuum FROM pg_stat_user_tables WHERE relname LIKE 'logs_%';Each partition will have its own row, indicating that VACUUM and ANALYZE operate independently.
Trade‑Offs and Caveats
- Schema complexity – You now have many child tables. DDL changes (e.g., adding a column) must be propagated to each partition or you need to use
ALTER TABLE … ATTACH PARTITIONwith a new definition. - Insert routing – When you insert without specifying
event_time, PostgreSQL routes the row to the correct partition automatically. If you omit the column or use a value that doesn’t match any partition, the insert fails. - Queries that span many partitions – A full table scan or a join that touches many partitions can still be expensive. Index design and query rewrite (e.g., using
WHERE event_time BETWEEN …instead of=) can mitigate this. - Maintenance overhead – While VACUUM runs per partition, you need to monitor each child table’s statistics and possibly run
ANALYZEon new partitions manually.
Practical Next Steps
- Plan your partition key: choose a column that naturally groups data and is frequently used in query predicates.
- Decide on partition granularity: monthly, daily, or yearly. Finer granularity reduces pruning cost but increases the number of child tables.
- Automate partition creation: write a cron job or use a tool like
pg_partmanto create and drop partitions automatically. - Monitor partition statistics: set up alerts on
pg_stat_user_tables.n_dead_tupper partition to catch bloat early. - Test queries with
EXPLAIN (ANALYZE, BUFFERS)to confirm that pruning works as expected.
By following these steps you can keep a large table manageable, reduce query latency, and maintain healthy indexes and statistics—all without a full rewrite of your application code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.