Time-Series with PostgreSQL: TimescaleDB, Hypertables, and Aggregates


Time-Series with PostgreSQL: TimescaleDB, Hypertables, and Aggregates

Time-series data powers monitoring systems, IoT applications, financial tick data, and analytics pipelines. PostgreSQL with TimescaleDB offers a robust solution that combines SQL power with time-series optimizations.

The Time-Series Challenge

Time-series workloads differ from traditional OLTP:


* **Append-heavy**: Most data is inserted, rarely updated.

* **Time-ordered**: Queries filter on time ranges.

* **Downsampling**: Old data is aggregated and retained at lower granularity.

* **Retention**: Data older than a threshold is dropped automatically.


Hypertables

TimescaleDB's central abstraction is the hypertable, which automatically partitions data by time:




-- Enable the extension


CREATE EXTENSION IF NOT EXISTS timescaledb;




-- Create a regular table


CREATE TABLE sensor_readings (


time TIMESTAMPTZ NOT NULL,


sensor_id INTEGER NOT NULL,


temperature DOUBLE PRECISION,


humidity DOUBLE PRECISION,


pressure DOUBLE PRECISION


);




-- Convert to hypertable, partitioned by time


SELECT create_hypertable('sensor_readings', 'time',


chunk_time_interval => INTERVAL '1 day');




-- Optional: space partition by sensor_id for parallel I/O


SELECT add_dimension('sensor_readings',


create_hypertable_index('sensor_readings', 'sensor_id', 4));





TimescaleDB automatically creates chunks (internal partitions), each covering one day of data. Queries that filter on `time` prune irrelevant chunks, similar to declarative partitioning.

Inserting Data




-- Inserts work exactly as with regular tables


INSERT INTO sensor_readings (time, sensor_id, temperature, humidity, pressure)


SELECT


generate_series('2026-01-01', '2026-05-12', INTERVAL '1 minute'),


(random() * 100)::INTEGER + 1,


random() * 35 + 5,


random() * 60 + 20,


random() * 50 + 950;





Querying Data




-- Time-range queries prune chunks automatically


SELECT time, temperature


FROM sensor_readings


WHERE sensor_id = 42


AND time BETWEEN '2026-05-10' AND '2026-05-11'


ORDER BY time;




-- Chunk pruning visible in explain plan


EXPLAIN (ANALYZE, BUFFERS)


SELECT avg(temperature) FROM sensor_readings


WHERE time > now() - INTERVAL '1 hour';





Continuous Aggregates

Continuous aggregates are TimescaleDB's answer to materialized views for time-series. They automatically and incrementally maintain pre-computed aggregations:




-- Create a continuous aggregate


CREATE MATERIALIZED VIEW hourly_stats


WITH (timescaledb.continuous) AS


SELECT


time_bucket('1 hour', time) AS bucket,


sensor_id,


COUNT(*) AS readings,


AVG(temperature) AS avg_temp,


MAX(temperature) AS max_temp,


MIN(temperature) AS min_temp,


AVG(humidity) AS avg_humidity


FROM sensor_readings


GROUP BY bucket, sensor_id;




-- Set refresh policy: refresh every hour, keep 7 days of data


SELECT add_continuous_aggregate_policy('hourly_stats',


start_offset => INTERVAL '3 days',


end_offset => INTERVAL '1 hour',


schedule_interval => INTERVAL '1 hour'


);





Continuous aggregates update incrementally: only the time buckets that have new data are recomputed. This makes them viable for real-time dashboards.




-- Query the continuous aggregate


SELECT bucket, avg_temp, max_temp


FROM hourly_stats


WHERE sensor_id = 42


AND bucket >= now() - INTERVAL '7 days'


ORDER BY bucket;





Data Retention

Time-series data grows indefinitely without a retention policy. TimescaleDB provides native retention policies:




-- Drop chunks older than 90 days


SELECT add_retention_policy('sensor_readings',


drop_after => INTERVAL '90 days');





The drop operation is nearly instant because it removes entire chunks rather than individual rows.




-- Manual chunk management


SELECT show_chunks('sensor_readings');


SELECT drop_chunks('sensor_readings', older_than => INTERVAL '90 days');


SELECT reorder_chunk('_hyper_1_1_chunk', 'sensor_readings_time_idx');





Compression

TimescaleDB's native compression is designed for time-series data:




-- Enable compression


ALTER TABLE sensor_readings SET (


timescaledb.compress,


timescaledb.compress_segmentby = 'sensor_id',


timescaledb.compress_orderby = 'time DESC'


);




-- Set compression policy: compress chunks older than 7 days


SELECT add_compression_policy('sensor_readings',


compress_after => INTERVAL '7 days');





TimescaleDB reports 90-95% compression ratios for typical sensor data because consecutive readings from the same sensor are highly correlated.

Performance Optimization




-- Index for typical queries


CREATE INDEX idx_sensor_time ON sensor_readings (sensor_id, time DESC);




-- Use timescaledb toolkit extension for advanced analytics


CREATE EXTENSION timescaledb_toolkit;




-- Approximate percentile


SELECT


time_bucket('1 hour', time) AS bucket,


approx_percentile(0.95, percentile_agg(temperature)) AS p95_temp


FROM sensor_readings


WHERE sensor_id = 42


GROUP BY bucket;





PostgreSQL vs Dedicated Time-Series Databases

| Feature | TimescaleDB | InfluxDB | ClickHouse | |---------|------------|----------|------------| | Query language | Full SQL | Flux | SQL-like | | Joins | Full support | Limited | Moderate | | ACID transactions | Yes | No | Limited | | Compression ratio | 90-95% | 90-95% | 90-99% | | Ingestion rate | 1M+ rows/sec | 1M+ rows/sec | 10M+ rows/sec | | Ecosystem | PostgreSQL ecosystem | Grafana mainly | Analytics tools |

TimescaleDB is the right choice when you need:


* Full SQL and JOIN capabilities across time-series and relational data.

* ACID guarantees for insert patterns.

* Existing PostgreSQL tooling (PgBouncer, pgBadger, ORMs).

* A single database for both transactional and time-series workloads.


The combination of hypertables, continuous aggregates, and compression makes PostgreSQL with TimescaleDB a compelling default choice for time-series data that coexists with relational data.