Ops Notes

PostgreSQL Partitioning in Production: A 2026 Guide with Real-World Configs and Mistakes

Developer Tools Visualization

Why I’m Writing This

Last month one of our core production tables hit 2.3TB. P99 query latency went from 30ms to 4.7 seconds. A single vacuum run took 12 hours and still didn’t finish. Someone in the DBA Slack channel joked our table was “basically cold storage at this point.”

I dug through the official docs and every community post I could find. Here’s the thing — the PostgreSQL docs are correct, but they’re not helpful. They tell you the syntax. They don’t tell you when Range partitioning will burn you, or how to unfuck a bad partition key choice.

This is the guide I wish I had three months ago. It’s everything I learned — including the parts that blew up in my face.

Step 1: Do You Actually Need Partitioning?

Don’t partition for the sake of partitioning. This is the #1 mistake I see.

Someone on r/devops put it well: “I’ve seen people partition 50GB tables thinking it’s a magic performance pill. It’s not.”

My rule of thumb:

  • Single table >500GB or >100M rows
  • Your queries have natural time or geographic boundaries
  • You need to regularly purge old data (e.g., keep last 90 days)
  • Your autovacuum is getting slower and slower

If you check 2+ boxes, go for it. Otherwise, don’t.

Step 2: Pick Your Strategy — Range or Hash?

Let me cut through the noise.

StrategyBest ForProsCons
RangeTime-series, logs, ordersGreat query locality, easy old data cleanupHotspot on latest partition
ListGeographic regions, enum status valuesControlled data distributionPoor scalability, need rebuild to add values
HashEven write distribution, avoiding hotspotsBalanced writes, no contentionTerrible for range queries, partition pruning fails on JOINs

My take: 80% of use cases should use Range with a timestamp key. For the other 20% — high-write-volume systems where you need uniform distribution — Hash is your friend.

Step 3: 2026 Syntax — What Actually Works

PostgreSQL 16+ native partitioning is mature. If you’re still using the old inheritance-based approach, stop. The Reddit threads on that topic are brutal.

Creating a Partitioned Table

-- Parent table
CREATE TABLE orders (
    id BIGSERIAL,
    created_at TIMESTAMPTZ NOT NULL,
    user_id BIGINT NOT NULL,
    amount NUMERIC(10,2),
    status VARCHAR(20)
) PARTITION BY RANGE (created_at);

-- Partitions
CREATE TABLE orders_2026_q1 PARTITION OF orders
    FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');

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

-- Indexes on parent table apply to all partitions automatically
CREATE INDEX idx_orders_user_id ON orders (user_id);
CREATE INDEX idx_orders_status ON orders (status);

Auto-Partitioning with pg_partman

Manual partition creation is a fool’s game in 2026.

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
);

UPDATE partman.part_config 
SET infinite_time_partitions = true,
    retention = '3 months',
    retention_keep_table = false
WHERE parent_table = 'public.orders';

This extension is worshipped on Reddit for good reason. My only complaint: I didn’t understand p_premake on my first try. Woke up to a 3 AM alarm because partitions weren’t pre-created.

Step 4: Partition Pruning — The Make-or-Break

If your queries don’t prune, you’ve gained nothing.

-- Good: prunes to single partition
EXPLAIN ANALYZE
SELECT * FROM orders 
WHERE created_at >= '2026-06-01' AND created_at < '2026-07-01';

-- Bad: scans ALL partitions
EXPLAIN ANALYZE
SELECT * FROM orders 
WHERE user_id = 12345;

The fix for the bad query? Sub-partition by user_id hash.

CREATE TABLE orders_2026_q2 PARTITION OF orders
    FOR VALUES FROM ('2026-04-01') TO ('2026-07-01')
    PARTITION BY HASH (user_id);

CREATE TABLE orders_2026_q2_h0 PARTITION OF orders_2026_q2
    FOR VALUES WITH (MODULUS 4, REMAINDER 0);
-- Create h1, h2, h3

Now queries filtering by user_id also prune. This saved our ass.

Step 5: Operations and Monitoring

Check Partition Sizes

SELECT
    parent.relname AS parent_table,
    child.relname AS partition_name,
    pg_size_pretty(pg_total_relation_size(child.oid)) AS size
FROM pg_inherits
    JOIN pg_class parent ON pg_inherits.inhparent = parent.oid
    JOIN pg_class child ON pg_inherits.inhrelid = child.oid
WHERE parent.relname = 'orders'
ORDER BY child.relname;

Automated Maintenance

#!/bin/bash
# Runs daily at 2 AM
psql -d mydb -c "CALL partman.partition_data_proc('public.orders', p_interval := '1 month', p_source_table := 'public.orders_old');"
psql -d mydb -c "DELETE FROM public.orders WHERE created_at < NOW() - INTERVAL '90 days';"

FAQ

Can I change the partition key after creation?

No. Once set, it’s immutable. You’d need to rebuild the entire table. Choose carefully.

Will adding new partitions cause downtime?

No. Adding a partition is a DDL operation, but PostgreSQL 16+ supports CREATE TABLE IF NOT EXISTS — no table locks.

Do foreign keys work with partitioned tables?

Yes, but with a catch: the foreign key must include the partition key. It’s a frustrating limitation.

How do I migrate existing data to a partitioned table?

Use pg_dump to export, create the partitioned table, then import. Or use INSERT INTO ... SELECT in batches. There’s no in-place conversion.

Summary

Partitioning isn’t a silver bullet. But when you get it right — right strategy, right key, automated maintenance — the performance gains are real. We saw 10-20x improvements on our worst queries.

Three rules:

  1. Pick the right strategy (Range for most cases)
  2. Make sure your queries actually prune partitions
  3. Automate everything with pg_partman

Don’t wait until your table explodes. We learned that one the hard way.

<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {
      "@type": "Question",
      "name": "Can I change the partition key after creation?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. Once set, it's immutable. You'd need to rebuild the entire table. Choose carefully."
      }
    },
    {
      "@type": "Question",
      "name": "Will adding new partitions cause downtime?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "No. Adding a partition is a DDL operation, but PostgreSQL 16+ supports CREATE TABLE IF NOT EXISTS — no table locks."
      }
    },
    {
      "@type": "Question",
      "name": "Do foreign keys work with partitioned tables?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Yes, but with a catch: the foreign key must include the partition key. It's a frustrating limitation."
      }
    },
    {
      "@type": "Question",
      "name": "How do I migrate existing data to a partitioned table?",
      "acceptedAnswer": {
        "@type": "Answer",
        "text": "Use pg_dump to export, create the partitioned table, then import. Or use INSERT INTO ... SELECT in batches. There's no in-place conversion."
      }
    }
  ]
}
</script>




## References & Community Insights
The following authoritative resources were referenced for architectural best practices and specifications:

- [PostgreSQL Official Documentation](https://www.postgresql.org/docs/)
- [Stack Overflow Engineering Blog](https://stackoverflow.blog/engineering/)
- [Redis Enterprise Architecture](https://redis.com/redis-enterprise/)
Elvin Hui

About the Author: Elvin Hui

Elvin is a Senior Infrastructure Engineer with 10+ years of experience spanning data centers, cloud-native architecture, and network security. Certified in CCNA and AWS Solutions Architecture, I focus on turning real-world production "war stories" into actionable, hardcore technical guides.