Partitioning Patterns — Time-Series, Multi-Tenant, and Archival

Partitioning Patterns — Time-Series, Multi-Tenant, and Archival

Partitioning is not one-size-fits-all. Different workloads call for different partition strategies, granularities, and maintenance patterns. This lesson covers the three most common real-world partitioning scenarios.


Pattern 1: Time-series with automated partition creation

For growing time-series tables, you need to create new partitions before data arrives. The standard approach is a function called by a cron job or application before each period:

-- Function to create the next month's partition automatically
CREATE OR REPLACE FUNCTION create_monthly_partition(
    parent_table TEXT,
    partition_date DATE
) RETURNS TEXT AS $$
DECLARE
    partition_name TEXT;
    start_date DATE;
    end_date DATE;
BEGIN
    partition_name := parent_table || '_' || TO_CHAR(partition_date, 'YYYY_MM');
    start_date := DATE_TRUNC('month', partition_date);
    end_date := start_date + INTERVAL '1 month';

    -- Create the partition if it doesn't exist
    IF NOT EXISTS (
        SELECT 1 FROM pg_class WHERE relname = partition_name
    ) THEN
        EXECUTE FORMAT(
            'CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
            partition_name, parent_table, start_date, end_date
        );
        -- Create GIN index on JSONB payload if the table has one
        EXECUTE FORMAT(
            'CREATE INDEX ON %I USING GIN (payload)',
            partition_name
        );
        RETURN 'Created: ' || partition_name;
    END IF;

    RETURN 'Already exists: ' || partition_name;
END;
$$ LANGUAGE plpgsql;

-- Create partitions for the next 3 months:
SELECT create_monthly_partition('events_partitioned', (CURRENT_DATE + (n || ' months')::INTERVAL)::DATE)
FROM generate_series(0, 2) n;

Pattern 2: Multi-tenant partitioning by LIST

For SaaS applications with many tenants (clients), LIST partitioning by tenant_id or region ensures each tenant's data is physically isolated:

CREATE TABLE tenant_events (
    event_id    BIGSERIAL,
    tenant_id   INT NOT NULL,
    event_type  TEXT NOT NULL,
    occurred_at TIMESTAMPTZ NOT NULL,
    payload     JSONB
) PARTITION BY LIST (tenant_id);

-- Enterprise tier: dedicated partitions per tenant
CREATE TABLE tenant_events_t1 PARTITION OF tenant_events FOR VALUES IN (1);
CREATE TABLE tenant_events_t2 PARTITION OF tenant_events FOR VALUES IN (2);
CREATE TABLE tenant_events_t3 PARTITION OF tenant_events FOR VALUES IN (3);

-- Standard tier: shared partition for smaller tenants
CREATE TABLE tenant_events_standard PARTITION OF tenant_events
    FOR VALUES IN (4, 5, 6, 7, 8, 9, 10);

-- Default: catch new tenants until explicitly partitioned
CREATE TABLE tenant_events_default PARTITION OF tenant_events DEFAULT;

-- Every query with WHERE tenant_id = N only scans one partition
-- This is both a performance and a data isolation guarantee

Pattern 3: Archival with DETACH and ATTACH

The most operationally powerful aspect of partitioning is instant data archival:

-- ❌
-- Step 1: Create an archive schema
CREATE SCHEMA archive;

-- Step 2: Detach old partition (makes it a regular table)
ALTER TABLE orders_partitioned DETACH PARTITION orders_2022;

-- Step 3: Move to archive schema
ALTER TABLE orders_2022 SET SCHEMA archive;

-- Step 4: Optionally compress (if using pg_compress or tablespace)
-- ALTER TABLE archive.orders_2022 SET TABLESPACE cold_storage;

-- Step 5: Make it read-only
REVOKE INSERT, UPDATE, DELETE ON archive.orders_2022 FROM PUBLIC;

-- The partitioned table now has one less partition (faster scans)
-- archive.orders_2022 still contains all data (accessible for historical queries)

-- Union view for combined access
CREATE VIEW all_orders_including_archive AS
    SELECT *, 'active' AS archive_status FROM orders_partitioned
    UNION ALL
    SELECT *, 'archived' FROM archive.orders_2022;

Pattern 4: Querying partition metadata

-- List all partitions of a table with their ranges and sizes
SELECT
    c.relname AS partition,
    pg_size_pretty(pg_relation_size(c.oid)) AS size,
    s.n_live_tup AS live_rows,
    s.n_dead_tup AS dead_rows,
    pg_get_expr(c.relpartbound, c.oid) AS partition_range
FROM pg_class p
JOIN pg_inherits i ON i.inhparent = p.oid
JOIN pg_class c ON c.oid = i.inhrelid
LEFT JOIN pg_stat_user_tables s ON s.relname = c.relname
WHERE p.relname = 'orders_partitioned'
ORDER BY c.relname;

-- Which partition would a specific value go to?
SELECT tableoid::regclass AS partition
FROM orders_partitioned
WHERE ctid = (
    SELECT min(ctid) FROM orders_partitioned
    WHERE created_at = '2025-06-15'
);

Key Takeaway

Time-series tables: RANGE partition by month, automate partition creation. Multi-tenant tables: LIST partition by tenant_id to isolate data and enable per-tenant maintenance. Archival: DETACH gives you instant data isolation and a standalone table you can move to cold storage. The consistent theme: partitioning is most valuable when your access patterns have a clear dominant dimension (time, tenant) that lets the planner prune 90%+ of partitions on typical queries.