Time-Series Examples
Examples 81-85 cover TimescaleDB, a PostgreSQL extension purpose-built for append-heavy,
timestamp-keyed data: converting a plain table into a time-partitioned hypertable via the current
generalized API, rolling raw points into hourly averages with time_bucket(), dropping old data
automatically with a retention policy, pre-computing rollups with a continuous aggregate, and
contrasting the TSDB's own built-in retention/downsampling against Cassandra's hand-rolled TTL +
clustering approach for the same feed. All 5 examples run against a real local TimescaleDB 2.28.3
Docker container (timescale/timescaledb:2.28.3-pg16) via psycopg, with Example 85 also connecting
to the same local Cassandra 5.0 container prior examples used. create_hypertable and time_bucket
are Apache-2.0; retention policies and continuous aggregates are Tiger Data License (TSL) / Community
tier -- source-available, not OSI-approved -- each example says so explicitly where it applies.
Example 81: TimescaleDB Hypertable, Created
ex-81 · exercises co-29
Context: create_hypertable('metrics', by_range('ts')) is the current (v2.13+) generalized API that
converts a plain PostgreSQL table into a time-partitioned hypertable -- inserts and time-range reads
use ordinary SQL, with chunk routing entirely transparent to the client.
learning/code/ex-81-timescale-hypertable-create/example.py
"""Example 81: TimescaleDB Hypertable Create.""" # => co-29: this file's own purpose, doubling as its module __doc__
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import psycopg # => co-29: psycopg, the official typed Python driver for PostgreSQL (TimescaleDB is a PG extension)
def create_hypertable(conn: psycopg.Connection) -> None: # => co-29: converts a regular table into a time-partitioned hypertable
"""Create a regular metrics table, then convert it into a TimescaleDB hypertable via the current API.""" # => documents the contract
conn.execute("DROP TABLE IF EXISTS metrics") # => resets state -- this example is fully self-contained
conn.execute( # => a PLAIN PostgreSQL table -- nothing TimescaleDB-specific about this line yet
"CREATE TABLE metrics (ts TIMESTAMPTZ NOT NULL, sensor_id TEXT NOT NULL, value DOUBLE PRECISION NOT NULL)" # => no partitioning declared here -- create_hypertable() below adds it
) # => closes the execute() call -- a plain, non-partitioned table exists at this point
conn.execute("SELECT create_hypertable('metrics', by_range('ts'))") # => co-29: the CURRENT (v2.13+) generalized hypertable API
# => co-29: the older positional form create_hypertable('metrics', 'ts') still works too, as the
# => backward-compatible interface -- by_range('ts') is the form new code should reach for
def insert_readings(conn: psycopg.Connection) -> None: # => co-29: writes arrive roughly in time order, timestamp-keyed
"""Insert timestamped sensor readings spanning several hours -- TimescaleDB auto-routes each into its time chunk.""" # => documents contract
readings = [ # => co-29: deliberately spans several hours so inserts land across MULTIPLE time-partitioned chunks
("2026-07-27 00:00:00+00", "sensor-1", 21.5), # => the EARLIEST reading -- hour 0
("2026-07-27 01:00:00+00", "sensor-1", 21.8), # => hour 1's reading
("2026-07-27 02:00:00+00", "sensor-1", 22.1), # => hour 2's reading
("2026-07-27 03:00:00+00", "sensor-1", 22.4), # => the LATEST reading -- hour 3
] # => closes the readings list -- 4 tuples spanning 3 hours, one sensor
for ts, sensor_id, value in readings: # => co-29: each insert is a plain SQL INSERT -- TimescaleDB routes it transparently
conn.execute( # => co-29: the hypertable's own chunk-routing is invisible to this INSERT statement
"INSERT INTO metrics (ts, sensor_id, value) VALUES (%s, %s, %s)", (ts, sensor_id, value) # => binds this one loop iteration's own reading
) # => closes this one execute() call -- runs once per reading
conn.commit() # => co-29: makes the 4 inserts durable and visible to the range query below
def main() -> None: # => entry point -- runs only when this file executes directly, not on import
conn = psycopg.connect("host=localhost port=5433 dbname=nosqldb user=postgres password=nosqldb") # => connects to the local TimescaleDB Docker container
conn.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") # => co-29: enables the extension -- TimescaleDB ships AS a PostgreSQL extension
conn.commit() # => makes the extension creation durable before the hypertable conversion below
create_hypertable(conn) # => co-29: converts metrics from a plain table into a time-partitioned hypertable
insert_readings(conn) # => co-29: inserts 4 readings spanning 3 hours
chunk_count_row = conn.execute( # => co-29: counts the ACTUAL time-partitioned chunks TimescaleDB created underneath the hypertable
"SELECT COUNT(*) FROM timescaledb_information.chunks WHERE hypertable_name = 'metrics'" # => queries TimescaleDB's own catalog view, not the metrics table itself
).fetchone() # => a COUNT(*) query always returns exactly one row -- never genuinely None
assert chunk_count_row is not None # => confirms the COUNT(*) row genuinely came back
chunk_count = chunk_count_row[0] # => the single aggregate value this COUNT(*) query returns
assert chunk_count >= 1 # => co-29: at least ONE chunk exists -- the hypertable conversion genuinely took effect
rows = conn.execute( # => co-29: a time-range SELECT, the dominant TSDB read pattern
"SELECT ts, value FROM metrics WHERE ts >= '2026-07-27 00:00:00+00' AND ts < '2026-07-27 04:00:00+00' ORDER BY ts" # => a bounded time-range filter, ORDER BY ts
).fetchall() # => materializes every row this range query matched
values_in_order = [row[1] for row in rows] # => extracts values in the RETURNED (time) order
assert values_in_order == [21.5, 21.8, 22.1, 22.4] # => co-29: returned strictly in TIME order, matching insertion order here
print(f"Hypertable 'metrics' created with {chunk_count} chunk(s)") # => Output line -- exact chunk count depends on the default chunk_time_interval
print(f"Time-range query returned {len(rows)} points, ordered by time: {values_in_order}") # => Output: Time-range query returned 4 points, ordered by time: [21.5, 21.8, 22.1, 22.4]
print("Plain SQL INSERT/SELECT -- TimescaleDB's chunk routing and time-partitioning are entirely transparent to the client") # => Output line
conn.close() # => always release what you open
if __name__ == "__main__": # => guards against running main() on `import example`
main() # => runs everything above when executed as a scriptRun: python3 example.py (representative output, run against psycopg[binary]==3.3.4 and a local
timescale/timescaledb:2.28.3-pg16 Docker container, TimescaleDB 2.28.3)
Output:
Hypertable 'metrics' created with 1 chunk(s)
Time-range query returned 4 points, ordered by time: [21.5, 21.8, 22.1, 22.4]
Plain SQL INSERT/SELECT -- TimescaleDB's chunk routing and time-partitioning are entirely transparent to the clientKey takeaway: create_hypertable(table, by_range('ts')) converts a table once, up front -- every
INSERT and SELECT afterward is ordinary SQL, with time-based chunk routing handled entirely by the
extension, invisibly to the client.
Why it matters: this is co-29's own definition made concrete -- a purpose-built time-series store
bakes the time axis into the engine itself, rather than leaving partitioning and chunk management to
hand-written schema and application code, the way Example 79's Cassandra wide-column feed does. A team
building a metrics or IoT pipeline that reaches for a general-purpose store first often ends up
re-implementing chunk management, retention, and rollups by hand -- work TimescaleDB's
create_hypertable call replaces with a single, one-time statement.
Example 82: time_bucket() Downsampling
ex-82 · exercises co-30
Context: time_bucket('1 hour', ts) rolls 120 raw per-minute readings into 2 hourly averages -- this
example verifies the SQL-computed rollup exactly matches a hand-computed average of the same raw
points, for every hour.
learning/code/ex-82-time-bucket-downsample-query/example.py
"""Example 82: time_bucket Downsample Query.""" # => co-30: this file's own purpose, doubling as its module __doc__
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import statistics # => co-30: computes the hand-rolled hourly average this example verifies the SQL rollup against
import psycopg # => co-30: psycopg, the official typed Python driver for PostgreSQL (TimescaleDB is a PG extension)
def seed_raw_readings(conn: psycopg.Connection) -> None: # => co-30: high-resolution, per-minute readings across 2 hours
"""Seed 120 per-minute readings across 2 hours -- raw resolution, before any downsampling.""" # => documents the contract
conn.execute("DROP TABLE IF EXISTS sensor_readings") # => resets state -- this example is fully self-contained
conn.execute("CREATE TABLE sensor_readings (ts TIMESTAMPTZ NOT NULL, value DOUBLE PRECISION NOT NULL)") # => a plain table
conn.execute("SELECT create_hypertable('sensor_readings', by_range('ts'))") # => co-29: converts it to a hypertable, same API Example 81 used
for minute in range(120): # => co-30: 120 raw, per-minute points -- 2 full hours at 1-minute resolution
hour = minute // 60 # => which hour bucket this raw point WILL roll up into
value = 20.0 + hour * 2.0 + (minute % 60) * 0.01 # => co-30: a deterministic, gently rising value per minute
conn.execute( # => co-30: each raw point is its OWN row -- no pre-aggregation happens here
"INSERT INTO sensor_readings (ts, value) VALUES (%s, %s)", # => positional placeholders bind this one minute's own timestamp and value
(f"2026-07-27 {hour:02d}:{minute % 60:02d}:00+00", value), # => this loop iteration's own minute-resolution reading
) # => closes this one execute() call -- runs once per raw minute
conn.commit() # => makes all 120 raw inserts durable and visible to the queries below
def hand_computed_hourly_average(conn: psycopg.Connection, hour: int) -> float: # => co-30: the INDEPENDENT, hand-rolled rollup this example checks against
"""Compute an hour's average by pulling ALL 60 raw points and averaging them in Python.""" # => documents the contract
rows = conn.execute( # => co-30: pulls every RAW point for this hour -- the expensive, non-bucketed path
"SELECT value FROM sensor_readings WHERE ts >= %s AND ts < %s", # => a bounded, half-open [hour, hour+1) time-range filter
(f"2026-07-27 {hour:02d}:00:00+00", f"2026-07-27 {hour + 1:02d}:00:00+00"), # => this call's own hour boundary, as a pair of timestamps
).fetchall() # => materializes all 60 raw points this specific hour matched
return statistics.mean(row[0] for row in rows) # => co-30: the hand-computed average, independent of time_bucket()
def main() -> None: # => entry point -- runs only when this file executes directly, not on import
conn = psycopg.connect("host=localhost port=5433 dbname=nosqldb user=postgres password=nosqldb") # => connects to the local TimescaleDB Docker container
conn.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") # => enables the extension, idempotent if already present
conn.commit() # => makes the extension creation durable before the hypertable conversion below
seed_raw_readings(conn) # => seeds 120 per-minute raw readings across 2 hours
bucketed_rows = conn.execute( # => co-30: time_bucket('1 hour', ts) rolls raw per-minute points into hourly averages
"SELECT time_bucket('1 hour', ts) AS bucket, avg(value) AS avg_value " # => the aggregate-per-bucket projection
"FROM sensor_readings GROUP BY bucket ORDER BY bucket" # => concatenated onto the line above -- ONE SQL statement string
).fetchall() # => materializes one row per hourly bucket -- 120 raw rows collapse to 2
assert len(bucketed_rows) == 2 # => co-30: 2 hours of raw data -- exactly 2 hourly buckets
for hour, (_bucket, bucketed_avg) in enumerate(bucketed_rows): # => co-30: verifies EACH bucket against its own hand-computed rollup
hand_avg = hand_computed_hourly_average(conn, hour) # => co-30: the independent, hand-rolled average for this same hour
assert abs(bucketed_avg - hand_avg) < 1e-9 # => co-30: the SQL rollup matches the hand-computed rollup, to floating-point precision
print(f"Hour {hour}: time_bucket avg={bucketed_avg:.4f}, hand-computed avg={hand_avg:.4f}") # => Output line, one per hour
print("The bucketed aggregate exactly matches a hand-computed rollup of the same raw points, for every hour") # => Output line
conn.close() # => always release what you open
if __name__ == "__main__": # => guards against running main() on `import example`
main() # => runs everything above when executed as a scriptRun: python3 example.py (representative output, run against psycopg[binary]==3.3.4 and a local
timescale/timescaledb:2.28.3-pg16 Docker container, TimescaleDB 2.28.3)
Output:
Hour 0: time_bucket avg=20.2950, hand-computed avg=20.2950
Hour 1: time_bucket avg=22.2950, hand-computed avg=22.2950
The bucketed aggregate exactly matches a hand-computed rollup of the same raw points, for every hourKey takeaway: time_bucket() is a straightforward, verifiable rollup -- the exact same average a
hand-written Python loop over the raw points would compute, just expressed as one SQL GROUP BY
instead of a client-side pull-everything-then-average.
Why it matters: this is co-30's downsampling half in its simplest form -- Example 84 continues by
pre-computing and materializing this same kind of rollup as a continuous aggregate, so a range query
never has to re-run it from raw points at all. A dashboard querying months of raw per-minute sensor
data directly would pull millions of rows for a single chart; time_bucket() lets the same chart run
against a query that returns only as many rows as the chart actually has pixels for.
Example 83: Retention Policy Drops Old Chunks
ex-83 · exercises co-30, co-28
Context: add_retention_policy('metrics_retention', drop_after => INTERVAL '30 days') schedules a
recurring background job that drops chunks older than the window -- this example adds the policy, then
manually triggers its job once (rather than waiting on its own schedule) to verify old data is dropped
while recent data survives.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart LR
P["add_retention_policy<br/>drop_after 30 days"]:::orange
P --> J["run_job()"]:::blue
J -->|"40 days old"| D["chunk DROPPED"]:::teal
J -->|"1 hour old"| K["chunk RETAINED"]:::teal
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-83-retention-policy-drop-old/example.py
"""Example 83: Retention Policy, Drop Old Chunks.""" # => co-30,co-28: this file's own purpose, doubling as its module __doc__
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import psycopg # => co-30: psycopg, the official typed Python driver for PostgreSQL (TimescaleDB is a PG extension)
def seed_old_and_recent_data(conn: psycopg.Connection) -> None: # => co-30: data straddling the 30-day retention window
"""Create a hypertable with 1-day chunks, then insert one point older than 30 days and one recent point.""" # => documents contract
conn.execute("DROP TABLE IF EXISTS metrics_retention") # => resets state -- this example is fully self-contained
conn.execute("CREATE TABLE metrics_retention (ts TIMESTAMPTZ NOT NULL, value DOUBLE PRECISION NOT NULL)") # => a plain table
conn.execute( # => co-30: a SMALL chunk_time_interval so the old and recent points land in DIFFERENT, separately droppable chunks
"SELECT create_hypertable('metrics_retention', by_range('ts', INTERVAL '1 day'))" # => 1-day chunks -- fine-grained enough to separate the two seeded points
) # => closes the execute() call -- metrics_retention is now a hypertable with 1-day chunks
conn.execute( # => co-30: 40 days old -- OUTSIDE the 30-day retention window, must be dropped
"INSERT INTO metrics_retention (ts, value) VALUES (now() - INTERVAL '40 days', 1.0)" # => co-30: lands in a chunk the retention policy WILL drop
) # => closes this one execute() call -- the old point now exists
conn.execute( # => co-30: recent -- INSIDE the 30-day retention window, must be RETAINED
"INSERT INTO metrics_retention (ts, value) VALUES (now() - INTERVAL '1 hour', 2.0)" # => co-30: lands in a chunk the retention policy must NOT drop
) # => closes this one execute() call -- the recent point now exists
conn.commit() # => makes both inserts durable before the retention policy runs
def add_and_run_retention_policy(conn: psycopg.Connection) -> None: # => co-30: schedules AND immediately triggers the drop, rather than waiting on its own schedule
"""Add a 30-day retention policy, then manually run its background job once instead of waiting for its schedule.""" # => documents contract
job_id_row = conn.execute( # => co-30: add_retention_policy schedules a RECURRING background job -- it does not drop anything itself, synchronously
"SELECT add_retention_policy('metrics_retention', drop_after => INTERVAL '30 days')" # => co-30: schedules the drop -- anything older than 30 days becomes eligible
).fetchone() # => this SELECT always returns exactly one row -- the newly-scheduled job's own id
assert job_id_row is not None # => confirms the scheduled job's id row genuinely came back
job_id = job_id_row[0] # => the single job id value this SELECT returns
conn.commit() # => makes the scheduled job durable before running it manually below
conn.execute("CALL run_job(%s)", (job_id,)) # => co-30: manually triggers the job NOW, instead of waiting for its own background schedule -- parameterized, not an f-string, so psycopg's typed overload resolves cleanly
conn.commit() # => makes the job's chunk-drop effect durable
def main() -> None: # => entry point -- runs only when this file executes directly, not on import
conn = psycopg.connect("host=localhost port=5433 dbname=nosqldb user=postgres password=nosqldb", autocommit=False) # => connects to the local TimescaleDB Docker container
conn.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") # => enables the extension, idempotent if already present
conn.commit() # => makes the extension creation durable before the hypertable conversion below
seed_old_and_recent_data(conn) # => co-30: seeds one 40-day-old point and one 1-hour-old point
row_count_before_row = conn.execute("SELECT COUNT(*) FROM metrics_retention").fetchone() # => a COUNT(*) query always returns exactly one row
assert row_count_before_row is not None # => confirms the COUNT(*) row genuinely came back
row_count_before = row_count_before_row[0] # => co-30: BOTH points present before the retention job runs
assert row_count_before == 2 # => co-30: confirms both the old and recent point genuinely landed
add_and_run_retention_policy(conn) # => co-30: TSL tier (per this course's own license-awareness discipline, co-28) -- schedules AND immediately runs the drop
remaining_rows = conn.execute("SELECT value FROM metrics_retention ORDER BY ts").fetchall() # => co-30: what survived the retention job
remaining_values = [row[0] for row in remaining_rows] # => extracts the surviving values
print(f"Rows before retention policy ran: {row_count_before}") # => Output: Rows before retention policy ran: 2
print(f"Rows after retention policy ran: {len(remaining_values)}, values={remaining_values}") # => Output: Rows after retention policy ran: 1, values=[2.0]
assert remaining_values == [2.0] # => co-30: ONLY the recent (1-hour-old) point survived -- the 40-day-old point's chunk was dropped
print("The 40-day-old point (outside the 30-day retention window) was dropped; the 1-hour-old point (inside it) was retained") # => Output line
# => co-28: add_retention_policy is a TSL / Community feature (source-available, NOT OSI-approved) --
# => distinct from the Apache-2.0-licensed create_hypertable/time_bucket API Examples 81-82 used
conn.close() # => always release what you open
if __name__ == "__main__": # => guards against running main() on `import example`
main() # => runs everything above when executed as a scriptRun: python3 example.py (representative output, run against psycopg[binary]==3.3.4 and a local
timescale/timescaledb:2.28.3-pg16 Docker container, TimescaleDB 2.28.3)
Output:
Rows before retention policy ran: 2
Rows after retention policy ran: 1, values=[2.0]
The 40-day-old point (outside the 30-day retention window) was dropped; the 1-hour-old point (inside it) was retainedKey takeaway: add_retention_policy only schedules a recurring job -- it does not drop anything
itself, synchronously; manually running that job (via CALL run_job(job_id)) is what genuinely dropped
the 40-day-old chunk here, and the 1-hour-old point survived untouched.
Why it matters: this is co-28's license-awareness discipline applied directly to TimescaleDB --
add_retention_policy is a TSL / Community (source-available, not OSI-approved) feature, a genuinely
different license tier from the Apache-2.0 create_hypertable/time_bucket API Examples 81-82 used,
and this distinction matters before adopting it. A team whose compliance policy requires OSI-approved
licenses throughout its stack needs to know this feature specifically sits outside that boundary, even
though it ships in the same TimescaleDB package as the Apache-2.0 core.
Example 84: Continuous Aggregate Rollup
ex-84 · exercises co-31, co-28
Context: a continuous aggregate materializes time_bucket() rollups incrementally as new data
lands -- this example measures, via EXPLAIN (ANALYZE, FORMAT JSON), the actual row count each query
scanned, verifying the continuous-aggregate read touches only its pre-computed buckets rather than
re-scanning every raw point.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73 -- color-blind friendly, WCAG AA
flowchart LR
R["events_raw<br/>500 raw rows"]:::orange -->|"re-scan every row"| Q1["range query<br/>500 rows scanned"]:::blue
R -->|"REFRESH continuous aggregate"| C["events_hourly<br/>5 pre-computed buckets"]:::teal
C -->|"read pre-aggregated buckets"| Q2["range query<br/>5 rows scanned"]:::blue
classDef blue fill:#0173B2,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef orange fill:#DE8F05,stroke:#000000,color:#FFFFFF,stroke-width:2px
classDef teal fill:#029E73,stroke:#000000,color:#FFFFFF,stroke-width:2px
learning/code/ex-84-continuous-aggregate-rollup/example.py
"""Example 84: Continuous Aggregate Rollup.""" # => co-31,co-28: this file's own purpose, doubling as its module __doc__
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
from typing import LiteralString # => co-31: both call sites below pass a compile-time string constant -- LiteralString states that honestly
import psycopg # => co-31: psycopg, the official typed Python driver for PostgreSQL (TimescaleDB is a PG extension)
from psycopg import sql # => co-31: composes the literal query into a genuinely typed Composed object, not a plain f-string
def seed_raw_events(conn: psycopg.Connection, hours: int, points_per_hour: int) -> None: # => co-31: many raw points, the expensive source a cagg pre-summarizes
"""Create a hypertable and seed points_per_hour raw points for each of hours hours.""" # => documents the contract, no runtime output
conn.execute("DROP MATERIALIZED VIEW IF EXISTS events_hourly") # => resets state FIRST -- the cagg view depends on events_raw, so it must drop before the table below
conn.execute("DROP TABLE IF EXISTS events_raw") # => resets state -- this example is fully self-contained
conn.execute("CREATE TABLE events_raw (ts TIMESTAMPTZ NOT NULL, value DOUBLE PRECISION NOT NULL)") # => a plain table
conn.execute("SELECT create_hypertable('events_raw', by_range('ts'))") # => co-29: converts it to a hypertable, same API Example 81 used
for hour in range(hours): # => co-31: HOURS worth of raw data -- this is what re-scanning the raw hypertable would cost
for point in range(points_per_hour): # => co-31: POINTS_PER_HOUR raw rows land in EACH hourly bucket
offset_seconds = point * (3600 // points_per_hour) # => spreads points_per_hour points evenly across the hour
minute, second = divmod(offset_seconds, 60) # => converts the raw offset into a valid minute:second pair, avoiding an out-of-range seconds field
conn.execute( # => co-31: each raw point is its OWN row -- no pre-aggregation happens at insert time
"INSERT INTO events_raw (ts, value) VALUES (%s, %s)", # => positional placeholders bind this one raw point's own timestamp and value
(f"2026-07-27 {hour:02d}:{minute:02d}:{second:02d}+00", 10.0 + hour + point * 0.001), # => this loop iteration's own raw point
) # => closes this one execute() call -- runs once per raw point
def create_and_refresh_continuous_aggregate(conn: psycopg.Connection) -> None: # => co-31: TSL tier (per co-28) -- pre-computed, incrementally maintained hourly rollups
"""Define an hourly continuous aggregate over events_raw, then manually refresh it once.""" # => documents the contract
conn.execute( # => co-31: WITH (timescaledb.continuous) -- the exact syntax that marks this as a continuous aggregate, not a plain view
# => CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) cannot run inside a transaction
# => block, which is why this connection runs in autocommit mode (co-31)
"CREATE MATERIALIZED VIEW events_hourly WITH (timescaledb.continuous) AS " # => the continuous-aggregate marker clause
"SELECT time_bucket('1 hour', ts) AS bucket, avg(value) AS avg_value " # => the aggregate-per-bucket projection this view maintains
"FROM events_raw GROUP BY bucket" # => concatenated onto the lines above -- ONE SQL statement string
) # => closes the execute() call -- events_hourly now exists as a continuous aggregate
conn.execute("CALL refresh_continuous_aggregate('events_hourly', NULL, NULL)") # => co-31: refreshes the WHOLE aggregate now, instead of waiting for a scheduled policy
def actual_rows_scanned(conn: psycopg.Connection, query: LiteralString, params: tuple[str, ...]) -> int: # => co-31: the REAL row count EXPLAIN ANALYZE observed, not an estimate
"""Run EXPLAIN (ANALYZE, FORMAT JSON) on query and return the top plan node's Actual Rows.""" # => documents the contract
composed_query = sql.SQL("EXPLAIN (ANALYZE, FORMAT JSON) {}").format(sql.SQL(query)) # => co-31: a Composed object, not a plain f-string -- resolves psycopg's own typed overload
plan_row = conn.execute(composed_query, params).fetchone() # => co-31: FORMAT JSON makes the plan machine-parseable
assert plan_row is not None # => confirms the EXPLAIN row genuinely came back
plan_json = plan_row[0] # => the single JSON plan value this EXPLAIN query returns
return plan_json[0]["Plan"]["Actual Rows"] # => co-31: the ACTUAL (not estimated) row count the top-level plan node returned
def main() -> None: # => entry point -- runs only when this file executes directly, not on import
conn = psycopg.connect("host=localhost port=5433 dbname=nosqldb user=postgres password=nosqldb", autocommit=True) # => co-31: autocommit=True -- CREATE MATERIALIZED VIEW ... WITH (timescaledb.continuous) cannot run inside a transaction block
conn.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") # => enables the extension, idempotent if already present
hours, points_per_hour = 5, 100 # => co-31: 5 hours x 100 points/hour = 500 raw rows, rolling up into 5 hourly buckets
seed_raw_events(conn, hours, points_per_hour) # => seeds 500 raw points across 5 hours
create_and_refresh_continuous_aggregate(conn) # => co-31: defines and refreshes the hourly continuous aggregate
raw_rows_scanned = actual_rows_scanned( # => co-31: re-scanning the RAW hypertable for the same 5-hour range
conn, "SELECT ts, value FROM events_raw WHERE ts >= %s AND ts < %s", ("2026-07-27 00:00:00+00", "2026-07-27 05:00:00+00") # => the SAME 5-hour range read raw
) # => closes this actual_rows_scanned() call
cagg_rows_scanned = actual_rows_scanned( # => co-31: reading the SAME 5-hour range from the pre-aggregated continuous aggregate instead
conn, "SELECT bucket, avg_value FROM events_hourly WHERE bucket >= %s AND bucket < %s", ("2026-07-27 00:00:00+00", "2026-07-27 05:00:00+00") # => the SAME 5-hour range read pre-aggregated
) # => closes this actual_rows_scanned() call
print(f"Raw hypertable range read: {raw_rows_scanned} rows scanned") # => Output: Raw hypertable range read: 500 rows scanned
print(f"Continuous aggregate range read: {cagg_rows_scanned} rows scanned") # => Output: Continuous aggregate range read: 5 rows scanned
assert raw_rows_scanned == hours * points_per_hour # => co-31: confirms the raw path genuinely re-scans EVERY one of the 500 raw points
assert cagg_rows_scanned == hours # => co-31: confirms the cagg path reads only the 5 pre-computed hourly buckets
assert cagg_rows_scanned < raw_rows_scanned # => co-31: the pre-aggregated view is scanned, NOT the raw hypertable -- exactly what this example verifies
print(f"The continuous aggregate scanned {raw_rows_scanned // cagg_rows_scanned}x fewer rows for the identical 5-hour range query") # => Output: The continuous aggregate scanned 100x fewer rows for the identical 5-hour range query
# => co-28: continuous aggregates are a TSL / Community feature (source-available, NOT OSI-approved),
# => the same license tier Example 83's retention policy carries -- distinct from the Apache-2.0
# => create_hypertable/time_bucket API Examples 81-82 used
conn.close() # => always release what you open
if __name__ == "__main__": # => guards against running main() on `import example`
main() # => runs everything above when executed as a scriptRun: python3 example.py (representative output, run against psycopg[binary]==3.3.4 and a local
timescale/timescaledb:2.28.3-pg16 Docker container, TimescaleDB 2.28.3)
Output:
Raw hypertable range read: 500 rows scanned
Continuous aggregate range read: 5 rows scanned
The continuous aggregate scanned 100x fewer rows for the identical 5-hour range queryKey takeaway: EXPLAIN (ANALYZE, FORMAT JSON)'s own Actual Rows field proves the claim directly,
not by inference -- the raw-hypertable query genuinely scanned all 500 points, while the identical
time-range query against the continuous aggregate scanned only the 5 pre-computed hourly buckets.
Why it matters: this is co-31's own payoff made measurable -- once a continuous aggregate is refreshed, a range query over it never re-pays the cost of scanning raw points again, which is exactly why co-30's downsampling and co-31's continuous aggregates exist as first-class TimescaleDB features rather than something every application must hand-roll itself.
Example 85: Time-Series vs. Wide-Column Feed
ex-85 · exercises co-29, co-22, co-30
Context: the same metrics feed for one sensor, modeled two ways -- a TimescaleDB hypertable and a Cassandra wide-column partition (contrast with Examples 71 and 79) -- both serve the identical time-range read correctly, but TimescaleDB bakes retention and downsampling into the engine while Cassandra leaves both entirely to hand-written schema and application code.
learning/code/ex-85-time-series-vs-wide-column-feed/example.py
"""Example 85: Time-Series vs. Wide-Column Feed.""" # => co-29,co-22,co-30: this file's own purpose, doubling as its module __doc__
from __future__ import annotations # => hygiene: postpones annotation evaluation, interpreter-version-agnostic
import psycopg # => co-29: psycopg, the official typed Python driver for PostgreSQL (TimescaleDB is a PG extension)
from cassandra.cluster import Cluster, Session # => co-22: cassandra-driver, the Apache Software Foundation-maintained Python driver
# The SAME metrics feed, modeled two ways: a TimescaleDB hypertable, and a Cassandra wide-column
# partition (contrast with Example 71's embedded-array-vs-partition and Example 79's own feed shape).
def timescale_feed_query(conn: psycopg.Connection) -> list[float]: # => co-29: TimescaleDB's own answer to "readings for sensor-1 in this hour"
"""Seed 4 readings for sensor-1 in a hypertable, then read them back time-ordered.""" # => documents the contract
conn.execute("DROP TABLE IF EXISTS feed_timescale") # => resets state -- this example is fully self-contained
conn.execute("CREATE TABLE feed_timescale (sensor_id TEXT NOT NULL, ts TIMESTAMPTZ NOT NULL, value DOUBLE PRECISION NOT NULL)") # => a plain table
conn.execute("SELECT create_hypertable('feed_timescale', by_range('ts'))") # => co-29: converts it to a hypertable, same API Example 81 used
readings = [("sensor-1", "2026-07-27 09:00:00+00", 20.0), ("sensor-1", "2026-07-27 09:15:00+00", 20.5), # => the first 2 of 4 readings, TimescaleDB timestamptz syntax
("sensor-1", "2026-07-27 09:30:00+00", 21.0), ("sensor-1", "2026-07-27 09:45:00+00", 21.5)] # => co-29: 4 readings, one sensor, 45-minute span
for sensor_id, ts, value in readings: # => co-29: each insert is a plain SQL INSERT -- chunk routing is transparent
conn.execute("INSERT INTO feed_timescale (sensor_id, ts, value) VALUES (%s, %s, %s)", (sensor_id, ts, value)) # => binds this loop iteration's own reading
rows = conn.execute( # => co-29: the dominant TSDB read pattern -- a time-range scan for one sensor
"SELECT value FROM feed_timescale WHERE sensor_id = %s AND ts >= %s AND ts < %s ORDER BY ts", # => a bounded, per-sensor time-range filter
("sensor-1", "2026-07-27 09:00:00+00", "2026-07-27 10:00:00+00"), # => the SAME sensor and time window cassandra_feed_query will use
).fetchall() # => materializes the matched rows in time order
return [row[0] for row in rows] # => hand back the values in time order
def cassandra_feed_query(session: Session) -> list[float]: # => co-22: Cassandra's own answer to the IDENTICAL shaped read
"""Seed the SAME 4 readings for sensor-1 in a Cassandra wide-column partition, then read them back.""" # => documents the contract
session.execute( # => a dedicated keyspace, replication_factor 1 on this single-node local cluster
"CREATE KEYSPACE IF NOT EXISTS nosqldb WITH replication = " # => the keyspace-level replication strategy clause
"{'class': 'SimpleStrategy', 'replication_factor': 1}" # => concatenated onto the line above -- ONE CQL statement string
) # => closes the execute() call -- the keyspace now exists, idempotently
session.set_keyspace("nosqldb") # => selects the keyspace for the statements below
session.execute("DROP TABLE IF EXISTS feed_wide") # => resets state -- this example is fully self-contained
session.execute( # => co-22: sensor_id partitions, ts clusters -- the SAME partition+clustering shape Example 79 used
"CREATE TABLE feed_wide (sensor_id text, ts timestamp, value double, PRIMARY KEY ((sensor_id), ts))" # => partition key sensor_id, clustering key ts
) # => closes the execute() call -- the table now exists with this exact partition + clustering layout
readings = [("sensor-1", "2026-07-27 09:00:00", 20.0), ("sensor-1", "2026-07-27 09:15:00", 20.5), # => the first 2 of 4 identical readings, Cassandra timestamp syntax
("sensor-1", "2026-07-27 09:30:00", 21.0), ("sensor-1", "2026-07-27 09:45:00", 21.5)] # => co-22: the IDENTICAL 4 readings
for sensor_id, ts, value in readings: # => co-22: each INSERT is a cheap, sequential append into sensor-1's own partition
session.execute("INSERT INTO feed_wide (sensor_id, ts, value) VALUES (%s, %s, %s)", (sensor_id, ts, value)) # => binds this loop iteration's own reading
rows = list(session.execute( # => co-22: a single-partition scan, ordered by the clustering key -- Cassandra's own time-range read
"SELECT value FROM feed_wide WHERE sensor_id = %s AND ts >= %s AND ts < %s", # => a bounded time-range filter, scoped to ONE partition
("sensor-1", "2026-07-27 09:00:00", "2026-07-27 10:00:00"), # => the SAME sensor and time window timescale_feed_query used
)) # => closes the list()/execute() call -- materializes the matched rows
return [row.value for row in rows] # => hand back the values in clustering (time) order
def main() -> None: # => entry point -- runs only when this file executes directly, not on import
timescale_conn = psycopg.connect("host=localhost port=5433 dbname=nosqldb user=postgres password=nosqldb") # => connects to the local TimescaleDB Docker container
timescale_conn.execute("CREATE EXTENSION IF NOT EXISTS timescaledb") # => enables the extension, idempotent if already present
timescale_conn.commit() # => makes the extension creation durable before the hypertable conversion below
cassandra_cluster = Cluster(["127.0.0.1"], port=9042) # => connects to the local single-node Cassandra 5.0 cluster
cassandra_session = cassandra_cluster.connect() # => opens a session against that cluster
timescale_values = timescale_feed_query(timescale_conn) # => co-29: TimescaleDB's own answer to the shared time-range read
timescale_conn.commit() # => makes Timescale's 4 inserts durable
cassandra_values = cassandra_feed_query(cassandra_session) # => co-22: Cassandra's own answer to the IDENTICAL shaped read
expected = [20.0, 20.5, 21.0, 21.5] # => co-29,co-22: the SAME shared expectation for both stores, since they seed identical logical data
assert timescale_values == cassandra_values == expected # => co-29,co-22: BOTH stores serve the identical time-range read correctly
print(f"TimescaleDB time-range read: {timescale_values}") # => Output: TimescaleDB time-range read: [20.0, 20.5, 21.0, 21.5]
print(f"Cassandra time-range read: {cassandra_values}") # => Output: Cassandra time-range read: [20.0, 20.5, 21.0, 21.5]
print("Identical results, same time-range read, from two genuinely different engines") # => Output line
print("Retention: TimescaleDB has a built-in add_retention_policy (Example 83) -- the engine drops old chunks itself") # => Output line
print(" Cassandra has NO built-in retention concept -- per-row TTL (Example 48) is the closest hand-rolled equivalent, set at every INSERT") # => Output line
print("Downsampling: TimescaleDB has built-in time_bucket() + continuous aggregates (Examples 82, 84) -- the engine maintains rollups for you") # => Output line
print(" Cassandra has NO built-in downsampling -- an application must hand-roll its own rollup job, writing summarized rows to a separate table itself") # => Output line
# => co-29,co-30: this is the concrete payoff of co-29's own definition -- a TSDB bakes the time
# => axis, retention, and rollups INTO the engine, while a general wide-column store leaves all
# => three entirely to the schema and application code, even though BOTH can physically store and
# => read the same time-ordered, partition-scoped data
timescale_conn.close() # => always release what you open
cassandra_cluster.shutdown() # => always release what you open
if __name__ == "__main__": # => guards against running main() on `import example`
main() # => runs everything above when executed as a scriptRun: python3 example.py (representative output, run against psycopg[binary]==3.3.4, cassandra-driver==3.30.1,
a local timescale/timescaledb:2.28.3-pg16 Docker container, and the same local Cassandra 5.0.8
Docker container prior examples used)
Output:
TimescaleDB time-range read: [20.0, 20.5, 21.0, 21.5]
Cassandra time-range read: [20.0, 20.5, 21.0, 21.5]
Identical results, same time-range read, from two genuinely different engines
Retention: TimescaleDB has a built-in add_retention_policy (Example 83) -- the engine drops old chunks itself
Cassandra has NO built-in retention concept -- per-row TTL (Example 48) is the closest hand-rolled equivalent, set at every INSERT
Downsampling: TimescaleDB has built-in time_bucket() + continuous aggregates (Examples 82, 84) -- the engine maintains rollups for you
Cassandra has NO built-in downsampling -- an application must hand-roll its own rollup job, writing summarized rows to a separate table itselfKey takeaway: both engines physically store and read the same time-ordered, partition-scoped data equally correctly -- the difference is entirely in what the engine does FOR you: TimescaleDB's retention and downsampling are first-class, built-in features, while Cassandra's equivalents are patterns an application must hand-roll itself, one table and one TTL setting at a time.
Why it matters: this closes out the course's time-series arc by directly answering "when do I reach for a purpose-built TSDB instead of a general wide-column store for time-ordered data" -- the answer is exactly co-29's own definition: reach for TimescaleDB when retention and downsampling are core, recurring requirements the engine should own, not edge cases the application re-implements.
← Previous: Advanced Examples · Next: OLAP and Columnar Analytics Examples →
Last updated July 26, 2026