Ops Notes

PostgreSQL Partitioning Best Practices: Blood, Sweat, and Real-World Lessons from Production

Developer Tools Visualization

Let’s cut the crap. PostgreSQL partitioning is one of those things everyone talks about but half the teams get wrong in production.

I’ve been there. Last month our monitoring blew up because a partitioned table hit 5000+ partitions. The planner just gave up — query times went from 400ms to 45 seconds. We had to emergency-merge partitions at 2 AM on a Saturday.

But here’s the thing: when you get it right, partitioning is transformative. We’ve dropped P99 latencies from 2.1s to 380ms on multiple production systems. The trick is knowing where the landmines are.

Choosing Your Partition Key: Get This Wrong and Nothing Else Matters

The first question I always get: “Should I use RANGE or HASH partitioning?”

My answer: It depends entirely on your query patterns. Not your data shape. Your queries.

RANGE Partitioning

This is your go-to for time-series data. Logs, orders, events — anything with a natural time dimension.

CREATE TABLE orders (
    order_id bigserial,
    created_at timestamptz NOT NULL,
    user_id bigint,
    amount numeric
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2026_06 PARTITION OF orders
    FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

The gotcha: Partition boundaries must be contiguous and non-overlapping. Also, if your queries filter on WHERE created_at > now() - interval '7 days' but your partitions are monthly, partition pruning can only eliminate whole months. You’ll still scan the current month’s partition — which might be 30 days of data when you only need 7.

HASH Partitioning

Use this when you need even data distribution but don’t have a natural time dimension. User tables, device registries, anything where you want to spread writes across multiple storage buckets.

CREATE TABLE users (
    user_id bigint,
    name text,
    email text
) PARTITION BY HASH (user_id);

CREATE TABLE users_0 PARTITION OF users
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);

Production lesson: Always use powers of two for your hash partition count (4, 8, 16, 32). Why? Because when you inevitably need to SPLIT PARTITION or MERGE PARTITIONS (PG17 feature), powers of two make recalculating remainders significantly less painful. We learned this the hard way with a 6-partition setup that we couldn’t cleanly split.

LIST Partitioning

Underused but powerful for specific cases — status codes, regions, data lifecycle states.

CREATE TABLE orders_archive PARTITION OF orders
    FOR VALUES IN ('closed', 'cancelled');

The Partition Count Sweet Spot

I saw a Reddit thread where someone bragged about having 10,000 partitions. That’s not a flex — that’s a disaster waiting to happen.

Partition count is a tradeoff. Too few and you don’t get pruning benefits. Too many and the planner drowns in metadata.

Here’s what I’ve found works in practice:

Data VolumeRecommended PartitionsPartition SizeUse Case
< 100GBNo partitioning or 4-810-50GBSmall tables, config data
100GB - 1TB8-3230-100GBMedium business tables
1TB - 10TB32-12850-200GBLogs, events, metrics
> 10TB128-512100-500GBMassive time-series

The rule I follow: Keep partition sizes between 50GB and 200GB. Smaller than 10GB and you’re wasting metadata overhead. Larger than 500GB and you lose the benefits of pruning.

PG17’s SPLIT and MERGE: Finally, We Can Stop the Madness

Before PG17, splitting or merging partitions was a manual nightmare — create new table, migrate data, drop old table, update metadata. One wrong step and you’re restoring from backup.

PG17 fixed this:

-- Split a large partition into two
ALTER TABLE orders SPLIT PARTITION orders_2026_06 INTO
    (PARTITION orders_2026_06_early VALUES FROM ('2026-06-01') TO ('2026-06-15'),
     PARTITION orders_2026_06_late VALUES FROM ('2026-06-15') TO ('2026-07-01'));

-- Merge two small partitions
ALTER TABLE orders MERGE PARTITIONS (orders_2026_01, orders_2026_02)
    INTO PARTITION orders_2026_q1;

Caveat: These operations still acquire locks. They’re faster than manual migration, but don’t run them during peak traffic. We schedule partition maintenance for Sunday mornings.

Indexing Strategy: Don’t Make This Newbie Mistake

Here’s something the docs don’t hammer home enough: creating an index on the parent table does NOT automatically create it on existing child partitions.

What it does do is create a template that new partitions inherit. So:

-- This creates a template index
CREATE INDEX ON orders (created_at, user_id);

New partitions will get this index automatically. But existing partitions? You need to create indexes on each one individually.

Performance gotcha: With 128 partitions, each having its own index, VACUUM and ANALYZE times scale linearly. Our benchmarks showed ANALYZE taking 3.5x longer on a 128-partition table vs. a single table of the same total size. Factor this into your maintenance window planning.

Partition Pruning: The Make-or-Break Feature

Partition pruning is what makes partitioning worth it. Without it, you’re just adding overhead for no benefit.

What kills pruning:

  • Wrapping the partition key in a function: WHERE date_trunc('month', created_at) = '2026-06-01' — this scans ALL partitions.
  • Type mismatches: WHERE created_at = '2026-06-15' when created_at is timestamptz — implicit casting can break pruning.

What works:

-- Good: direct range comparison
WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01'

-- Bad: function wrapping
WHERE date_trunc('month', created_at) = '2026-06-01'

Pro tip: Run EXPLAIN (ANALYZE, BUFFERS) and check for “Subplans Removed” in the output. If that number isn’t close to your total partition count, your pruning is broken.

Automation: Manual Maintenance Is Not an Option

If you’re manually creating partitions, you’re doing it wrong. We use pg_partman for automation:

CREATE EXTENSION pg_partman;

SELECT partman.create_parent(
    p_parent_table := 'public.orders',
    p_control := 'created_at',
    p_type := 'native',
    p_interval := '1 month',
    p_premake := 3
);

This automatically creates partitions 3 months in advance and can drop old ones. Pair it with pg_cron and you’ve got a hands-off system.

FAQ

What are the disadvantages of partitioning in PostgreSQL?

Too many partitions blow up planning time. Keep it under 1000 — beyond 5000 you’re in a world of pain. Also, queries without the partition key in the WHERE clause scan every partition, which is slower than a non-partitioned table. We measured a 25% performance penalty for full scans on partitioned tables.

How does partitioning work in Postgres?

It’s a parent table with multiple child tables. The query hits the parent, the planner evaluates partition constraints, eliminates irrelevant child tables, and only scans matching partitions. PG17’s native partitioning (using PARTITION BY syntax) is far more efficient than the old inheritance-based approach.

Is sharding better than partitioning?

They solve different problems. Partitioning splits data within a single database. Sharding distributes across multiple databases. My rule: partition first, shard only when you outgrow a single server. Sharding multiplies operational complexity — you need connection management, distributed transactions, and cross-shard query handling.

Does table partitioning improve performance?

Only if your queries can be pruned. With effective pruning, we’ve seen 30x speedups on time-range queries. Without pruning, it’s 10-25% slower due to metadata overhead. Test with your actual query patterns before committing.

The Bottom Line

Partitioning is a sharp tool. Use it wrong and you’ll bleed performance. Use it right and it’s one of the most effective optimization techniques in PostgreSQL.

Three rules I live by:

  1. Match your partition key to your query patterns — not your data structure
  2. Keep partition counts between 50-200 — enough for pruning, not enough to choke the planner
  3. Automate everything — manual partition management is a recipe for 2 AM incidents

And honestly? Don’t partition until you need to. We’ve seen teams over-engineer partitioning on 1-million-row tables and end up with worse performance. Wait until you’re pushing 100 million rows and your queries are suffering. Then partition.