Time-Series Databases
Optimized storage and queries for timestamped data at scale.
When Regular Databases Struggle with Time
Storing billions of sensor readings, server metrics, stock prices, or IoT events in PostgreSQL or MySQL creates performance problems: queries slow to a crawl, indexes become massive, storage explodes, and common queries like "show me the average CPU usage per hour for the last 30 days" take minutes instead of milliseconds. Time-series databases solve these problems with specialized storage engines, compression algorithms, and query optimizations designed for timestamped data. This lesson covers TimescaleDB (PostgreSQL extension for time-series), InfluxDB (purpose-built for metrics), and Prometheus (monitoring-focused). You'll learn when to use each, how retention policies automatically delete old data, how downsampling reduces storage by 90%+, and the query patterns that make time-series queries blazingly fast.
Try it locally (TimescaleDB, InfluxDB, Prometheus, Grafana)
You can reproduce the examples in this lesson on your own machine. Everything shown here was verified against this container.
# Every TimescaleDB example on this page runs against this container.
docker run -d --name timescale-demo \
-e POSTGRES_PASSWORD=demo -e POSTGRES_USER=demo -e POSTGRES_DB=demo \
-p 5432:5432 timescale/timescaledb-ha:pg16
docker exec -it timescale-demo psql -U demo -d demo
# CREATE EXTENSION timescaledb; is already done in this image.
# Clean up: docker rm -f timescale-demo
# ---- InfluxDB 2.7, for the Flux examples ----
docker run -d --name influx-demo -p 8086:8086 \
-e DOCKER_INFLUXDB_INIT_MODE=setup \
-e DOCKER_INFLUXDB_INIT_USERNAME=admin \
-e DOCKER_INFLUXDB_INIT_PASSWORD=adminpassword123 \
-e DOCKER_INFLUXDB_INIT_ORG=my-org \
-e DOCKER_INFLUXDB_INIT_BUCKET=sensors \
-e DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=my-super-secret-token \
influxdb:2.7
# Python packages belong in a virtualenv. On Debian/Ubuntu a bare
# "pip install" is refused (PEP 668: externally-managed-environment).
python3 -m venv .venv && source .venv/bin/activate
pip install influxdb-client # for the Flux examples
pip install prometheus-api-client # for the PromQL examples
# ---- Prometheus + Grafana, for the metrics examples ----
# STEP 0. Pick a working directory and STAY IN IT. The two containers
# below bind-mount files with -v $PWD/..., and $PWD is simply whatever
# directory you run docker from. Both files must sit in that directory.
mkdir -p ~/prometheus-demo
cd ~/prometheus-demo # every command below runs here
# STEP 1. Create exporter.py here. This is the same script explained in
# "Exposing Metrics with Python" later in the lesson, repeated so this
# block runs start to finish without scrolling ahead.
cat > exporter.py <<'PYEOF'
from prometheus_client import start_http_server, Counter, Gauge, Histogram
import random
import time
request_counter = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
)
active_users = Gauge('active_users', 'Number of active users')
request_duration = Histogram(
'request_duration_seconds',
'Request duration in seconds',
['endpoint']
)
def simulate_traffic():
endpoints = ['/api/users', '/api/products', '/api/orders']
methods = ['GET', 'POST']
statuses = ['200', '404', '500']
while True:
endpoint = random.choice(endpoints)
method = random.choice(methods)
status = random.choice(statuses)
request_counter.labels(method=method, endpoint=endpoint,
status=status).inc()
active_users.set(random.randint(100, 500))
request_duration.labels(endpoint=endpoint).observe(
random.uniform(0.01, 2.0))
time.sleep(1)
if __name__ == '__main__':
start_http_server(8000)
print("Metrics server started on port 8000", flush=True)
simulate_traffic()
PYEOF
# STEP 2. Create prometheus.yml here. The scrape target must be the
# exporter's CONTAINER NAME: Prometheus resolves it over the Docker
# network, whereas 'localhost' inside the Prometheus container means
# Prometheus itself, leaving the target "health":"down" forever.
cat > prometheus.yml <<'EOF'
global:
scrape_interval: 15s
scrape_configs:
- job_name: 'python_app'
static_configs:
- targets: ['exporter:8000']
EOF
# STEP 3. Check both files before starting anything. Two ways this goes
# wrong, and neither announces itself clearly:
ls -l exporter.py prometheus.yml
# -rw-r--r-- 1 you you 1143 ... exporter.py <- leading '-', non-zero size
# -rw-r--r-- 1 you you 130 ... prometheus.yml
#
# a) A leading 'd' means it is a DIRECTORY. A bind mount never creates
# a missing file: point -v at a path that does not exist and Docker
# creates a directory there, and the container then dies with
# 'error mounting ... to rootfs at "/etc/prometheus/prometheus.yml"'.
# Fix: rmdir exporter.py prometheus.yml then redo steps 1 and 2.
#
# b) A size of 0 means the file exists but is empty, which is what you
# get by creating it and forgetting to paste anything in. Nothing
# errors: python runs an empty file, prints nothing and exits 0, so
# 'docker ps -a' shows "Exited (0)" and it looks like a crash that
# left no trace. Fix: redo step 1.
# Prometheus scrapes over the network, so put it and the exporter on the
# same Docker network and refer to the exporter by container name.
docker network create promnet
docker run -d --name exporter --network promnet -p 8000:8000 \
-v $PWD/exporter.py:/app/exporter.py python:3.12-slim \
sh -c "pip install -q prometheus_client && exec python /app/exporter.py"
docker run -d --name prom-demo --network promnet -p 9090:9090 \
-v $PWD/prometheus.yml:/etc/prometheus/prometheus.yml prom/prometheus
docker run -d --name grafana-demo -p 3001:3000 grafana/grafana
# Check both targets are up before querying:
curl -s http://localhost:9090/api/v1/targets | grep -o '"health":"[a-z]*"'
# Clean up:
# docker rm -f timescale-demo influx-demo exporter prom-demo grafana-demo
# docker network rm promnetLogging in to InfluxDB
TimescaleDB is PostgreSQL, so psql gets you in. InfluxDB is its own server with its own auth, and the DOCKER_INFLUXDB_INIT_* variables above did the first-run setup for you: there is no signup screen to click through. That leaves two ways in, and you will want both.
- The web UI at
http://localhost:8086, signing in asadmin/adminpassword123. Useful for browsing data and for building Flux queries interactively - The
influxCLI, which ships inside the container, authenticating withmy-super-secret-tokenrather than the password
# Is it ready yet? curl -s http://localhost:8086/health # Reach the CLI inside the container. `influx ping` needs no credentials; # anything touching data needs the admin token. docker exec influx-demo influx ping docker exec influx-demo influx bucket list \ --org my-org --token my-super-secret-token
Expected Output:
{"name":"influxdb", "message":"ready for queries and writes", "status":"pass", "checks":[], "version": "v2.7.12", "commit": "ec9dcde5d6"}
OK
ID Name Retention Shard group duration Organization ID Schema Type
6e9157bda8f9ad9f _monitoring 168h0m0s 24h0m0s dfdf57142d9af9bc implicit
df1482527269617c _tasks 72h0m0s 24h0m0s dfdf57142d9af9bc implicit
ca7eccbebf2446da sensors infinite 168h0m0s dfdf57142d9af9bc implicit
The ids are generated per install, so yours will differ. What matters is
that `sensors` is listed: that is the bucket the INIT_ vars created and the
one every Flux example on this page writes to.The token, not the password
The username and password log you into the UI and nothing else. Every programmatic client, the CLI above and the Python examples later in this lesson, authenticates with the admin token. That is whyDOCKER_INFLUXDB_INIT_ADMIN_TOKEN is pinned to a known value here: without it InfluxDB generates a random one you would have to go and look up. A real deployment does the opposite, letting it be generated and reading it from the environment rather than from a literal in the source.
What Makes Time-Series Data Different?
Time-series data has unique characteristics that make traditional databases inefficient: write-heavy workloads, append-only patterns, time-based queries, and massive volumes.
Write-Heavy, Append-Only
- Constant stream of new data
- Rarely update historical data
- No random updates or deletes
- Example: 10,000 sensors × 1 reading/sec = 864M writes/day
Time-Based Queries
- Queries always filter by time range
- Aggregations over time windows
- Recent data queried more often
- Example: "CPU usage last 24 hours"
Massive Volume
- Billions of data points
- Storage grows continuously
- Old data less valuable
- Example: 1 year = 315 billion readings
Tagged/Labeled Data
- Metadata tags (server, region, sensor_id)
- High cardinality dimensions
- Queries filter by tags + time
- Example: server="web-1", region="us-east"
Why an Ordinary Table Struggles
Nothing about the schema below is wrong. It is the obvious way to store sensor readings, and for a few million rows it is entirely fine. The trouble starts when the table is large and the query is narrow, which is the normal shape of a time-series question:
-- A billion sensor readings in an ordinary PostgreSQL table.
CREATE TABLE sensor_readings (
timestamp TIMESTAMPTZ NOT NULL,
sensor_id TEXT NOT NULL,
value DOUBLE PRECISION,
location TEXT
);
-- The question you actually ask of it: one sensor, last 24 hours.
SELECT AVG(value)
FROM sensor_readings
WHERE timestamp >= NOW() - INTERVAL '24 hours'
AND sensor_id = 'sensor_1';What the plain table does
- Scans far more than it needs - without help, the whole billion rows are candidates, though the answer lives in one day of them
- Pays for a huge index - a
(timestamp, sensor_id)index over a billion rows is itself large enough to stop fitting in memory - Stores every row in full - readings from the same sensor differ by fractions, yet each one is written out independently with no compression across them
What a time-series database does
- Partitions by time automatically - the time predicate eliminates whole chunks before any row is read
- Compresses column wise - similar values stored together compress far better than whole rows, and delta encoding exploits the fact that consecutive readings barely change
- Pre-aggregates rollups - hourly and daily averages are maintained as data arrives, so common dashboard queries never touch raw rows
How big is the win, really?
Entirely dependent on how much of the table the time predicate lets you skip. Partition a year of data by day and a 24-hour query touches roughly 1/365th of it; ask for the whole year and partitioning has bought you nothing. That is the honest framing: these are not blanket speedups, they are the payoff for queries whose shape matches the storage. Measure your own, and see the compression figures later in this lesson for numbers taken from a real TimescaleDB run.
TimescaleDB: PostgreSQL Extension for Time-Series
TimescaleDB extends PostgreSQL with automatic partitioning (hypertables), compression, and time-series-specific functions. You get time-series performance while keeping full SQL compatibility and PostgreSQL's reliability.
Setting Up TimescaleDB
-- Install TimescaleDB extension
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Create regular PostgreSQL table
CREATE TABLE sensor_data (
time TIMESTAMPTZ NOT NULL,
sensor_id TEXT NOT NULL,
temperature DOUBLE PRECISION,
humidity DOUBLE PRECISION,
location TEXT
);
-- Convert to hypertable (automatic time-based partitioning)
SELECT create_hypertable('sensor_data', 'time');
-- Create indexes for common queries
CREATE INDEX ON sensor_data (sensor_id, time DESC);
CREATE INDEX ON sensor_data (location, time DESC);Inserting Time-Series Data with Python
import psycopg
from datetime import datetime, timedelta, timezone
import random
# Connect to TimescaleDB (PostgreSQL with extension)
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cursor = conn.cursor()
# Simulate sensor data
random.seed(42) # so the aggregates in the next example are reproducible
sensors = ['sensor_1', 'sensor_2', 'sensor_3']
locations = ['building_a', 'building_b', 'building_c']
print("Inserting 10,000 sensor readings...")
# datetime.now() is NAIVE: it carries no offset, so PostgreSQL
# interprets it in the session's TimeZone. Write that into a
# TIMESTAMPTZ column from a machine set to anything but UTC and
# every reading silently lands hours away from where you meant.
base_time = datetime.now(timezone.utc) - timedelta(days=7)
for i in range(10000):
timestamp = base_time + timedelta(minutes=i)
sensor_id = random.choice(sensors)
location = random.choice(locations)
temperature = round(random.uniform(18.0, 28.0), 2)
humidity = round(random.uniform(30.0, 70.0), 2)
cursor.execute("""
INSERT INTO sensor_data (time, sensor_id, temperature, humidity, location)
VALUES (%s, %s, %s, %s, %s)
""", (timestamp, sensor_id, temperature, humidity, location))
if (i + 1) % 1000 == 0:
print(f" Inserted {i + 1} records...")
conn.commit()
conn.commit()
print("✓ Data insertion complete!")
cursor.execute("""
SELECT count(*) FROM timescaledb_information.chunks
WHERE hypertable_name = 'sensor_data'
""")
print(f"chunks created automatically: {cursor.fetchone()[0]}")
cursor.close()
conn.close()Expected Output:
Inserting 10,000 sensor readings...
Inserted 1000 records...
Inserted 2000 records...
Inserted 3000 records...
Inserted 4000 records...
Inserted 5000 records...
Inserted 6000 records...
Inserted 7000 records...
Inserted 8000 records...
Inserted 9000 records...
Inserted 10000 records...
✓ Data insertion complete!
chunks created automatically: 2
The INSERT statements are ordinary PostgreSQL and name no partition, yet
TimescaleDB split the readings across two chunks on its own. The default
chunk interval is 7 days, and 10,000 one-minute readings span just under
seven, so the range straddles one boundary and produces two chunks.
Chunk size is the one setting worth thinking about up front. The guidance
is that a chunk's indexes should fit in memory, so on a busy ingest path
you shrink the interval:
SELECT set_chunk_time_interval('sensor_data', INTERVAL '1 day');
Changing it later affects only NEW chunks; existing ones keep the interval
they were created with.Time-Series Queries
import psycopg
from datetime import datetime, timedelta, timezone
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cursor = conn.cursor()
# Query 1: Recent data (last 24 hours)
cursor.execute("""
SELECT
sensor_id,
AVG(temperature) as avg_temp,
MAX(temperature) as max_temp,
MIN(temperature) as min_temp,
COUNT(*) as reading_count
FROM sensor_data
WHERE time >= NOW() - INTERVAL '24 hours'
GROUP BY sensor_id
ORDER BY sensor_id
""")
print("Sensor statistics (last 24 hours):")
for sensor, avg_t, max_t, min_t, count in cursor.fetchall():
print(f" {sensor}: Avg={avg_t:.1f}°C, Max={max_t:.1f}°C, Min={min_t:.1f}°C ({count} readings)")
# Query 2: Time-bucket aggregation (hourly averages)
cursor.execute("""
SELECT
time_bucket('1 hour', time) AS hour,
sensor_id,
AVG(temperature) as avg_temp,
AVG(humidity) as avg_humidity
FROM sensor_data
WHERE time >= NOW() - INTERVAL '7 days'
GROUP BY hour, sensor_id
ORDER BY hour DESC, sensor_id
LIMIT 10
""")
print("\nHourly averages (last 7 days, most recent first):")
for hour, sensor, temp, humidity in cursor.fetchall():
print(f" {hour} - {sensor}: {temp:.1f}°C, {humidity:.1f}%")
cursor.close()
conn.close()Expected Output:
Sensor statistics (last 24 hours): sensor_1: Avg=22.8°C, Max=27.9°C, Min=18.1°C (442 readings) sensor_2: Avg=23.1°C, Max=28.0°C, Min=18.0°C (467 readings) sensor_3: Avg=23.2°C, Max=28.0°C, Min=18.0°C (450 readings) Hourly averages (last 7 days, most recent first): 2026-07-29 17:00:00+00:00 - sensor_1: 22.7°C, 54.6% 2026-07-29 17:00:00+00:00 - sensor_2: 24.0°C, 46.7% 2026-07-29 17:00:00+00:00 - sensor_3: 22.3°C, 52.1% 2026-07-29 16:00:00+00:00 - sensor_1: 24.0°C, 49.8% 2026-07-29 16:00:00+00:00 - sensor_2: 23.5°C, 47.4% 2026-07-29 16:00:00+00:00 - sensor_3: 23.7°C, 56.0% 2026-07-29 15:00:00+00:00 - sensor_1: 23.7°C, 47.2% 2026-07-29 15:00:00+00:00 - sensor_2: 23.4°C, 55.0% 2026-07-29 15:00:00+00:00 - sensor_3: 23.6°C, 51.4% 2026-07-29 14:00:00+00:00 - sensor_1: 23.0°C, 50.3% The dates track whatever day you run this, because the loader anchors the readings to NOW() - 7 days. The counts do not: random.seed(42) fixes which sensor each reading is assigned to, so you should get 442 / 467 / 450, and they sum to 1,359 rather than 1,440 because the newest reading lands about 80 minutes before now. time_bucket is the function that makes this lesson worth reading. Plain PostgreSQL has date_trunc, which can only cut on calendar boundaries: hour, day, month. time_bucket takes an arbitrary interval, so '5 minutes' or '90 seconds' are just as available as '1 hour', which is what irregular sensor data actually needs. Note the timestamps come back with +00:00. The column is TIMESTAMPTZ and the container runs in UTC. Time-series data should always be TIMESTAMPTZ: store an ambiguous local time and a daylight-saving change will give you two readings that claim the same instant.
Compression (Reduce Storage by 90%)
-- Enable compression on chunks older than 7 days
ALTER TABLE sensor_data SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'sensor_id, location',
timescaledb.compress_orderby = 'time DESC'
);
-- Add compression policy (automatic)
SELECT add_compression_policy('sensor_data', INTERVAL '7 days');
-- Manually compress chunks now, so you can see the numbers without
-- waiting a week for the policy to fire. Note the interval: the sample
-- data only spans seven days, so older_than => INTERVAL '7 days' would
-- match nothing and silently compress zero chunks.
SELECT compress_chunk(i)
FROM show_chunks('sensor_data', older_than => INTERVAL '1 day') i;Compression is transparent, and that is the point
Nothing above changes how you query the table. Run the same aggregate before and after compressing four of six chunks and the answer is byte-identical: same counts, same averages, same SQL. There is no decompress step, no separate archive table, and no second code path in your application. Indexes keep working too, the plan just shows a ColumnarScan reaching into the compressed chunk instead of a normal index scan.
Writes into compressed chunks also work on modern TimescaleDB, verified here on 2.28.3: INSERT, UPDATE, and DELETE against already-compressed data all succeed. This was not always true, so older advice warning that compressed chunks are read-only is out of date, but treat it as something that works rather than something that is cheap: writing into a compressed chunk is far more expensive than appending to a recent one. Compress data you have stopped editing.
Checking Compression Results
import psycopg
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cursor = conn.cursor()
# Check compression statistics
cursor.execute("""
SELECT
pg_size_pretty(before_compression_total_bytes) as uncompressed,
pg_size_pretty(after_compression_total_bytes) as compressed,
ROUND(100 - (after_compression_total_bytes::numeric /
before_compression_total_bytes::numeric * 100), 2) as compression_ratio
FROM hypertable_compression_stats('sensor_data')
""")
result = cursor.fetchone()
# A row comes back even when nothing has been compressed, with NULLs in
# it, so test the values rather than the row.
if result and result[0] is not None:
uncompressed, compressed, ratio = result
print("Compression Statistics:")
print(f" Uncompressed size: {uncompressed}")
print(f" Compressed size: {compressed}")
print(f" Space saved: {ratio}%")
else:
print("No compression data available yet")
cursor.close()
conn.close()Expected Output:
Compression Statistics: Uncompressed size: 152 kB Compressed size: 40 kB Space saved: 73.68% 74% on the older chunk of this sample data, measured on TimescaleDB 2.28.3. Do not expect that exact figure, or the 152 kB/40 kB it came from: the default chunk interval is 7 days, so which of your two chunks counts as "older than 1 day" (and how much of the 10,000 rows landed in it) shifts with the day of the week you run this, and that alone swung a repeat of this exact script from 74% to 86% here. Vendor marketing often quotes 90%+; that is achievable, but it depends entirely on how compressible your columns are. This test used random floats, which is close to the worst case. Real sensors drift slowly, so adjacent readings are nearly identical and the delta encoding does far better. The lever is compress_segmentby. Grouping rows by sensor_id and location puts similar values next to each other before they are encoded, so choosing the right segmentby column matters more than any other setting. Get it wrong and you can land well under 50%. One trap worth repeating from the SQL above: compress_chunk only touches chunks that show_chunks actually returns. Point older_than at an interval your data does not reach and it compresses nothing, reports success, and the stats query then returns a row full of NULLs rather than no row at all. That is why the Python tests result[0] is not None rather than just testing the row.
Retention Policies (Auto-Delete Old Data)
-- Automatically drop data older than 90 days
SELECT add_retention_policy('sensor_data', INTERVAL '90 days');
-- Check retention policy
SELECT * FROM timescaledb_information.jobs
WHERE proc_name = 'policy_retention';InfluxDB: Purpose-Built Time-Series Database
InfluxDB is designed from the ground up for time-series data. It uses its own query language (Flux/InfluxQL), provides built-in visualization, and excels at high-cardinality data (millions of unique tag combinations).
InfluxDB Data Model
InfluxDB Data Structure: measurement (like a table) │ ├─ tags (indexed, metadata) │ ├─ sensor_id = "sensor_1" │ ├─ location = "building_a" │ └─ region = "us-east" │ ├─ fields (actual data, not indexed) │ ├─ temperature = 23.5 │ └─ humidity = 52.3 │ └─ timestamp = 2024-06-15T10:30:00Z Example data point: ┌─────────────────────────────────────────────────┐ │ Measurement: sensor_readings │ ├─────────────────────────────────────────────────┤ │ Tags: │ │ sensor_id=sensor_1, location=building_a │ │ Fields: │ │ temperature=23.5, humidity=52.3 │ │ Timestamp: 2024-06-15T10:30:00Z │ └─────────────────────────────────────────────────┘ Key Concepts: • Measurement = table name • Tags = indexed dimensions (WHERE filters) • Fields = actual values (SELECT columns) • Timestamp = automatically indexed
Writing Data to InfluxDB
from influxdb_client import InfluxDBClient, Point
from influxdb_client.client.write_api import SYNCHRONOUS
from datetime import datetime, timedelta, timezone
import random
# Connect to InfluxDB
client = InfluxDBClient(
url="http://localhost:8086",
token="my-super-secret-token", # from the docker run above
org="my-org"
)
write_api = client.write_api(write_options=SYNCHRONOUS)
bucket = "sensors"
# Write sensor readings
print("Writing sensor data to InfluxDB...")
random.seed(42) # so the averages in the next example are reproducible
sensors = ['sensor_1', 'sensor_2', 'sensor_3']
locations = ['building_a', 'building_b']
base_time = datetime.now(timezone.utc) - timedelta(hours=24)
for i in range(1000):
timestamp = base_time + timedelta(minutes=i)
for sensor in sensors:
for location in locations:
point = Point("sensor_readings") \
.tag("sensor_id", sensor) \
.tag("location", location) \
.field("temperature", round(random.uniform(18.0, 28.0), 2)) \
.field("humidity", round(random.uniform(30.0, 70.0), 2)) \
.time(timestamp)
write_api.write(bucket=bucket, record=point)
if (i + 1) % 100 == 0:
print(f" Written {(i + 1) * len(sensors) * len(locations)} points...")
print("✓ Data written successfully!")
client.close()Expected Output:
Writing sensor data to InfluxDB...
Written 600 points...
Written 1200 points...
Written 1800 points...
...
Written 6000 points...
✓ Data written successfully!
6,000 points: 1,000 timestamps x 3 sensors x 2 locations. Measured
against InfluxDB 2.7.12.
The loop as written calls write_api.write() once per point, which is one
HTTP request per point and is the single most common way to make
InfluxDB look slow. Build a list and pass it in batches instead:
points.append(point)
if len(points) >= 500:
write_api.write(bucket=bucket, record=points)
points = []
SYNCHRONOUS also means each write blocks until the server acknowledges.
That is the right default while learning, because errors surface
immediately; for production throughput use WriteOptions(batch_size=...)
and let the client batch in the background.Querying with Flux
from influxdb_client import InfluxDBClient
client = InfluxDBClient(
url="http://localhost:8086",
token="my-super-secret-token", # from the docker run above
org="my-org"
)
query_api = client.query_api()
# Query 1: Average temperature by sensor (last 24 hours)
query = '''
from(bucket: "sensors")
|> range(start: -24h)
|> filter(fn: (r) => r._measurement == "sensor_readings")
|> filter(fn: (r) => r._field == "temperature")
|> group(columns: ["sensor_id"])
|> mean()
'''
tables = query_api.query(query)
print("Average temperature by sensor (last 24h):")
for table in tables:
for record in table.records:
sensor = record.values.get("sensor_id")
avg_temp = record.get_value()
print(f" {sensor}: {avg_temp:.2f}°C")
# Query 2: Downsampled data (hourly averages)
query = '''
from(bucket: "sensors")
|> range(start: -7d)
|> filter(fn: (r) => r._measurement == "sensor_readings")
|> filter(fn: (r) => r.sensor_id == "sensor_1")
|> filter(fn: (r) => r._field == "temperature")
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> limit(n: 10)
'''
tables = query_api.query(query)
# sensor_1 was written under two locations, so Flux returns TWO tables,
# one per series, and limit(n: 10) applied to each of them separately.
# Print the location or the rows look like inexplicable duplicates.
print(f"\nHourly averages for sensor_1 ({len(tables)} tables returned):")
for table in tables:
location = table.records[0].values.get("location")
print(f" location={location}")
for record in table.records:
time = record.get_time()
temp = record.get_value()
print(f" {time}: {temp:.2f}°C")
client.close()Expected Output:
Average temperature by sensor (last 24h):
sensor_1: 23.06°C
sensor_2: 23.03°C
sensor_3: 22.91°C
Hourly averages for sensor_1 (2 tables returned):
location=building_a
2026-07-28 20:00:00+00:00: 23.17°C
2026-07-28 21:00:00+00:00: 23.20°C
2026-07-28 22:00:00+00:00: 22.56°C
... 7 more rows ...
location=building_b
2026-07-28 20:00:00+00:00: 23.26°C
2026-07-28 21:00:00+00:00: 23.57°C
2026-07-28 22:00:00+00:00: 23.02°C
... 7 more rows ...
Measured against InfluxDB 2.7.12 with the 6,000 points written above. The
three averages sit within 0.2°C of each other because the generator draws
uniformly from the same range for every sensor; real sensors would separate.
random.seed(42) in the writer is what makes them reproducible.
The second query is the one to study, because it returns 20 rows and not
the 10 its limit() asks for. Flux does not have a flat result set: it has
tables, one per series, and a series is a distinct combination of tags.
sensor_1 was written under two locations, so it is two series, and
limit(n: 10) is applied to each table independently. Every Flux function
works this way. If you want a global limit you have to collapse the series
first, for example with group() to drop the grouping keys before limiting.
Read the pipeline from the top: range() first, then filter(), then the
aggregation. That order matters. range() is the only stage that can use the
time index, so a query without it scans the whole bucket, and InfluxDB will
refuse rather than let you do it by accident. Put filters before aggregation
for the same reason SQL puts WHERE before GROUP BY: every later stage then
handles less data.
group(columns: ["sensor_id"]) is Flux's GROUP BY. Omit it and mean()
collapses all three sensors into a single number, which is rarely what you
meant and never raises an error.Downsampling (Continuous Queries)
# Create downsampling task (runs every hour)
# Reduces high-resolution data to hourly averages
from influxdb_client import InfluxDBClient
client = InfluxDBClient(url="http://localhost:8086",
token="my-super-secret-token", org="my-org")
# create_task_every() wants an Organization OBJECT, not the name string,
# so look it up first. Passing "my-org" here fails with a 404.
org = client.organizations_api().find_organizations(org="my-org")[0]
# The destination bucket has to exist before the task can write to it.
buckets_api = client.buckets_api()
if buckets_api.find_bucket_by_name("sensor_data_hourly") is None:
buckets_api.create_bucket(bucket_name="sensor_data_hourly", org_id=org.id)
# NOTE: no 'option task = {...}' line here. create_task_every() injects
# one for you from the name/every arguments below. Supply both and
# InfluxDB rejects the request:
# 400 Bad Request: invalid options: multiple task options defined
# Either write the option yourself and call create_task(), or leave it
# out and call create_task_every(). Not both.
task_flux = '''
from(bucket: "sensors")
|> range(start: -1h)
|> filter(fn: (r) => r._measurement == "sensor_readings")
|> aggregateWindow(every: 1h, fn: mean, createEmpty: false)
|> to(bucket: "sensor_data_hourly")
'''
task = client.tasks_api().create_task_every(
name="downsample_hourly",
flux=task_flux,
every="1h",
organization=org
)
print(f"Created downsampling task: {task.name}")
print(f" Runs every: {task.every}")
print(f" Status: {task.status}")
print(f" Task ID: {task.id}")
client.close()Expected Output:
Created downsampling task: downsample_hourly Runs every: 1h Status: active Task ID: 1117d3f4abd25000 Verified on InfluxDB 2.7.12 with influxdb-client 1.50.0. Your task id will differ; it is assigned by the server. Two setup steps are easy to miss and both fail late rather than early. The destination bucket must already exist, or the task is created happily and then fails on every run. And create_task_every() wants an Organization object rather than the org name, so look it up with organizations_api() first: passing the string "my-org" returns a 404 that does not obviously point at the org argument. The task now runs server-side every hour with no client involved, which is InfluxDB's answer to TimescaleDB's continuous aggregates. The difference is that a continuous aggregate is incrementally maintained and queryable like a view, whereas this task simply writes new points into a second bucket on a schedule. If a task run fails, that hour is missing until you backfill it; nothing reconciles automatically. Note aggregateWindow(createEmpty: false). Left at its default of true it emits a null-valued point for every window with no data, which quietly fills your downsampled bucket with rows representing nothing.
Retention Policies in InfluxDB
from influxdb_client import InfluxDBClient, BucketRetentionRules
client = InfluxDBClient(url="http://localhost:8086",
token="my-super-secret-token", org="my-org")
buckets_api = client.buckets_api()
DAY = 24 * 60 * 60
def ensure_bucket(name, days):
"""Create a bucket with a retention period, or leave it alone if it exists."""
existing = buckets_api.find_bucket_by_name(name)
if existing is not None:
return existing
return buckets_api.create_bucket(
bucket_name=name,
# The class is BucketRetentionRules, exported from the package root.
# There is no RetentionRules.
retention_rules=BucketRetentionRules(type="expire",
every_seconds=days * DAY),
org="my-org",
)
raw = ensure_bucket("sensor_data_raw", 30)
hourly = ensure_bucket("sensor_data_hourly", 365)
print(f"Created bucket: {raw.name}")
print(f" retention: {raw.retention_rules[0].every_seconds} seconds (30 days)")
# find_buckets() does not promise an order, so sort before printing.
print("\nBuckets now:")
for b in sorted(buckets_api.find_buckets().buckets, key=lambda b: b.name):
if b.retention_rules:
secs = b.retention_rules[0].every_seconds
retention = f"{secs // DAY} days" if secs else "infinite"
else:
retention = "infinite"
print(f" {b.name + ':':<20} retention {retention}")
client.close()Expected Output:
Created bucket: sensor_data_raw retention: 2592000 seconds (30 days) Buckets now: _monitoring: retention 7 days _tasks: retention 3 days sensor_data_hourly: retention infinite sensor_data_raw: retention 30 days sensors: retention infinite Verified against influxdb-client 1.50.0 and InfluxDB 2.7.12. The retention class is BucketRetentionRules, exported from the package root. There is no RetentionRules, and reaching for that name is a NameError at the second bucket rather than the first, so the script half-succeeds and leaves you with one bucket created. Prefer the accessors on the client, client.buckets_api() and client.tasks_api(), over constructing the Api classes yourself: they wire up the shared connection. find_buckets() does not promise an order, hence the sort. Without it the listing changes between runs for no reason. Look at sensor_data_hourly: retention infinite, even though we asked for a year. It already existed, created by the downsampling task example above with no retention rule, and create_bucket does not retroactively apply one. That is the whole failure mode of retention in InfluxDB in one line. Retention is a property of the bucket, not of the data, so it has to be set when the bucket is created; a bucket created as a side effect of something else quietly keeps its data forever. Tiering means writing each resolution into its own bucket, deliberately, which is why the downsampling task writes to a second bucket rather than back into the first.
Prometheus: Monitoring-Focused Time-Series DB
Prometheus is designed specifically for monitoring and alerting. It uses a pull-based model (scrapes metrics from targets), has a powerful query language (PromQL), and integrates seamlessly with Grafana for visualization.
Prometheus Data Model
Prometheus has no schema to declare. A series is identified by its metric name plus its full set of labels, and that identity is created implicitly the first time a target exposes it. The consequence is the thing to internalise: labels are not columns attached to one series, they are part of what makes a series distinct.
# One line of Prometheus exposition format: this IS the wire protocol.
http_requests_total{method="GET", endpoint="/api/users", status="200"} 12345
# http_requests_total .......... metric name
# {method="GET", ...} .......... labels
# 12345 ........................ value at scrape time
# Change ANY label value and you have a different time series. These are
# four separate series, not one series with four readings:
http_requests_total{method="GET", endpoint="/api/users", status="200"} 12345
http_requests_total{method="POST", endpoint="/api/users", status="200"} 42
http_requests_total{method="GET", endpoint="/api/users", status="500"} 7
http_requests_total{method="GET", endpoint="/api/orders", status="200"} 990Every label value multiplies
Series count is the product of the label values, not their sum: 5 methods x 100 endpoints x 8 status codes is 4,000 series from a single metric name. That is why putting a user id, a request id, or an email address in a label is the classic way to take Prometheus down, and it is the same cardinality explosion described earlier in this lesson. Labels are for dimensions you group by, never for identifiers.
The Four Metric Types
| Type | Behaviour | Example | How you query it |
|---|---|---|---|
| Counter | Only ever increases, resets to 0 on restart | http_requests_total | Almost always via rate(). The raw number is meaningless; the slope is the signal |
| Gauge | Goes up and down freely | memory_usage_bytes | Read directly, or with avg_over_time() |
| Histogram | Counts observations into cumulative buckets, plus_sum and _count | request_duration_seconds | histogram_quantile(), computed server side, so quantiles can be aggregated across instances |
| Summary | Like a histogram but quantiles are computed in the client | api_latency_seconds | Read directly. Cannot be meaningfully averaged across instances, which is why histograms are usually the better default |
The difference is visible in what each one actually puts on the wire. A counter and a gauge emit a single number; a histogram expands into one series per bucket plus two more; a summary emits only the aggregates unless you configure quantiles:
# What each metric type actually emits, from a live exporter.
# (prometheus_client 0.26.0; scrape output trimmed to the four metrics.)
# TYPE http_requests_total counter
http_requests_total{endpoint="/api/users",method="GET",status="200"} 12345.0
# TYPE memory_usage_bytes gauge
memory_usage_bytes 5.24288e+08
# TYPE request_duration_seconds histogram
request_duration_seconds_bucket{le="0.1"} 1.0
request_duration_seconds_bucket{le="0.5"} 2.0
request_duration_seconds_bucket{le="1.0"} 3.0
request_duration_seconds_bucket{le="+Inf"} 4.0
request_duration_seconds_count 4.0
request_duration_seconds_sum 3.15
# TYPE api_latency_seconds summary
api_latency_seconds_count 4.0
api_latency_seconds_sum 3.15Exposing Metrics with Python
Run this in the container or on your machine, not both
The setup block at the top of this lesson already runs this script inside theexporter container, published with-p 8000:8000. That claims host port 8000, so starting a second copy locally fails before it serves anything:
OSError: [Errno 98] Address already in usePick one. To read the code and run it yourself, stop the container first withdocker stop exporter. To leave the container serving, treat the script below as reading material and check it withcurl -s http://localhost:8000/metrics | head. Either way Prometheus keeps scraping exporter:8000 over the Docker network, so stopping the container is also what makes its target go"health":"down".
from prometheus_client import start_http_server, Counter, Gauge, Histogram
import random
import time
# Define metrics
request_counter = Counter(
'http_requests_total',
'Total HTTP requests',
['method', 'endpoint', 'status']
)
active_users = Gauge(
'active_users',
'Number of active users'
)
request_duration = Histogram(
'request_duration_seconds',
'Request duration in seconds',
['endpoint']
)
# Simulate application metrics
def simulate_traffic():
"""Simulate web application traffic"""
endpoints = ['/api/users', '/api/products', '/api/orders']
methods = ['GET', 'POST']
statuses = ['200', '404', '500']
while True:
# Increment request counter
endpoint = random.choice(endpoints)
method = random.choice(methods)
status = random.choice(statuses)
request_counter.labels(method=method, endpoint=endpoint, status=status).inc()
# Update active users gauge
active_users.set(random.randint(100, 500))
# Record request duration
duration = random.uniform(0.01, 2.0)
request_duration.labels(endpoint=endpoint).observe(duration)
time.sleep(1) # Wait 1 second
if __name__ == '__main__':
# Start metrics server on port 8000
start_http_server(8000)
# flush=True matters in Docker: stdout is a pipe rather than a
# terminal, so Python block-buffers it and these lines would not
# reach "docker logs" until the buffer filled, making a perfectly
# healthy container look silent.
print("Metrics server started on port 8000", flush=True)
print("Prometheus can scrape: http://localhost:8000/metrics", flush=True)
# Simulate traffic
simulate_traffic()Expected Output:
Metrics server started on port 8000
Prometheus can scrape: http://localhost:8000/metrics
curl http://localhost:8000/metrics then shows something like this. The
counter values are NOT reproducible: simulate_traffic() loops forever
picking endpoints and statuses at random, so what you see is whatever the
counters had reached at the moment you scraped. The metric names, label
sets and exposition format are the parts to check against your own run.
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{endpoint="/api/orders",method="GET",status="200"} 67.0
http_requests_total{endpoint="/api/orders",method="GET",status="404"} 79.0
http_requests_total{endpoint="/api/orders",method="GET",status="500"} 79.0
http_requests_total{endpoint="/api/orders",method="POST",status="200"} 59.0
...
# HELP active_users Number of active users
# TYPE active_users gauge
active_users 433.0
This is the whole protocol: a plain-text page over HTTP. Prometheus PULLS
it on a schedule; your application never connects to Prometheus and does
not need to know it exists.
Note the shape of the counter. One Python Counter with three labels
became 18 separate time series, one per combination of method, endpoint
and status. That multiplication is the thing to watch: add a label with
many distinct values, user_id being the classic mistake, and you create a
series per user. This is cardinality explosion, and it is the standard
way to run a Prometheus server out of memory.
Note also that a counter only ever goes up. 67 is the total since
process start, not a rate. Turning it into "requests per second" is the
query's job, using rate(), which is why the next example exists.Querying with PromQL
# Querying needs a different package from the exporter above. The exporter
# used prometheus_client; reading data back uses prometheus-api-client.
# Note the names differ: hyphens to install, underscores to import.
#
# python3 -m venv .venv && source .venv/bin/activate
# pip install prometheus-api-client
#
from prometheus_api_client import PrometheusConnect
# Connect to Prometheus server
prom = PrometheusConnect(url="http://localhost:9090", disable_ssl=True)
# Query 1: Current request rate (requests per second)
query = 'rate(http_requests_total[5m])'
result = prom.custom_query(query)
print("Current request rate (requests/sec):")
for item in result:
metric = item['metric']
value = float(item['value'][1])
# Include status: the series is keyed on all three labels, so
# printing only two makes distinct series look like duplicates.
print(f" {metric['endpoint']} ({metric['method']}, "
f"{metric['status']}): {value:.2f} req/s")
# Query 2: 95th percentile request duration
query = 'histogram_quantile(0.95, rate(request_duration_seconds_bucket[5m]))'
result = prom.custom_query(query)
print("\n95th percentile request duration:")
for item in result:
endpoint = item['metric'].get('endpoint', 'unknown')
p95 = float(item['value'][1])
print(f" {endpoint}: {p95:.3f}s")
# Query 3: Error rate (percentage)
query = '''
sum(rate(http_requests_total{status=~"5.."}[5m])) /
sum(rate(http_requests_total[5m])) * 100
'''
result = prom.custom_query(query)
if result:
error_rate = float(result[0]['value'][1])
print(f"\nCurrent error rate: {error_rate:.2f}%")Expected Output:
Current request rate (requests/sec): /api/orders (GET, 200): 0.35 req/s /api/orders (GET, 404): 0.35 req/s /api/orders (GET, 500): 0.32 req/s /api/orders (POST, 200): 0.28 req/s /api/orders (POST, 404): 0.31 req/s /api/orders (POST, 500): 0.34 req/s ... 95th percentile request duration: /api/orders: 0.477s /api/products: 0.475s /api/users: 0.475s Error rate: 34.26% Measured against Prometheus scraping the exporter from the previous example. Print only endpoint and method, as the original loop did, and these come out as three identical-looking lines per endpoint, because status is part of the series key too. When a PromQL result looks like it contains duplicates, you are almost always dropping a label. rate() is what turns the ever-increasing counter into a per-second figure: it takes the increase over the window and divides by its length, and it handles counter resets when a process restarts. Never subtract two counter samples by hand. histogram_quantile reads the _bucket series the Histogram produced, not the raw observations. So the 0.477s is interpolated from bucket boundaries, not an exact 95th percentile. With the default buckets it can be noticeably off; define buckets that match your actual latency range if the number matters. 34% errors is high only because the generator picks status uniformly from 200/404/500. It does show the shape of the query worth memorising: a ratio of two rates, filtered with a regex label matcher.
Prometheus Configuration (prometheus.yml)
Prometheus is pull-based, and this file is the whole reason that matters: your applications never send anything anywhere. They expose a/metrics endpoint and sit still, and Prometheus decides who to visit and how often. Adding a service to monitoring is an edit here, not a deploy of the service.
# prometheus.yml configuration
global:
scrape_interval: 15s # How often to scrape targets
evaluation_interval: 15s # How often to evaluate rules
# Scrape configurations
scrape_configs:
# Job 1: Python application metrics
#
# 'localhost' is correct only when Prometheus and the exporter share a
# host. In the Docker setup at the top of this lesson they do not: use
# the exporter's container name, targets: ['exporter:8000']. Getting
# this wrong leaves the target permanently "health":"down" while
# Prometheus itself looks perfectly healthy.
- job_name: 'python_app'
static_configs:
- targets: ['localhost:8000']
labels:
environment: 'production'
app: 'sensor_api'
# Job 2: Node exporter (system metrics)
- job_name: 'node'
static_configs:
- targets: ['localhost:9100']
# Job 3: PostgreSQL metrics
- job_name: 'postgres'
static_configs:
- targets: ['localhost:9187']
# Storage retention (configured via CLI flag, not in this file):
# prometheus --storage.tsdb.retention.time=15dReading it top to bottom
scrape_intervalsets your resolution floor. At 15s no query can distinguish events closer together than that, and lowering it multiplies storage across every series you collectevaluation_intervalis a different clock: how often alerting and recording rules run over data already collected. The two are commonly equal, which hides the fact that they are unrelatedjob_nameis not a comment. It becomes ajoblabel on every series from that target, so it is how you later write{job="python_app"}to scope a querystatic_configsmeans you list the targets by hand. Fine for a demo and for fixed infrastructure; real clusters swap it for service discovery so pods appearing and disappearing do not require an edit herelabelsattaches extra dimensions to everything that target exposes, which is how one metric name serves staging and production without collisions
Where those labels end up
Query any metric from that job and you get four labels you never wrote into the Python code. This is the real scraped result for active_users:
active_users{app="sensor_api", environment="production", instance="exporter:8000", job="python_app"} 176job came from job_name, instance from the target address, and app and environment from thelabels block. Remember that each of these multiplies series count, so config-level labels deserve the same restraint as labels in code.
Jobs 2 and 3 are not databases speaking Prometheus
Neither Linux nor PostgreSQL exposes /metrics. Ports 9100 and 9187 belong to exporters: small companion processes that query the real system and republish the answers in Prometheus format. That is the standard pattern for anything you cannot instrument directly, and it is why the ecosystem ships hundreds of exporters. Prometheus itself only ever speaks HTTP to something that already talks its language.
Use Cases: Which Database to Choose?
Each time-series database excels in different scenarios. Choose based on your specific requirements and existing infrastructure.
| Use Case | Best Choice | Why |
|---|---|---|
| IoT sensor data | TimescaleDB | SQL queries, relational joins with device metadata, PostgreSQL reliability |
| Application monitoring | Prometheus | Pull-based metrics, alerting built-in, Grafana integration |
| Financial tick data | TimescaleDB | ACID guarantees, SQL for complex analysis, continuous aggregates |
| DevOps metrics | Prometheus | Industry standard, service discovery, powerful alerting |
| High-cardinality data | InfluxDB | Optimized for millions of unique tag combinations |
| Network telemetry | InfluxDB | Handles high write throughput, built-in downsampling |
| Business analytics | TimescaleDB | SQL for BI tools, joins with dimensional tables, PostgreSQL ecosystem |
| Kubernetes monitoring | Prometheus | Native Kubernetes integration, automatic service discovery |
| Multi-tenant SaaS metrics | InfluxDB | Tag-based isolation, enterprise features, cloud-native |
| Energy consumption tracking | TimescaleDB | Complex queries, reporting, regulatory compliance (SQL audit trails) |
TimescaleDB
Best for:
- SQL familiarity important
- Need ACID guarantees
- Complex joins required
- PostgreSQL ecosystem
Pros:
- Full SQL support
- PostgreSQL reliability
- Rich ecosystem
InfluxDB
Best for:
- High write throughput
- High cardinality
- Cloud-native deployment
- Built-in visualization
Pros:
- Purpose-built for TS
- Excellent compression
- Flux language power
Prometheus
Best for:
- Monitoring & alerting
- Kubernetes environments
- DevOps workflows
- Service discovery
Pros:
- Industry standard
- Powerful PromQL
- Great ecosystem
Downsampling and Retention Strategies
Time-series data grows continuously. Downsampling reduces resolution over time (hourly → daily → weekly), while retention policies automatically delete old data to manage storage costs.
Multi-Tier Retention Strategy
The insight behind tiering is that the value of a data point and the resolution you need from it fall away at different speeds. Yesterday's incident wants per-second detail; last year's capacity trend does not, and storing it at per-second resolution buys nothing but cost. So keep several copies at different resolutions and expire each on its own schedule.
| Tier | Retention | Granularity | Points per series | What it answers |
|---|---|---|---|---|
| 1. Raw | 7 days | 1 second | 604,800 | Incident forensics: what happened at 02:14 last Tuesday |
| 2. Hourly rollup | 90 days | 1 hour | 2,160 (280x fewer) | Weekly and monthly reporting, quarter-over-quarter comparisons |
| 3. Daily rollup | 2 years | 1 day | 730 (828x fewer) | Capacity planning, long-term trends, compliance retention |
Point counts are exact arithmetic (7 x 86,400 = 604,800 and so on); storage figures depend on your series count and compression, so the often-quoted shape of "100 GB raw becomes roughly 500 MB of daily rollups", a 99.5% reduction, is the right order of magnitude rather than a number to budget against. Note also that a rollup usually stores several values per bucket (min, max, avg, count), so it shrinks by less than the point ratio alone suggests.
What tiering buys, and what it costs
- Detail where it matters - full resolution while anyone might still ask, discarded once nobody will
- Cheap history - two years of daily points costs less than a single day of raw ones
- Faster dashboards - a year-long chart reads 730 pre-aggregated rows instead of aggregating 63 million on the fly
- Compliance without the bill - summaries can outlive the raw data they came from
The cost is that downsampling is lossy and irreversible. An hourly average erases the 200ms spike inside that hour, so once the raw tier expires that spike never existed. Decide what each tier must preserve before you need it: keeping max alongside avg costs almost nothing now and is impossible to add later.
Implementing Multi-Tier Retention (TimescaleDB)
-- Create continuous aggregate for hourly data
CREATE MATERIALIZED VIEW sensor_data_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS hour,
sensor_id,
location,
AVG(temperature) as avg_temperature,
MAX(temperature) as max_temperature,
MIN(temperature) as min_temperature,
AVG(humidity) as avg_humidity,
COUNT(*) as reading_count
FROM sensor_data
GROUP BY hour, sensor_id, location;
-- Create continuous aggregate for daily data
CREATE MATERIALIZED VIEW sensor_data_daily
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 day', time) AS day,
sensor_id,
location,
AVG(temperature) as avg_temperature,
MAX(temperature) as max_temperature,
MIN(temperature) as min_temperature,
AVG(humidity) as avg_humidity,
COUNT(*) as reading_count
FROM sensor_data
GROUP BY day, sensor_id, location;
-- Retention policies. Same story as compression below: the retention
-- section earlier in this lesson already put a 90-day policy on
-- sensor_data, and a second one fails with "retention policy already
-- exists for hypertable". if_not_exists makes this block re-runnable,
-- but note it also means the 7-day interval here is NOT applied if the
-- older policy is still in place. Drop the old one first if you mean to
-- change it: SELECT remove_retention_policy('sensor_data');
SELECT add_retention_policy('sensor_data', INTERVAL '7 days',
if_not_exists => true); -- Raw: 7 days
SELECT add_retention_policy('sensor_data_hourly', INTERVAL '90 days'); -- Hourly: 90 days
SELECT add_retention_policy('sensor_data_daily', INTERVAL '2 years'); -- Daily: 2 years
-- Compression policies.
-- A policy only SCHEDULES compression; it does not turn the feature on.
-- Call add_compression_policy on a table that has never had compression
-- enabled and TimescaleDB 2.18+ rejects it outright:
-- ERROR: columnstore not enabled on hypertable "sensor_data"
-- HINT: Enable columnstore before adding a columnstore policy.
-- So enable it first, on the hypertable and on each continuous aggregate:
ALTER TABLE sensor_data SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'sensor_id, location',
timescaledb.compress_orderby = 'time DESC'
);
ALTER MATERIALIZED VIEW sensor_data_hourly SET (timescaledb.compress = true);
-- if_not_exists matters here: the compression section earlier in this
-- lesson already put a policy on sensor_data, and adding a second one
-- fails with "columnstore policy already exists for hypertable".
SELECT add_compression_policy('sensor_data', INTERVAL '1 day',
if_not_exists => true);
SELECT add_compression_policy('sensor_data_hourly', INTERVAL '7 days');
-- A continuous aggregate does NOT refresh itself just because it was
-- created. Without a refresh policy it holds only the data that existed
-- at CREATE time and silently goes stale. This is the step most often
-- missed:
SELECT add_continuous_aggregate_policy('sensor_data_hourly',
start_offset => INTERVAL '3 days',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');
SELECT add_continuous_aggregate_policy('sensor_data_daily',
start_offset => INTERVAL '30 days',
end_offset => INTERVAL '1 day',
schedule_interval => INTERVAL '1 day');Expected Output:
NOTICE: refreshing continuous aggregate "sensor_data_hourly"
CREATE MATERIALIZED VIEW
NOTICE: refreshing continuous aggregate "sensor_data_daily"
CREATE MATERIALIZED VIEW
WARNING: retention policy already exists for hypertable "sensor_data"
DETAIL: A policy already exists with different arguments.
add_retention_policy -> -1 (skipped, see below)
add_retention_policy -> 1002 (sensor_data_hourly)
add_retention_policy -> 1003 (sensor_data_daily)
WARNING: columnstore policy already exists for hypertable "sensor_data"
add_compression_policy -> -1 (skipped)
add_compression_policy -> 1004
add_continuous_aggregate_policy -> 1005
add_continuous_aggregate_policy -> 1006
Each call returns a job id and the scheduler runs them in the background.
Read the two -1 results, though, because they are the interesting part.
if_not_exists => true does not mean "update it". It means "leave whatever
is there alone", and TimescaleDB says so out loud: "A policy already exists
with different arguments." The earlier sections of this lesson put a 90-day
retention policy and a 7-day compression policy on sensor_data; this block
asks for 7 days and 1 day respectively and gets neither. Check what you
actually ended up with rather than assuming the last statement won:
job_id | proc_name | drop_after | compress_after
-------+-------------------------------------+------------+---------------
1000 | policy_compression | | 7 days
1001 | policy_retention | 90 days |
1002 | policy_retention | 90 days |
1003 | policy_retention | 2 years |
1004 | policy_compression | | 7 days
1005 | policy_refresh_continuous_aggregate | |
1006 | policy_refresh_continuous_aggregate | |
To actually change an interval, remove the old policy first:
remove_retention_policy('sensor_data') then add the new one.
The two ALTER statements are not optional. Without them both compression
policies fail with "columnstore not enabled on hypertable". TimescaleDB
2.18 renamed compression to the columnstore, which is why the error says
columnstore while the API still says compression.
And the last two calls are the step most often forgotten: a continuous
aggregate does NOT refresh itself just because it was created. CREATE
MATERIALIZED VIEW populates it once, then it goes stale forever unless a
refresh policy exists. The NOTICE about "refreshing continuous aggregate"
is that one-time population, not a promise of anything ongoing.
Finally, read the retention and compression intervals together. Raw data
compresses, then is dropped, while the hourly rollup it feeds is kept for
90 days and the daily for 2 years. Drop raw data before the aggregate that
summarises it has refreshed and the detail is gone for good, which is what
end_offset on the refresh policy is protecting you from.Querying Across Retention Tiers
import psycopg
from datetime import datetime, timedelta, timezone
conn = psycopg.connect("host=localhost dbname=demo user=demo password=demo")
cursor = conn.cursor()
# Query recent data (uses raw table - high resolution)
cursor.execute("""
SELECT
time_bucket('5 minutes', time) as interval,
AVG(temperature) as avg_temp
FROM sensor_data
WHERE time >= NOW() - INTERVAL '6 hours'
AND sensor_id = 'sensor_1'
GROUP BY interval
ORDER BY interval DESC
LIMIT 10
""")
print("Recent data (5-minute intervals, last 6 hours):")
for interval, temp in cursor.fetchall():
print(f" {interval}: {temp:.2f}°C")
# Query older data (uses hourly aggregate - pre-computed)
cursor.execute("""
-- The aggregate's group key is (hour, sensor_id, location), so
-- filtering on sensor_id alone still returns one row per location
-- and the timestamps look duplicated. Select location too.
SELECT
hour,
location,
avg_temperature
FROM sensor_data_hourly
WHERE hour >= NOW() - INTERVAL '30 days'
AND hour < NOW() - INTERVAL '7 days'
AND sensor_id = 'sensor_1'
ORDER BY hour DESC, location
LIMIT 10
""")
print("\nOlder data (hourly averages, 7-30 days ago):")
for hour, location, temp in cursor.fetchall():
print(f" {hour} {location}: {temp:.2f}°C")
# Query historical data (uses daily aggregate)
cursor.execute("""
SELECT
day,
location,
avg_temperature,
max_temperature,
min_temperature
FROM sensor_data_daily
WHERE day >= NOW() - INTERVAL '1 year'
AND sensor_id = 'sensor_1'
ORDER BY day DESC, location
LIMIT 10
""")
print("\nHistorical data (daily averages, last year):")
for day, location, avg_t, max_t, min_t in cursor.fetchall():
print(f" {day} {location}: Avg={avg_t:.1f}°C, Max={max_t:.1f}°C, "
f"Min={min_t:.1f}°C")
cursor.close()
conn.close()Expected Output:
Recent data (5-minute intervals, last 6 hours): 2026-07-29 18:00:00+00:00: 19.41°C 2026-07-29 17:55:00+00:00: 21.53°C 2026-07-29 17:50:00+00:00: 18.94°C 2026-07-29 17:45:00+00:00: 23.88°C 2026-07-29 17:40:00+00:00: 21.38°C 2026-07-29 17:30:00+00:00: 24.15°C 2026-07-29 17:25:00+00:00: 24.09°C 2026-07-29 17:20:00+00:00: 26.11°C 2026-07-29 17:15:00+00:00: 21.13°C 2026-07-29 16:55:00+00:00: 26.22°C Older data (hourly averages, 7-30 days ago): 2026-07-22 19:00:00+00:00 building_a: 23.61°C 2026-07-22 19:00:00+00:00 building_b: 20.38°C 2026-07-22 19:00:00+00:00 building_c: 25.32°C Historical data (daily averages, last year): 2026-07-29 00:00:00+00:00 building_a: Avg=23.0°C, Max=27.9°C, Min=18.1°C 2026-07-29 00:00:00+00:00 building_b: Avg=22.9°C, Max=27.8°C, Min=18.2°C 2026-07-29 00:00:00+00:00 building_c: Avg=22.7°C, Max=27.9°C, Min=18.2°C ... 7 more rows ... Three things to read here. First, the gaps in the top block: 17:35 is missing, and so is everything between 16:55 and 17:15. sensor_1 simply had no reading in those buckets, because the generator assigns each minute to a random sensor. time_bucket does not invent empty buckets, so a chart drawn straight from this will silently close the gap and imply a reading that never happened. Use time_bucket_gapfill() when the absence matters. Second, the middle and bottom blocks return three rows per timestamp, not one. The continuous aggregates group by (bucket, sensor_id, location), so filtering on sensor_id alone still leaves one row per location. This is the standard surprise with rollups: the aggregate's group key is part of its schema, and you either select every key column or re-aggregate across the ones you do not want. Selecting location is the honest fix; a query that had simply printed the temperature would have looked like duplicated timestamps with contradictory values. Third, only the first query reads the raw table. At 5-minute resolution the hourly rollup cannot help, since it only holds hourly buckets. Rollups answer "how did last quarter trend", raw data answers "what happened in the last hour". Keeping both, with different retention, is the whole point of the tiering set up earlier. Dates track whatever day you run this; the loader anchors readings to NOW() - 7 days.
Key Takeaways
- Time-series data: Timestamped, write-heavy, append-only
- TimescaleDB: PostgreSQL extension, SQL queries, ACID guarantees
- InfluxDB: Purpose-built, high cardinality, Flux queries
- Prometheus: Monitoring-focused, pull-based, PromQL
- Compression: Reduces storage by 90%+ using delta encoding
- Downsampling: Reduce resolution over time (hourly → daily)
- Retention policies: Auto-delete old data to manage costs
- Multi-tier: Keep raw recent, aggregated historical data