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.

The Time-Series Challenge: A single IoT deployment can generate 100 million data points per day. Traditional databases store each point individually (inefficient). Time-series databases use compression (delta encoding, downsampling) to reduce storage by 10-100x, and automatically partition by time for fast queries.
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 promnet
Logging 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 athttp://localhost:8086, signing in asadmin / adminpassword123. Useful for browsing data and for building Flux queries interactively
  • The influx CLI, which ships inside the container, authenticating withmy-super-secret-token rather 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"} 990
Every 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

TypeBehaviourExampleHow you query it
CounterOnly ever increases, resets to 0 on restarthttp_requests_totalAlmost always via rate(). The raw number is meaningless; the slope is the signal
GaugeGoes up and down freelymemory_usage_bytesRead directly, or with avg_over_time()
HistogramCounts observations into cumulative buckets, plus_sum and _countrequest_duration_secondshistogram_quantile(), computed server side, so quantiles can be aggregated across instances
SummaryLike a histogram but quantiles are computed in the clientapi_latency_secondsRead 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.15

Exposing 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 use

Pick 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=15d
Reading it top to bottom
  • scrape_interval sets your resolution floor. At 15s no query can distinguish events closer together than that, and lowering it multiplies storage across every series you collect
  • evaluation_interval is 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 unrelated
  • job_name is not a comment. It becomes a job label on every series from that target, so it is how you later write {job="python_app"} to scope a query
  • static_configs means 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 here
  • labels attaches 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"} 176

job 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 CaseBest ChoiceWhy
IoT sensor dataTimescaleDBSQL queries, relational joins with device metadata, PostgreSQL reliability
Application monitoringPrometheusPull-based metrics, alerting built-in, Grafana integration
Financial tick dataTimescaleDBACID guarantees, SQL for complex analysis, continuous aggregates
DevOps metricsPrometheusIndustry standard, service discovery, powerful alerting
High-cardinality dataInfluxDBOptimized for millions of unique tag combinations
Network telemetryInfluxDBHandles high write throughput, built-in downsampling
Business analyticsTimescaleDBSQL for BI tools, joins with dimensional tables, PostgreSQL ecosystem
Kubernetes monitoringPrometheusNative Kubernetes integration, automatic service discovery
Multi-tenant SaaS metricsInfluxDBTag-based isolation, enterprise features, cloud-native
Energy consumption trackingTimescaleDBComplex 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.

TierRetentionGranularityPoints per seriesWhat it answers
1. Raw7 days1 second604,800Incident forensics: what happened at 02:14 last Tuesday
2. Hourly rollup90 days1 hour2,160 (280x fewer)Weekly and monthly reporting, quarter-over-quarter comparisons
3. Daily rollup2 years1 day730 (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
Remember: Choose TimescaleDB for SQL familiarity and complex analytics, InfluxDB for high-cardinality metrics and cloud-native deployments, Prometheus for monitoring and alerting in Kubernetes environments. Always implement retention policies (delete old raw data) and downsampling (keep aggregates longer) to manage storage costs. A typical setup: 7 days raw data, 90 days hourly aggregates, 2 years daily aggregates - this reduces storage by 95%+ while maintaining useful historical data. Time-series databases turn "query takes 30 seconds" into "query takes 50ms" for timestamped data at scale.