Skip to content

PostgreSQL Time Series Data Best Practices

Objective: Master senior-level PostgreSQL time series data patterns for production systems. When you need to handle time series data, when you want to optimize time-based queries, when you need enterprise-grade time series strategies—these best practices become your weapon of choice.

Core Principles

  • Partitioning: Use time-based partitioning for large datasets
  • Indexing: Create appropriate indexes for time series queries
  • Compression: Implement data compression for storage efficiency
  • Aggregation: Use materialized views for pre-computed aggregations
  • Retention: Implement data retention policies

Time Series Data Design

Time Series Table Design

-- Create time series tables with appropriate design
CREATE TABLE sensor_readings (
    id BIGSERIAL,
    sensor_id INTEGER NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    value DOUBLE PRECISION NOT NULL,
    unit VARCHAR(20) NOT NULL,
    quality_score NUMERIC(3,2) DEFAULT 1.0,
    metadata JSONB,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (timestamp);

-- Create monthly partitions
CREATE TABLE sensor_readings_2024_01 PARTITION OF sensor_readings
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

CREATE TABLE sensor_readings_2024_02 PARTITION OF sensor_readings
    FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');

CREATE TABLE sensor_readings_2024_03 PARTITION OF sensor_readings
    FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');

-- Create indexes on partitions
CREATE INDEX idx_sensor_readings_2024_01_sensor_timestamp 
    ON sensor_readings_2024_01 (sensor_id, timestamp);
CREATE INDEX idx_sensor_readings_2024_01_timestamp 
    ON sensor_readings_2024_01 (timestamp);

-- Create BRIN indexes for time series data
CREATE INDEX idx_sensor_readings_2024_01_timestamp_brin 
    ON sensor_readings_2024_01 USING BRIN (timestamp);
CREATE INDEX idx_sensor_readings_2024_01_sensor_brin 
    ON sensor_readings_2024_01 USING BRIN (sensor_id);

Time Series Data Types

-- Create tables for different time series use cases
CREATE TABLE financial_data (
    id BIGSERIAL,
    symbol VARCHAR(10) NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    open_price NUMERIC(10,4) NOT NULL,
    high_price NUMERIC(10,4) NOT NULL,
    low_price NUMERIC(10,4) NOT NULL,
    close_price NUMERIC(10,4) NOT NULL,
    volume BIGINT NOT NULL,
    adjusted_close NUMERIC(10,4),
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (timestamp);

CREATE TABLE user_events (
    id BIGSERIAL,
    user_id INTEGER NOT NULL,
    event_type VARCHAR(50) NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    session_id VARCHAR(100),
    properties JSONB,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (timestamp);

CREATE TABLE system_metrics (
    id BIGSERIAL,
    hostname VARCHAR(100) NOT NULL,
    metric_name VARCHAR(100) NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    value DOUBLE PRECISION NOT NULL,
    tags JSONB,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (timestamp);

Time Series Partitioning

Automatic Partitioning

-- Create function for automatic partition creation
CREATE OR REPLACE FUNCTION create_monthly_partition(
    table_name TEXT,
    start_date DATE
)
RETURNS VOID AS $$
DECLARE
    partition_name TEXT;
    end_date DATE;
BEGIN
    partition_name := table_name || '_' || to_char(start_date, 'YYYY_MM');
    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 new partition
    EXECUTE format('CREATE INDEX %I ON %I (timestamp)', 
                   'idx_' || partition_name || '_timestamp', partition_name);
    EXECUTE format('CREATE INDEX %I ON %I USING BRIN (timestamp)', 
                   'idx_' || partition_name || '_timestamp_brin', partition_name);
END;
$$ LANGUAGE plpgsql;

-- Create function to create partitions for the next 12 months
CREATE OR REPLACE FUNCTION create_future_partitions(table_name TEXT)
RETURNS VOID AS $$
DECLARE
    current_date DATE := CURRENT_DATE;
    i INTEGER;
BEGIN
    FOR i IN 0..11 LOOP
        PERFORM create_monthly_partition(table_name, current_date + (i || ' months')::interval);
    END LOOP;
END;
$$ LANGUAGE plpgsql;

-- Create future partitions
SELECT create_future_partitions('sensor_readings');
SELECT create_future_partitions('financial_data');
SELECT create_future_partitions('user_events');
SELECT create_future_partitions('system_metrics');

Partition Management

-- Create function to drop old partitions
CREATE OR REPLACE FUNCTION drop_old_partitions(
    table_name TEXT,
    retention_months INTEGER DEFAULT 12
)
RETURNS INTEGER AS $$
DECLARE
    partition_name TEXT;
    partition_date DATE;
    dropped_count INTEGER := 0;
BEGIN
    FOR partition_name IN 
        SELECT schemaname||'.'||tablename 
        FROM pg_tables 
        WHERE tablename LIKE table_name || '_%'
        AND schemaname = 'public'
    LOOP
        -- Extract date from partition name
        partition_date := to_date(
            regexp_replace(partition_name, '.*_(\d{4}_\d{2})', '\1'), 
            'YYYY_MM'
        );

        -- Drop partition if older than retention period
        IF partition_date < CURRENT_DATE - (retention_months || ' months')::interval THEN
            EXECUTE 'DROP TABLE ' || partition_name;
            dropped_count := dropped_count + 1;
        END IF;
    END LOOP;

    RETURN dropped_count;
END;
$$ LANGUAGE plpgsql;

-- Create function to archive old partitions
CREATE OR REPLACE FUNCTION archive_old_partitions(
    table_name TEXT,
    archive_table_name TEXT,
    retention_months INTEGER DEFAULT 6
)
RETURNS INTEGER AS $$
DECLARE
    partition_name TEXT;
    partition_date DATE;
    archived_count INTEGER := 0;
BEGIN
    FOR partition_name IN 
        SELECT schemaname||'.'||tablename 
        FROM pg_tables 
        WHERE tablename LIKE table_name || '_%'
        AND schemaname = 'public'
    LOOP
        -- Extract date from partition name
        partition_date := to_date(
            regexp_replace(partition_name, '.*_(\d{4}_\d{2})', '\1'), 
            'YYYY_MM'
        );

        -- Archive partition if older than retention period
        IF partition_date < CURRENT_DATE - (retention_months || ' months')::interval THEN
            EXECUTE format('INSERT INTO %I SELECT * FROM %I', archive_table_name, partition_name);
            EXECUTE 'DROP TABLE ' || partition_name;
            archived_count := archived_count + 1;
        END IF;
    END LOOP;

    RETURN archived_count;
END;
$$ LANGUAGE plpgsql;

Time Series Indexing

Time Series Index Strategies

-- Create comprehensive indexes for time series data
CREATE INDEX idx_sensor_readings_sensor_timestamp 
    ON sensor_readings (sensor_id, timestamp DESC);

CREATE INDEX idx_sensor_readings_timestamp_value 
    ON sensor_readings (timestamp DESC, value);

CREATE INDEX idx_sensor_readings_sensor_timestamp_value 
    ON sensor_readings (sensor_id, timestamp DESC, value);

-- Create partial indexes for active data
CREATE INDEX idx_sensor_readings_active 
    ON sensor_readings (sensor_id, timestamp DESC) 
    WHERE timestamp > CURRENT_DATE - INTERVAL '30 days';

-- Create indexes for specific time ranges
CREATE INDEX idx_sensor_readings_recent 
    ON sensor_readings (timestamp DESC) 
    WHERE timestamp > CURRENT_DATE - INTERVAL '7 days';

-- Create indexes for aggregation queries
CREATE INDEX idx_sensor_readings_hourly_agg 
    ON sensor_readings (sensor_id, date_trunc('hour', timestamp));

BRIN Indexes for Time Series

-- Create BRIN indexes for time series data
CREATE INDEX idx_sensor_readings_timestamp_brin 
    ON sensor_readings USING BRIN (timestamp);

CREATE INDEX idx_sensor_readings_sensor_brin 
    ON sensor_readings USING BRIN (sensor_id);

CREATE INDEX idx_sensor_readings_value_brin 
    ON sensor_readings USING BRIN (value);

-- Create BRIN indexes on partitions
CREATE INDEX idx_sensor_readings_2024_01_timestamp_brin 
    ON sensor_readings_2024_01 USING BRIN (timestamp);
CREATE INDEX idx_sensor_readings_2024_01_sensor_brin 
    ON sensor_readings_2024_01 USING BRIN (sensor_id);

Time Series Aggregation

Materialized Views for Aggregations

-- Create materialized views for time series aggregations
CREATE MATERIALIZED VIEW sensor_readings_hourly AS
SELECT 
    sensor_id,
    date_trunc('hour', timestamp) as hour,
    COUNT(*) as reading_count,
    AVG(value) as avg_value,
    MIN(value) as min_value,
    MAX(value) as max_value,
    STDDEV(value) as stddev_value,
    PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY value) as median_value,
    PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY value) as p95_value
FROM sensor_readings
WHERE timestamp > CURRENT_DATE - INTERVAL '30 days'
GROUP BY sensor_id, date_trunc('hour', timestamp);

-- Create index on materialized view
CREATE INDEX idx_sensor_readings_hourly_sensor_hour 
    ON sensor_readings_hourly (sensor_id, hour);

-- Create function to refresh materialized view
CREATE OR REPLACE FUNCTION refresh_sensor_aggregations()
RETURNS VOID AS $$
BEGIN
    REFRESH MATERIALIZED VIEW CONCURRENTLY sensor_readings_hourly;
END;
$$ LANGUAGE plpgsql;

Time Series Functions

-- Create function for time series aggregation
CREATE OR REPLACE FUNCTION get_sensor_statistics(
    p_sensor_id INTEGER,
    p_start_time TIMESTAMPTZ,
    p_end_time TIMESTAMPTZ,
    p_interval INTERVAL DEFAULT '1 hour'
)
RETURNS TABLE (
    time_bucket TIMESTAMPTZ,
    reading_count BIGINT,
    avg_value DOUBLE PRECISION,
    min_value DOUBLE PRECISION,
    max_value DOUBLE PRECISION,
    stddev_value DOUBLE PRECISION
) AS $$
BEGIN
    RETURN QUERY
    SELECT 
        date_trunc('hour', timestamp) as time_bucket,
        COUNT(*) as reading_count,
        AVG(value) as avg_value,
        MIN(value) as min_value,
        MAX(value) as max_value,
        STDDEV(value) as stddev_value
    FROM sensor_readings
    WHERE sensor_id = p_sensor_id
    AND timestamp BETWEEN p_start_time AND p_end_time
    GROUP BY date_trunc('hour', timestamp)
    ORDER BY time_bucket;
END;
$$ LANGUAGE plpgsql;

-- Create function for time series interpolation
CREATE OR REPLACE FUNCTION interpolate_sensor_values(
    p_sensor_id INTEGER,
    p_start_time TIMESTAMPTZ,
    p_end_time TIMESTAMPTZ,
    p_interval INTERVAL DEFAULT '1 minute'
)
RETURNS TABLE (
    timestamp TIMESTAMPTZ,
    interpolated_value DOUBLE PRECISION
) AS $$
DECLARE
    current_time TIMESTAMPTZ;
    prev_value DOUBLE PRECISION;
    next_value DOUBLE PRECISION;
    prev_time TIMESTAMPTZ;
    next_time TIMESTAMPTZ;
    interpolation_factor DOUBLE PRECISION;
BEGIN
    current_time := p_start_time;

    WHILE current_time <= p_end_time LOOP
        -- Find previous and next values
        SELECT value, timestamp INTO prev_value, prev_time
        FROM sensor_readings
        WHERE sensor_id = p_sensor_id
        AND timestamp <= current_time
        ORDER BY timestamp DESC
        LIMIT 1;

        SELECT value, timestamp INTO next_value, next_time
        FROM sensor_readings
        WHERE sensor_id = p_sensor_id
        AND timestamp >= current_time
        ORDER BY timestamp ASC
        LIMIT 1;

        -- Interpolate value
        IF prev_value IS NOT NULL AND next_value IS NOT NULL THEN
            interpolation_factor := EXTRACT(EPOCH FROM (current_time - prev_time)) / 
                                  EXTRACT(EPOCH FROM (next_time - prev_time));
            interpolated_value := prev_value + (next_value - prev_value) * interpolation_factor;
        ELSIF prev_value IS NOT NULL THEN
            interpolated_value := prev_value;
        ELSIF next_value IS NOT NULL THEN
            interpolated_value := next_value;
        ELSE
            interpolated_value := NULL;
        END IF;

        RETURN NEXT;
        current_time := current_time + p_interval;
    END LOOP;
END;
$$ LANGUAGE plpgsql;

Time Series Compression

Data Compression Strategies

-- Create function for time series compression
CREATE OR REPLACE FUNCTION compress_sensor_data(
    p_sensor_id INTEGER,
    p_start_time TIMESTAMPTZ,
    p_end_time TIMESTAMPTZ,
    p_compression_ratio NUMERIC DEFAULT 0.1
)
RETURNS TABLE (
    compressed_timestamp TIMESTAMPTZ,
    compressed_value DOUBLE PRECISION,
    compression_method TEXT
) AS $$
DECLARE
    total_points INTEGER;
    target_points INTEGER;
    step_size INTEGER;
    current_time TIMESTAMPTZ;
    current_value DOUBLE PRECISION;
BEGIN
    -- Calculate compression parameters
    SELECT COUNT(*) INTO total_points
    FROM sensor_readings
    WHERE sensor_id = p_sensor_id
    AND timestamp BETWEEN p_start_time AND p_end_time;

    target_points := GREATEST(1, FLOOR(total_points * p_compression_ratio));
    step_size := GREATEST(1, FLOOR(total_points / target_points));

    -- Return compressed data
    RETURN QUERY
    SELECT 
        timestamp as compressed_timestamp,
        value as compressed_value,
        'uniform_sampling'::TEXT as compression_method
    FROM (
        SELECT timestamp, value,
               ROW_NUMBER() OVER (ORDER BY timestamp) as rn
        FROM sensor_readings
        WHERE sensor_id = p_sensor_id
        AND timestamp BETWEEN p_start_time AND p_end_time
    ) t
    WHERE rn % step_size = 1
    ORDER BY timestamp;
END;
$$ LANGUAGE plpgsql;

Data Retention Policies

-- Create function for data retention
CREATE OR REPLACE FUNCTION apply_retention_policy(
    table_name TEXT,
    retention_days INTEGER DEFAULT 365
)
RETURNS INTEGER AS $$
DECLARE
    deleted_count INTEGER;
    cutoff_date TIMESTAMPTZ;
BEGIN
    cutoff_date := CURRENT_TIMESTAMP - (retention_days || ' days')::interval;

    EXECUTE format('DELETE FROM %I WHERE timestamp < %L', table_name, cutoff_date);

    GET DIAGNOSTICS deleted_count = ROW_COUNT;

    RETURN deleted_count;
END;
$$ LANGUAGE plpgsql;

-- Create function for data archiving
CREATE OR REPLACE FUNCTION archive_old_data(
    source_table TEXT,
    archive_table TEXT,
    retention_days INTEGER DEFAULT 365
)
RETURNS INTEGER AS $$
DECLARE
    archived_count INTEGER;
    cutoff_date TIMESTAMPTZ;
BEGIN
    cutoff_date := CURRENT_TIMESTAMP - (retention_days || ' days')::interval;

    EXECUTE format('INSERT INTO %I SELECT * FROM %I WHERE timestamp < %L', 
                   archive_table, source_table, cutoff_date);

    GET DIAGNOSTICS archived_count = ROW_COUNT;

    EXECUTE format('DELETE FROM %I WHERE timestamp < %L', source_table, cutoff_date);

    RETURN archived_count;
END;
$$ LANGUAGE plpgsql;

Time Series Monitoring

Time Series Performance Monitoring

# monitoring/timeseries_monitor.py
import psycopg2
import json
from datetime import datetime, timedelta
import logging

class TimeSeriesMonitor:
    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):
        """Get partition statistics."""
        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
                    FROM pg_stat_user_tables
                    WHERE tablename LIKE '%_2024_%'
                    ORDER BY size_bytes DESC
                """)

                partition_stats = cur.fetchall()
                return partition_stats

        except Exception as e:
            self.logger.error(f"Error getting partition statistics: {e}")
            return []
        finally:
            conn.close()

    def get_time_series_performance(self):
        """Get time series query performance."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                # Test time series query performance
                start_time = datetime.now()
                cur.execute("""
                    SELECT COUNT(*) 
                    FROM sensor_readings 
                    WHERE timestamp > CURRENT_DATE - INTERVAL '1 day'
                """)
                daily_count_time = (datetime.now() - start_time).total_seconds()

                start_time = datetime.now()
                cur.execute("""
                    SELECT sensor_id, AVG(value) 
                    FROM sensor_readings 
                    WHERE timestamp > CURRENT_DATE - INTERVAL '1 hour'
                    GROUP BY sensor_id
                """)
                hourly_agg_time = (datetime.now() - start_time).total_seconds()

                start_time = datetime.now()
                cur.execute("""
                    SELECT * 
                    FROM sensor_readings 
                    WHERE sensor_id = 1 
                    AND timestamp > CURRENT_DATE - INTERVAL '1 day'
                    ORDER BY timestamp DESC
                    LIMIT 1000
                """)
                recent_data_time = (datetime.now() - start_time).total_seconds()

                return {
                    'daily_count_time': daily_count_time,
                    'hourly_agg_time': hourly_agg_time,
                    'recent_data_time': recent_data_time
                }

        except Exception as e:
            self.logger.error(f"Error getting time series performance: {e}")
            return {}
        finally:
            conn.close()

    def get_data_retention_status(self):
        """Get data retention status."""
        conn = psycopg2.connect(**self.conn_params)

        try:
            with conn.cursor() as cur:
                cur.execute("""
                    SELECT 
                        'sensor_readings' as table_name,
                        MIN(timestamp) as oldest_data,
                        MAX(timestamp) as newest_data,
                        COUNT(*) as total_records,
                        COUNT(*) FILTER (WHERE timestamp > CURRENT_DATE - INTERVAL '30 days') as recent_records
                    FROM sensor_readings
                    UNION ALL
                    SELECT 
                        'financial_data' as table_name,
                        MIN(timestamp) as oldest_data,
                        MAX(timestamp) as newest_data,
                        COUNT(*) as total_records,
                        COUNT(*) FILTER (WHERE timestamp > CURRENT_DATE - INTERVAL '30 days') as recent_records
                    FROM financial_data
                """)

                retention_status = cur.fetchall()
                return retention_status

        except Exception as e:
            self.logger.error(f"Error getting data retention status: {e}")
            return []
        finally:
            conn.close()

    def generate_timeseries_report(self):
        """Generate comprehensive time series report."""
        partition_stats = self.get_partition_statistics()
        performance_metrics = self.get_time_series_performance()
        retention_status = self.get_data_retention_status()

        report = {
            'report_timestamp': datetime.now().isoformat(),
            'partition_statistics': partition_stats,
            'performance_metrics': performance_metrics,
            'retention_status': retention_status
        }

        return report

# Usage
if __name__ == "__main__":
    monitor = TimeSeriesMonitor({
        'host': 'localhost',
        'database': 'production',
        'user': 'monitor_user',
        'password': 'monitor_password'
    })

    report = monitor.generate_timeseries_report()
    print(json.dumps(report, indent=2))

TL;DR Runbook

Quick Start

-- 1. Create time series table with partitioning
CREATE TABLE sensor_readings (
    id BIGSERIAL,
    sensor_id INTEGER NOT NULL,
    timestamp TIMESTAMPTZ NOT NULL,
    value DOUBLE PRECISION NOT NULL,
    PRIMARY KEY (id, timestamp)
) PARTITION BY RANGE (timestamp);

-- 2. Create partitions
CREATE TABLE sensor_readings_2024_01 PARTITION OF sensor_readings
    FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');

-- 3. Create indexes
CREATE INDEX idx_sensor_readings_sensor_timestamp 
    ON sensor_readings (sensor_id, timestamp DESC);
CREATE INDEX idx_sensor_readings_timestamp_brin 
    ON sensor_readings USING BRIN (timestamp);

-- 4. Insert data
INSERT INTO sensor_readings (sensor_id, timestamp, value) VALUES
    (1, CURRENT_TIMESTAMP, 25.5);

-- 5. Query time series data
SELECT sensor_id, AVG(value) as avg_value
FROM sensor_readings
WHERE timestamp > CURRENT_DATE - INTERVAL '1 day'
GROUP BY sensor_id;

Essential Patterns

# Complete PostgreSQL time series setup
def setup_postgresql_timeseries():
    # 1. Time series table design
    # 2. Partitioning strategies
    # 3. Indexing strategies
    # 4. Aggregation patterns
    # 5. Compression strategies
    # 6. Retention policies
    # 7. Performance monitoring
    # 8. Data archiving

    print("PostgreSQL time series setup complete!")

This guide provides the complete machinery for PostgreSQL time series data excellence. Each pattern includes implementation examples, optimization strategies, and real-world usage patterns for enterprise PostgreSQL time series systems.