cloudparts

Time series: wide-column with TWCS, a time-series database, or a data lake?

DimensionWide-column storesTime-series databasesData lakes
In one lineOne partition per series and time bucket, rows sorted by time, old windows dropped as whole filesA database built around timestamped points: time chunks, specialised compression, retention and rollupsColumnar files in object storage, partitioned by date and read by any SQL engine
Write pathEach point is a row appended to the commit log and memtable on every replica, later flushed to SSTablesPoints go into the newest time chunk or an in-memory head, which is cut into blocks (two hours in Prometheus)Points are batched by a stream or batch job and written as new files, such as Parquet, under a date folder
How old data expiresA TTL per row; with TWCS, an SSTable whose rows have all expired is deleted whole, unless it might shadow older dataA retention setting drops whole blocks or chunks past an age (15 days by default in Prometheus; add_retention_policy in TimescaleDB)Lifecycle rules delete files by age or move them to archive storage such as S3 Glacier; or a job drops old date partitions
Latest value per seriesFast: the first row of the series’ current partition (LIMIT 1 with ts DESC)Built in: a PromQL instant query returns the newest sample of every matching seriesSlow: scan the newest partitions and keep the highest timestamp per key; there is no index
Time range for one seriesThe query it is designed for: one partition per bucket, read in clustering orderFast: chunks outside the time range are skippedDate partitions outside the range are skipped, but each remaining file is scanned for the series
Aggregates across all seriesWeak: GROUP BY works only on primary key columns, and filtering across partitions is a full scan; usually a separate rollup table or a Spark jobThe main job: aggregation over series and time windows, plus background rollups such as TimescaleDB continuous aggregatesGood: engines read only the columns a query needs, across as much history as you keep
Ad-hoc analyticsPoor: every query must follow the partition key; a new question needs a new table or an external engine such as Spark or TrinoDepends on the language: full SQL with joins in TimescaleDB, series and labels only in PromQLIts strength: schema on read, and any engine (Athena, Trino, Spark, DuckDB) over the same files
CompressionGeneral-purpose, per SSTable: LZ4 on 16 KiB chunks by default, Zstd for a better ratioPer-column encodings for time series (delta-of-delta, XOR, run-length): Prometheus averages 1–2 bytes per sample; TimescaleDB’s columnstore saves more than 90%Columnar files with per-column compression; Athena reads only the columns a query names
Keeping years of historyCostly: every replica keeps the data on its own disks, and the node count grows with itCheaper after compression and rollups; Prometheus’s local storage isn’t replicated, so long-term data goes to a remote storeCheapest: object storage billed per GB-month, with archive tiers; queries pay per data scanned ($5 per TB in Athena)
Typical productsCassandra, ScyllaDB, Amazon Keyspaces; Bigtable expires cells by age through per-column-family garbage collection insteadTimescaleDB, InfluxDB, Prometheus, QuestDB, kdb+S3, Azure Data Lake Storage or Cloud Storage with Parquet, queried by Athena, Trino or Spark

Rule of thumb

They answer different questions, and many systems use two of them. Use a wide-column store with TWCS when an application reads per series, such as a device’s latest reading or its last day, at high write rates and across regions, and you can live without ad-hoc queries. Use a time-series database when most reads are dashboards and aggregates over many series and recent time. Use a data lake for years of history and questions nobody has asked yet: it is the cheapest place to keep data, but the slowest for one series or one latest value. A common split is to stream the same events into Cassandra with a 30-day TTL for the application and into Parquet files for analysis.

Sources