PostgreSQL Partitioning Best Practices¶
Objective: Master senior-level PostgreSQL partitioning strategies for production systems. When you need to manage large tables efficiently, when you want to improve query performance, when you need enterprise-grade partitioning strategies—these best practices become your weapon of choice.
Core Principles¶
- Partition Pruning: Design for automatic partition elimination
- Maintenance Efficiency: Easy partition management and cleanup
- Query Performance: Optimize for common query patterns
- Storage Optimization: Efficient disk usage and backup strategies
- Monitoring: Track partition usage and performance
Range Partitioning¶
Time-Based Partitioning¶
-- Create partitioned table for time-series data
CREATE TABLE sensor_data (
id BIGSERIAL,
sensor_id INTEGER NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
value DOUBLE PRECISION NOT NULL,
metadata JSONB
) PARTITION BY RANGE (timestamp);
-- Create monthly partitions
CREATE TABLE sensor_data_2024_01 PARTITION OF sensor_data
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE sensor_data_2024_02 PARTITION OF sensor_data
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
CREATE TABLE sensor_data_2024_03 PARTITION OF sensor_data
FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
-- Create indexes on partitions
CREATE INDEX idx_sensor_data_2024_01_sensor_id ON sensor_data_2024_01 (sensor_id);
CREATE INDEX idx_sensor_data_2024_01_timestamp ON sensor_data_2024_01 (timestamp);
-- Insert data (automatically routed to correct partition)
INSERT INTO sensor_data (sensor_id, timestamp, value, metadata)
VALUES (1, '2024-01-15 10:30:00+00', 25.5, '{"unit": "celsius", "location": "room1"}');
Automatic Partition Creation¶
-- Function to create monthly partitions automatically
CREATE OR REPLACE FUNCTION create_monthly_partition(table_name text, start_date date)
RETURNS void AS $$
DECLARE
partition_name text;
end_date date;
start_month text;
BEGIN
start_month := to_char(start_date, 'YYYY_MM');
partition_name := table_name || '_' || start_month;
end_date := start_date + interval '1 month';
EXECUTE format('CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
partition_name, table_name, start_date, end_date);
-- Create indexes on the new partition
EXECUTE format('CREATE INDEX %I ON %I (sensor_id)',
'idx_' || partition_name || '_sensor_id', partition_name);
EXECUTE format('CREATE INDEX %I ON %I (timestamp)',
'idx_' || partition_name || '_timestamp', partition_name);
RAISE NOTICE 'Created partition % for date range % to %', partition_name, start_date, end_date;
END;
$$ LANGUAGE plpgsql;
-- Create partitions for the next 12 months
DO $$
DECLARE
current_date date := date_trunc('month', CURRENT_DATE);
i integer;
BEGIN
FOR i IN 0..11 LOOP
PERFORM create_monthly_partition('sensor_data', current_date + (i || ' months')::interval);
END LOOP;
END $$;
Hash Partitioning¶
Hash Partitioning for Even Distribution¶
-- Create hash-partitioned table for user data
CREATE TABLE user_events (
id BIGSERIAL,
user_id INTEGER NOT NULL,
event_type VARCHAR(50) NOT NULL,
event_data JSONB,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
) PARTITION BY HASH (user_id);
-- Create hash partitions
CREATE TABLE user_events_0 PARTITION OF user_events
FOR VALUES WITH (modulus 4, remainder 0);
CREATE TABLE user_events_1 PARTITION OF user_events
FOR VALUES WITH (modulus 4, remainder 1);
CREATE TABLE user_events_2 PARTITION OF user_events
FOR VALUES WITH (modulus 4, remainder 2);
CREATE TABLE user_events_3 PARTITION OF user_events
FOR VALUES WITH (modulus 4, remainder 3);
-- Create indexes on each partition
CREATE INDEX idx_user_events_0_user_id ON user_events_0 (user_id);
CREATE INDEX idx_user_events_0_created_at ON user_events_0 (created_at);
CREATE INDEX idx_user_events_0_event_type ON user_events_0 (event_type);
CREATE INDEX idx_user_events_1_user_id ON user_events_1 (user_id);
CREATE INDEX idx_user_events_1_created_at ON user_events_1 (created_at);
CREATE INDEX idx_user_events_1_event_type ON user_events_1 (event_type);
-- Similar indexes for other partitions...
List Partitioning¶
Geographic Partitioning¶
-- Create list-partitioned table for regional data
CREATE TABLE sales_data (
id BIGSERIAL,
region VARCHAR(20) NOT NULL,
product_id INTEGER NOT NULL,
sale_amount DECIMAL(10,2) NOT NULL,
sale_date DATE NOT NULL,
customer_id INTEGER NOT NULL
) PARTITION BY LIST (region);
-- Create regional partitions
CREATE TABLE sales_data_north_america PARTITION OF sales_data
FOR VALUES IN ('US', 'CA', 'MX');
CREATE TABLE sales_data_europe PARTITION OF sales_data
FOR VALUES IN ('UK', 'DE', 'FR', 'IT', 'ES');
CREATE TABLE sales_data_asia PARTITION OF sales_data
FOR VALUES IN ('JP', 'CN', 'KR', 'IN', 'SG');
CREATE TABLE sales_data_other PARTITION OF sales_data
FOR VALUES IN ('AU', 'BR', 'ZA', 'OTHER');
-- Create indexes on partitions
CREATE INDEX idx_sales_na_product_id ON sales_data_north_america (product_id);
CREATE INDEX idx_sales_na_sale_date ON sales_data_north_america (sale_date);
CREATE INDEX idx_sales_na_customer_id ON sales_data_north_america (customer_id);
CREATE INDEX idx_sales_eu_product_id ON sales_data_europe (product_id);
CREATE INDEX idx_sales_eu_sale_date ON sales_data_europe (sale_date);
CREATE INDEX idx_sales_eu_customer_id ON sales_data_europe (customer_id);
Composite Partitioning¶
Range + Hash Partitioning¶
-- Create composite partitioned table
CREATE TABLE log_entries (
id BIGSERIAL,
timestamp TIMESTAMPTZ NOT NULL,
log_level VARCHAR(10) NOT NULL,
service_name VARCHAR(50) NOT NULL,
message TEXT NOT NULL,
metadata JSONB
) PARTITION BY RANGE (timestamp);
-- Create monthly partitions with hash sub-partitioning
CREATE TABLE log_entries_2024_01 PARTITION OF log_entries
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01')
PARTITION BY HASH (service_name);
-- Create hash sub-partitions for January
CREATE TABLE log_entries_2024_01_0 PARTITION OF log_entries_2024_01
FOR VALUES WITH (modulus 4, remainder 0);
CREATE TABLE log_entries_2024_01_1 PARTITION OF log_entries_2024_01
FOR VALUES WITH (modulus 4, remainder 1);
CREATE TABLE log_entries_2024_01_2 PARTITION OF log_entries_2024_01
FOR VALUES WITH (modulus 4, remainder 2);
CREATE TABLE log_entries_2024_01_3 PARTITION OF log_entries_2024_01
FOR VALUES WITH (modulus 4, remainder 3);
Partition Management¶
Automated Partition Management¶
# partition_management/partition_manager.py
import psycopg2
from datetime import datetime, timedelta
import logging
class PostgreSQLPartitionManager:
def __init__(self, connection_params):
self.conn_params = connection_params
self.setup_logging()
def setup_logging(self):
"""Setup logging."""
logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(__name__)
def create_monthly_partition(self, table_name, start_date):
"""Create monthly partition for a table."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
# Generate partition name
partition_name = f"{table_name}_{start_date.strftime('%Y_%m')}"
# Create partition
create_sql = f"""
CREATE TABLE {partition_name} PARTITION OF {table_name}
FOR VALUES FROM ('{start_date}') TO ('{start_date + timedelta(days=32)}')
"""
cur.execute(create_sql)
# Create indexes
self.create_partition_indexes(cur, partition_name, table_name)
conn.commit()
self.logger.info(f"Created partition {partition_name}")
except Exception as e:
self.logger.error(f"Error creating partition: {e}")
conn.rollback()
finally:
conn.close()
def create_partition_indexes(self, cursor, partition_name, table_name):
"""Create indexes on a partition."""
# Get table structure to create appropriate indexes
cursor.execute("""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = %s AND table_schema = 'public'
ORDER BY ordinal_position
""", (table_name,))
columns = cursor.fetchall()
# Create common indexes based on column types
for column_name, data_type in columns:
if data_type in ['timestamp with time zone', 'timestamp without time zone']:
index_name = f"idx_{partition_name}_{column_name}"
cursor.execute(f"CREATE INDEX {index_name} ON {partition_name} ({column_name})")
elif data_type == 'integer':
index_name = f"idx_{partition_name}_{column_name}"
cursor.execute(f"CREATE INDEX {index_name} ON {partition_name} ({column_name})")
def drop_old_partitions(self, table_name, retention_months=12):
"""Drop partitions older than retention period."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
# Find old partitions
cutoff_date = datetime.now() - timedelta(days=retention_months * 30)
cur.execute("""
SELECT schemaname, tablename
FROM pg_tables
WHERE tablename LIKE %s
AND tablename ~ %s
ORDER BY tablename
""", (f"{table_name}_%", r'\d{4}_\d{2}'))
partitions = cur.fetchall()
for schema, partition_name in partitions:
# Extract date from partition name
date_part = partition_name.split('_')[-2] + '_' + partition_name.split('_')[-1]
partition_date = datetime.strptime(date_part, '%Y_%m')
if partition_date < cutoff_date:
drop_sql = f"DROP TABLE {schema}.{partition_name}"
cur.execute(drop_sql)
self.logger.info(f"Dropped old partition {partition_name}")
conn.commit()
except Exception as e:
self.logger.error(f"Error dropping partitions: {e}")
conn.rollback()
finally:
conn.close()
def get_partition_info(self, table_name):
"""Get information about table partitions."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes,
n_live_tup as live_tuples,
n_dead_tup as dead_tuples
FROM pg_tables pt
LEFT JOIN pg_stat_user_tables pst ON pt.tablename = pst.relname
WHERE pt.tablename LIKE %s
ORDER BY pt.tablename
""", (f"{table_name}_%",))
partitions = cur.fetchall()
return partitions
except Exception as e:
self.logger.error(f"Error getting partition info: {e}")
return []
finally:
conn.close()
def maintain_partitions(self, table_name, months_ahead=3, retention_months=12):
"""Maintain partitions for a table."""
self.logger.info(f"Maintaining partitions for {table_name}")
# Create future partitions
current_date = datetime.now().replace(day=1)
for i in range(months_ahead):
partition_date = current_date + timedelta(days=32 * i)
self.create_monthly_partition(table_name, partition_date)
# Drop old partitions
self.drop_old_partitions(table_name, retention_months)
# Get partition info
partition_info = self.get_partition_info(table_name)
self.logger.info(f"Partition maintenance completed. {len(partition_info)} partitions exist.")
# Usage
if __name__ == "__main__":
manager = PostgreSQLPartitionManager({
'host': 'localhost',
'database': 'production',
'user': 'partition_manager',
'password': 'partition_password'
})
# Maintain partitions for sensor_data table
manager.maintain_partitions('sensor_data', months_ahead=3, retention_months=12)
Query Optimization¶
Partition-Aware Queries¶
-- Optimize queries for partition pruning
-- Good: Query with partition key in WHERE clause
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM sensor_data
WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01'
AND sensor_id = 123;
-- Good: Query with multiple partition keys
EXPLAIN (ANALYZE, BUFFERS)
SELECT sensor_id, AVG(value) as avg_value
FROM sensor_data
WHERE timestamp >= '2024-01-01' AND timestamp < '2024-04-01'
GROUP BY sensor_id;
-- Bad: Query without partition key (scans all partitions)
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM sensor_data
WHERE sensor_id = 123;
-- Better: Include partition key even if not needed for logic
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM sensor_data
WHERE sensor_id = 123
AND timestamp >= '2024-01-01' AND timestamp < '2024-12-31';
Parallel Query Execution¶
-- Enable parallel query execution for partitioned tables
SET max_parallel_workers_per_gather = 4;
SET parallel_tuple_cost = 0.1;
SET parallel_setup_cost = 10.0;
-- Query that can benefit from parallel execution
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT
sensor_id,
DATE_TRUNC('hour', timestamp) as hour,
AVG(value) as avg_value,
COUNT(*) as reading_count
FROM sensor_data
WHERE timestamp >= '2024-01-01' AND timestamp < '2024-02-01'
GROUP BY sensor_id, DATE_TRUNC('hour', timestamp)
ORDER BY sensor_id, hour;
Partition Maintenance Automation¶
Cron Job for Partition Management¶
#!/bin/bash
# scripts/partition_maintenance.sh
# Configuration
DB_HOST="localhost"
DB_NAME="production"
DB_USER="partition_manager"
DB_PASSWORD="partition_password"
TABLES=("sensor_data" "log_entries" "user_events")
# Function to create future partitions
create_future_partitions() {
local table_name=$1
local months_ahead=${2:-3}
echo "Creating future partitions for $table_name"
psql -h $DB_HOST -d $DB_NAME -U $DB_USER -c "
DO \$\$
DECLARE
current_date date := date_trunc('month', CURRENT_DATE);
i integer;
BEGIN
FOR i IN 0..$months_ahead LOOP
PERFORM create_monthly_partition('$table_name', current_date + (i || ' months')::interval);
END LOOP;
END \$\$;
"
}
# Function to drop old partitions
drop_old_partitions() {
local table_name=$1
local retention_months=${2:-12}
echo "Dropping old partitions for $table_name"
psql -h $DB_HOST -d $DB_NAME -U $DB_USER -c "
DO \$\$
DECLARE
cutoff_date date := CURRENT_DATE - INTERVAL '$retention_months months';
partition_record RECORD;
BEGIN
FOR partition_record IN
SELECT schemaname, tablename
FROM pg_tables
WHERE tablename LIKE '$table_name_%'
AND tablename ~ '\d{4}_\d{2}'
LOOP
-- Extract date from partition name and drop if old
IF to_date(split_part(partition_record.tablename, '_', -2) || '_' ||
split_part(partition_record.tablename, '_', -1), 'YYYY_MM') < cutoff_date THEN
EXECUTE 'DROP TABLE ' || partition_record.schemaname || '.' || partition_record.tablename;
RAISE NOTICE 'Dropped old partition %', partition_record.tablename;
END IF;
END LOOP;
END \$\$;
"
}
# Main execution
for table in "${TABLES[@]}"; do
echo "Maintaining partitions for $table"
create_future_partitions "$table" 3
drop_old_partitions "$table" 12
done
echo "Partition maintenance completed"
Monitoring Partition Performance¶
Partition Usage Monitoring¶
# monitoring/partition_monitor.py
import psycopg2
import json
from datetime import datetime, timedelta
import logging
class PartitionMonitor:
def __init__(self, connection_params):
self.conn_params = connection_params
self.setup_logging()
def setup_logging(self):
"""Setup logging."""
logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(__name__)
def get_partition_statistics(self, table_name):
"""Get statistics for all partitions of a table."""
conn = psycopg2.connect(**self.conn_params)
try:
with conn.cursor() as cur:
cur.execute("""
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size,
pg_total_relation_size(schemaname||'.'||tablename) as size_bytes,
n_tup_ins as inserts,
n_tup_upd as updates,
n_tup_del as deletes,
n_live_tup as live_tuples,
n_dead_tup as dead_tuples,
last_vacuum,
last_autovacuum,
last_analyze,
last_autoanalyze
FROM pg_tables pt
LEFT JOIN pg_stat_user_tables pst ON pt.tablename = pst.relname
WHERE pt.tablename LIKE %s
ORDER BY pt.tablename
""", (f"{table_name}_%",))
partitions = cur.fetchall()
# Convert to list of dictionaries
partition_stats = []
for partition in partitions:
partition_stats.append({
'schema': partition[0],
'table': partition[1],
'size': partition[2],
'size_bytes': partition[3],
'inserts': partition[4] or 0,
'updates': partition[5] or 0,
'deletes': partition[6] or 0,
'live_tuples': partition[7] or 0,
'dead_tuples': partition[8] or 0,
'last_vacuum': partition[9],
'last_autovacuum': partition[10],
'last_analyze': partition[11],
'last_autoanalyze': partition[12]
})
return partition_stats
except Exception as e:
self.logger.error(f"Error getting partition statistics: {e}")
return []
finally:
conn.close()
def check_partition_health(self, table_name):
"""Check health of partitions."""
partition_stats = self.get_partition_statistics(table_name)
health_issues = []
for partition in partition_stats:
# Check for high dead tuple ratio
if partition['live_tuples'] > 0:
dead_ratio = partition['dead_tuples'] / partition['live_tuples']
if dead_ratio > 0.1: # 10% threshold
health_issues.append({
'partition': partition['table'],
'issue': 'high_dead_tuple_ratio',
'ratio': dead_ratio,
'message': f"Dead tuple ratio {dead_ratio:.2%} exceeds threshold"
})
# Check for missing statistics
if not partition['last_analyze'] and not partition['last_autoanalyze']:
health_issues.append({
'partition': partition['table'],
'issue': 'missing_statistics',
'message': 'No analyze has been run on this partition'
})
# Check for large partition size
if partition['size_bytes'] > 100 * 1024 * 1024 * 1024: # 100GB threshold
health_issues.append({
'partition': partition['table'],
'issue': 'large_partition_size',
'size': partition['size'],
'message': f"Partition size {partition['size']} exceeds threshold"
})
return health_issues
def generate_partition_report(self, table_name):
"""Generate comprehensive partition report."""
partition_stats = self.get_partition_statistics(table_name)
health_issues = self.check_partition_health(table_name)
report = {
'table_name': table_name,
'report_timestamp': datetime.now().isoformat(),
'total_partitions': len(partition_stats),
'total_size_bytes': sum(p['size_bytes'] for p in partition_stats),
'total_live_tuples': sum(p['live_tuples'] for p in partition_stats),
'total_dead_tuples': sum(p['dead_tuples'] for p in partition_stats),
'health_issues': health_issues,
'partitions': partition_stats
}
return report
# Usage
if __name__ == "__main__":
monitor = PartitionMonitor({
'host': 'localhost',
'database': 'production',
'user': 'monitor_user',
'password': 'monitor_password'
})
# Generate report for sensor_data table
report = monitor.generate_partition_report('sensor_data')
print(json.dumps(report, indent=2))
TL;DR Runbook¶
Quick Start¶
-- 1. Create partitioned table
CREATE TABLE sensor_data (
id BIGSERIAL,
sensor_id INTEGER NOT NULL,
timestamp TIMESTAMPTZ NOT NULL,
value DOUBLE PRECISION NOT NULL
) PARTITION BY RANGE (timestamp);
-- 2. Create partitions
CREATE TABLE sensor_data_2024_01 PARTITION OF sensor_data
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
-- 3. Create indexes
CREATE INDEX idx_sensor_data_2024_01_sensor_id ON sensor_data_2024_01 (sensor_id);
CREATE INDEX idx_sensor_data_2024_01_timestamp ON sensor_data_2024_01 (timestamp);
-- 4. Insert data (automatically routed)
INSERT INTO sensor_data (sensor_id, timestamp, value) VALUES (1, '2024-01-15 10:30:00+00', 25.5);
Essential Patterns¶
# Complete PostgreSQL partitioning setup
def setup_postgresql_partitioning():
# 1. Range partitioning
# 2. Hash partitioning
# 3. List partitioning
# 4. Composite partitioning
# 5. Partition management
# 6. Query optimization
# 7. Monitoring and maintenance
# 8. Automation
print("PostgreSQL partitioning setup complete!")
This guide provides the complete machinery for PostgreSQL partitioning excellence. Each pattern includes implementation examples, optimization strategies, and real-world usage patterns for enterprise PostgreSQL partitioning systems.