TimescaleDB
Full PostgreSQL with time series superpowers bolted in at the storage layer
Use Cases
Architecture
Why This Exists
Every time series database requires learning something new. InfluxDB has InfluxQL and Flux. Prometheus has PromQL. Druid has its own JSON query format. That is fine if the team has the bandwidth, but a lot of organizations already have years of PostgreSQL experience and tooling. TimescaleDB's pitch is simple: what if time series performance were possible without leaving PostgreSQL?
TimescaleDB is a PostgreSQL extension, not a fork. It runs inside the existing PostgreSQL process, uses the same WAL, same replication, same backup tools, same pgAdmin or DBeaver or whatever is in use today. Applications connect with a standard PostgreSQL driver. Existing ORMs work unchanged. DBAs already know how to operate it.
The tradeoff is real though. Being an extension means inheriting PostgreSQL's single-writer architecture, MVCC overhead, and vacuum requirements. A purpose-built TSDB like InfluxDB will beat it on raw write throughput. But if the workload involves mixing time series queries with relational data (which is surprisingly common), TimescaleDB provides that in one database instead of two.
How Hypertables Work
Calling SELECT create_hypertable('conditions', 'time') causes TimescaleDB to replace the underlying table with a virtual table that routes data to chunks. Each chunk is a regular PostgreSQL table covering a specific time range. The default chunk interval is 7 days, but TimescaleDB has an adaptive algorithm that adjusts this based on the write rate.
Inserts go to the chunk covering the current time window. If the chunk does not exist yet, TimescaleDB creates it automatically. Each chunk has its own indexes, statistics, and storage settings. This is the key insight: instead of one massive table that gets slower as it grows, the database has many smaller tables that each stay fast.
Queries benefit because the chunk exclusion optimizer eliminates chunks that cannot contain matching data. A query with WHERE time > now() - interval '1 hour' only touches the current chunk, not the 500 chunks from last year. This is aggressive constraint exclusion, and it happens automatically.
Under the hood, TimescaleDB uses PostgreSQL's table inheritance and custom scan nodes in the executor. The query planner sees the hypertable, TimescaleDB's custom planner hook kicks in, figures out which chunks to scan based on the WHERE clause, and generates a plan that only touches relevant chunks. For range queries on time, this is dramatically faster than scanning a single large table.
Compression Deep Dive
Compression is where TimescaleDB gets its biggest storage wins. When a compression policy is enabled, TimescaleDB takes a row-oriented chunk and converts it into a columnar format. The physical layout changes from rows of (timestamp, device_id, temperature, humidity) to separate columns of all timestamps, all device_ids, all temperatures, and all humidities.
Each column uses type-appropriate compression. Timestamps get delta-of-delta encoding (since they are usually evenly spaced, the delta-of-delta is often zero, which compresses extremely well). Floats use Gorilla compression, borrowed from Facebook's time series paper. Low-cardinality strings use dictionary encoding. Everything then gets wrapped in LZ4. The result is typically 90-95% compression.
The gotcha: queries on compressed chunks have to decompress data first. For simple aggregations like avg(temperature) WHERE time > X, TimescaleDB can push down the aggregation and decompress only what it needs, which is efficient. But complex queries with multiple JOINs on compressed data will be slower than the same query on uncompressed chunks. The standard pattern is to compress chunks older than the hot query window (maybe 24-48 hours) and keep recent data uncompressed.
Production Architecture
A solid single-node production setup starts with tuning PostgreSQL for time series workloads. Set shared_buffers to 25% of RAM (standard PostgreSQL advice). Set effective_cache_size to 75% of RAM. Increase work_mem to 64-256MB for aggregation-heavy queries. Set max_parallel_workers_per_gather to 4-8 to leverage parallel chunk scanning. Disable jit unless on PostgreSQL 15+ where it has fewer performance regressions.
For TimescaleDB-specific settings, timescaledb.max_background_workers controls how many workers handle compression and continuous aggregate refresh. Set it to at least 4 for a production workload. Configure a compression policy that kicks in after data cools off:
SELECT add_compression_policy('conditions', INTERVAL '2 days');
SELECT add_retention_policy('conditions', INTERVAL '90 days');
This compresses chunks older than 2 days and drops chunks older than 90 days. Combined with continuous aggregates that roll up data into hourly and daily summaries, the system can keep years of aggregated data while only storing days of raw granularity.
For high availability, use PostgreSQL streaming replication with a synchronous standby. This is standard PostgreSQL HA, nothing TimescaleDB-specific. For multi-node deployments, TimescaleDB's distributed hypertables spread chunks across multiple PostgreSQL instances. Data is routed based on time and an optional space partition key. Queries that span multiple nodes use the access node to coordinate. It works, but it adds operational complexity. Most workloads fit on a single well-tuned node for longer than people expect.
Capacity Planning
Write throughput on a single node depends heavily on batch size. Individual INSERT statements cap out at around 20-50K rows/sec. But using COPY or multi-row INSERT with 1000+ rows per statement, throughput can reach 200-400K rows/sec on an 8-core machine with NVMe SSDs. Each "row" here is a full measurement with multiple columns.
Storage: with compression enabled, expect 2-4 bytes per value for typical metrics data. At 100K measurements/sec with 5 fields each, that is roughly 40-80GB/day compressed. On a 2TB NVMe drive with 90-day retention and compression, roughly 100 days of this workload fits comfortably.
Memory sizing: keep shared_buffers at 25% of RAM. For queries, work_mem multiplied by max_connections should not exceed 50% of remaining RAM. A 32GB machine with 100 connections, shared_buffers at 8GB, and work_mem at 128MB uses 8GB + (100 * 128MB) = 20.5GB, leaving headroom for the OS page cache. The OS cache is important because TimescaleDB benefits from cached chunk files for range scans.
Failure Scenarios
Scenario 1: Chunk bloat from missing retention policy. The team sets up TimescaleDB, enables compression, but forgets to add a retention policy. After 18 months, the database has 1500 chunks across the hypertable. Query planning alone takes seconds because PostgreSQL's planner has to evaluate chunk exclusion for each one. Vacuuming the catalog tables takes minutes. Detection: monitor SELECT count(*) FROM timescaledb_information.chunks and alert when it exceeds a threshold (200-300 for most workloads). Recovery: add a retention policy and run SELECT drop_chunks('conditions', older_than => INTERVAL '90 days'). This drops old chunks immediately and frees disk space. Lesson: always define retention at the same time as creating the hypertable.
Scenario 2: Compression job blocks writes during peak hours. Compression rewrites a chunk from row format to columnar format. During this rewrite, the chunk is locked. If the chunk is large (maybe the chunk interval is too big at 30 days) and compression runs during peak write hours, new inserts to that time range block. Detection: monitor pg_stat_activity for long-running compression workers and lock waits. Recovery: cancel the compression job, resize the chunk interval to something smaller (1-7 days), and reschedule compression during off-peak hours. With smaller chunks, each compression job finishes in seconds instead of minutes.
Pros
- • It is PostgreSQL. Full SQL, JOINs, CTEs, window functions, stored procedures, all of it
- • Your existing PostgreSQL tooling, ORMs, drivers, and expertise all carry over
- • Automatic time-based partitioning via hypertables is genuinely painless
- • Continuous aggregates give you materialized rollups that refresh incrementally
- • Native compression achieves 90-95% reduction on typical time series data
Cons
- • Single-node performance ceiling for writes compared to purpose-built TSDBs
- • Multi-node (distributed hypertables) adds operational complexity
- • Compression is columnar but queries on compressed chunks are slower than on uncompressed data
- • Still carries the PostgreSQL overhead for MVCC, vacuuming, and WAL management
- • License changed from Apache 2.0 to a more restrictive Timescale License for some features
When to use
- • You already run PostgreSQL and want to add time series without another database
- • Your queries need JOINs between time series data and relational tables
- • Your team knows SQL well and does not want to learn a new query language
- • You need ACID transactions on time series data
When NOT to use
- • Pure metrics collection at massive scale (Prometheus or VictoriaMetrics is simpler)
- • You need 1M+ inserts/sec sustained on a single node (look at QuestDB or InfluxDB)
- • Your workload is append-only with no relational queries (a purpose-built TSDB will be leaner)
- • You want a fully managed experience without any PostgreSQL administration
Key Points
- •A hypertable looks like a regular PostgreSQL table but is automatically partitioned into chunks based on time. Each chunk is a standard PostgreSQL table under the hood, which means all PostgreSQL features work on it. The default chunk interval is 7 days, but size it so each chunk fits in about 25% of available memory for best query performance.
- •Continuous aggregates are materialized views that refresh incrementally. Unlike a regular PostgreSQL materialized view that recomputes everything on refresh, TimescaleDB tracks which chunks have changed and only processes new data. This takes a 30-day rollup from minutes to seconds.
- •Native compression converts row-oriented chunks into a columnar format using type-specific algorithms. Gorilla for floats, delta-of-delta for timestamps, dictionary for low-cardinality text, LZ4 on top. Typical compression ratios are 10-20x. The catch is that queries on compressed chunks are slower because data has to be decompressed first.
- •Retention policies via drop_chunks() remove old data efficiently by dropping entire PostgreSQL tables (chunks) rather than deleting individual rows. No vacuum bloat, no dead tuples, just clean file removal. Combine this with continuous aggregates to keep rollups while discarding raw data.
- •Space partitioning adds a second dimension beyond time. Partitioning by device_id with 4 space partitions and weekly time chunks produces 4 chunks per week instead of 1. This helps with parallel query execution and write distribution across disks, but adds complexity. Only use it with a clear reason.
- •TimescaleDB inherits PostgreSQL's MVCC, which means UPDATE and DELETE create dead tuples that need vacuuming. For time series that is mostly append-only, this is fine. But if the workload involves frequent updates (like updating the latest reading for each sensor), the vacuum overhead becomes noticeable.
Common Mistakes
- ✗Chunk intervals that are too small or too large. Too-small chunks (1 hour) create thousands of tables and slow down planning. Too-large chunks (1 month) prevent dropping old data granularly and individual chunks get too big for memory. Size chunks so 10-20 chunks fit in memory at a time.
- ✗Not enabling compression. Uncompressed hypertables use 5-10x more disk than compressed ones. Enable compression policies on chunks older than a few hours or days (once writes to them are complete). There is almost no reason to skip this.
- ✗Forgetting to set up continuous aggregates before data volume gets large. Running a GROUP BY across 6 months of raw data at query time is going to be slow. Set up continuous aggregates from the start so rollup tables are always ready.
- ✗Over-partitioning with space dimensions. Adding 16 space partitions on a 4-core machine creates 16 chunks per time interval, most of which sit idle. Match space partitions to the number of disks or the parallelism actually available.
- ✗Treating TimescaleDB like a standard TSDB and ignoring the PostgreSQL side. Tuning shared_buffers, effective_cache_size, work_mem, and autovacuum settings is still essential. A poorly tuned PostgreSQL instance will bottleneck TimescaleDB regardless of how well the hypertables are designed.