Time-Series Storage Partitioning and Retention

An environmental sensor archive is unusual among time-series workloads: it is append-only, it is queried both temporally and spatially, it is rarely enormous by industry standards, and it has a retention obligation attached to it that most workloads do not. Those four properties together point at a specific architecture — time-partitioned rows in PostGIS with precomputed aggregates and a columnar cold tier — and away from the distributed systems that dominate general time-series advice. This stage of geospatial data storage, interpolation and GIS export lays out that architecture and the retention policy that governs it.

The number that anchors every decision is smaller than people expect. Two hundred sensors, four metrics, one-minute cadence is 1.15 million rows per day. Even at seven years that is under three billion rows, which one well-partitioned PostgreSQL instance handles without complaint. Most of the complexity in this area comes from designs sized for a workload the network will never have.


Prerequisites

  • PostgreSQL 15+ with PostGIS 3.4 and, optionally, TimescaleDB 2.14 for continuous aggregates and native compression.
  • Python 3.11 with # python 3.11 · psycopg[binary]==3.1.18 · pyarrow==15.0.2 · pandas==2.2.2.
  • A natural key on every reading. Partitioning and archival both rely on (device_id, observed_at, metric) being unique, which the PostGIS storage stage establishes.
  • UTC observation timestamps. Partition boundaries are computed in UTC; a local-time column produces partitions whose boundaries move twice a year.
  • QC flags and quality scores present. Retention policy differs by data class, and the class is read from the flag — see uncertainty quantification and data quality scoring.
Three tiers, sized by how the data is actually used Flow diagram of the storage tiers: current partitions taking writes, warm partitions answering queries, precomputed aggregates serving dashboards, and a columnar cold tier holding everything past the reprocessing window. Three tiers, sized by how the data is actually used Hot partition current month takes all writes Warm partitions 18 months reprocessing window Aggregates hourly + daily kept indefinitely Cold tier Parquet, zstd 8–15× smaller Once raw data moves to the cold tier, the aggregates stop being a cache and become the primary record — with the backup obligations that implies.
Most environmental networks never need more than this: one PostgreSQL instance, partitioned, plus object storage.

Step-by-Step Workflow

Step 1 — Size the Archive Before Choosing a Layout

# python 3.11 · psycopg[binary]==3.1.18 · pyarrow==15.0.2 · pandas==2.2.2
from dataclasses import dataclass


@dataclass(frozen=True)
class ArchiveSize:
    sensors: int
    metrics: int
    readings_per_day_per_metric: int
    retention_days: int
    bytes_per_row: int = 96          # row header + 5 columns + index share, measured

    @property
    def rows_per_day(self) -> int:
        return self.sensors * self.metrics * self.readings_per_day_per_metric

    @property
    def total_rows(self) -> int:
        return self.rows_per_day * self.retention_days

    @property
    def gigabytes(self) -> float:
        return self.total_rows * self.bytes_per_row / 1e9

    def layout(self) -> str:
        if self.total_rows < 50e6:
            return "single table with a BRIN index"
        if self.total_rows < 5e9:
            return "monthly range partitions + continuous aggregates"
        return "partitions + columnar cold tier"

Complexity: arithmetic. Running it is the point: a network that lands in the first branch should not be building the third, and the estimate takes a minute against the weeks a premature architecture costs.

Step 2 — Partition by Time

Range partitions on observed_at give three properties at once: dropping old data becomes a DETACH/DROP rather than a DELETE of a hundred million rows, each partition’s indexes stay shallow, and the planner prunes whole partitions from time-bounded queries.

CREATE TABLE reading (
    device_id   text        NOT NULL,
    observed_at timestamptz NOT NULL,
    metric      text        NOT NULL,
    value       double precision NOT NULL,
    qc_flag     smallint    NOT NULL DEFAULT 1,
    uncertainty real,
    geom        geometry(Point, 4326),
    PRIMARY KEY (device_id, observed_at, metric)
) PARTITION BY RANGE (observed_at);

CREATE TABLE reading_2026_08 PARTITION OF reading
    FOR VALUES FROM ('2026-08-01Z') TO ('2026-09-01Z');

CREATE INDEX ON reading_2026_08 USING brin (observed_at) WITH (pages_per_range = 32);
CREATE INDEX ON reading_2026_08 USING gist (geom);

BRIN on the time column rather than B-tree is the deliberate choice: rows arrive in time order, so correlation is near 1.0, and the index is thousands of times smaller — the argument made in full in GiST vs BRIN indexes for sensor geometry.

Complexity: partition pruning turns a time-bounded query from O(all rows) to O(rows in the matching partitions).

Step 3 — Precompute the Aggregates People Query

Almost every dashboard query asks for hourly or daily means, and computing them from raw readings every time is the single largest avoidable cost in the system.

CREATE MATERIALIZED VIEW reading_hourly
WITH (timescaledb.continuous) AS
SELECT device_id,
       metric,
       time_bucket('1 hour', observed_at) AS bucket,
       avg(value)                          AS mean_value,
       max(value)                          AS max_value,
       count(*)                            AS n_total,
       count(*) FILTER (WHERE qc_flag IN (1, 2, 3)) AS n_measured
FROM   reading
GROUP  BY device_id, metric, bucket;

Carrying n_measured separately from n_total is not optional — it is what lets a consumer apply a data-capture rule, per flagging imputed values through the pipeline.

Complexity: the aggregate is maintained incrementally, so a query that scanned 4.3 million rows scans 720 instead.

Step 4 — Tier Cold Partitions to Columnar Files

import pathlib

import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq


def archive_partition(conn, partition_start: str, partition_end: str,
                      root: pathlib.Path) -> pathlib.Path:
    """Export one partition to Hive-partitioned Parquet, then verify before dropping.

    The verification is the point: an archive job that drops a partition it has
    not confirmed it wrote is one bad disk away from a permanent gap.
    """
    frame = pd.read_sql(
        "SELECT device_id, observed_at, metric, value, qc_flag, uncertainty "
        "FROM reading WHERE observed_at >= %s AND observed_at < %s",
        conn, params=(partition_start, partition_end),
    )
    frame["year"] = frame["observed_at"].dt.year
    frame["month"] = frame["observed_at"].dt.month

    out = root / "reading"
    pq.write_to_dataset(
        pa.Table.from_pandas(frame, preserve_index=False),
        root_path=str(out),
        partition_cols=["year", "month"],
        compression="zstd",
    )
    written = pq.ParquetDataset(str(out)).read().num_rows
    if written < len(frame):
        raise RuntimeError(f"archive incomplete: {written} of {len(frame)} rows")
    return out

Complexity: O(n) per partition, run once per partition per lifetime. Expect 8 to 15 times compression against the row store — a year that occupies 60 GB in PostgreSQL fits in 4 to 7 GB of zstd-compressed Parquet.

Step 5 — Enforce Retention as a Scheduled Job

-- Drop a partition older than the raw-retention window, but only after the
-- archive job has recorded a verified export for it.
DO $$
DECLARE part record;
BEGIN
  FOR part IN
    SELECT c.relname, p.archived_at
    FROM   pg_class c
    JOIN   archive_log p ON p.partition_name = c.relname
    WHERE  c.relname LIKE 'reading_%'
      AND  p.archived_at IS NOT NULL
      AND  p.partition_end < now() - interval '18 months'
  LOOP
    EXECUTE format('ALTER TABLE reading DETACH PARTITION %I', part.relname);
    EXECUTE format('DROP TABLE %I', part.relname);
  END LOOP;
END $$;

The archive_log join is the safety interlock. Retention that deletes on a date alone will eventually delete a partition whose archive failed silently three weeks earlier.

Row-store size against archive size, per year Grouped bar chart comparing PostgreSQL storage including indexes with zstd-compressed Parquet for four network sizes, showing roughly an eleven-fold reduction. Row-store size against archive size, per year 0 2000 4000 6000 20×2, 5 min 200×4, 1 min 2 000×4, 1 min 20 000×6, 1 min GB per year PostgreSQL (GB/year) Parquet archive (GB/year)
The second bar is the common case, and 40 GB a year is comfortably one instance — most of the complexity in this area comes from designs sized for a workload the network will never have.

Configuration and Tuning

Layout by archive size

Network Rows/day 1-year rows Row-store size Recommended layout
20 sensors × 2 metrics, 5 min 11 500 4.2 M 0.4 GB single table, BRIN index
200 × 4, 1 min 1.15 M 420 M 40 GB monthly partitions + hourly aggregates
2 000 × 4, 1 min 11.5 M 4.2 B 400 GB partitions + compression + Parquet tier
20 000 × 6, 1 min 173 M 63 B 6 TB partitions + aggressive tiering, raw ≤ 90 days

Retention by data class

Data class Typical retention Storage tier Rationale
Raw readings (all flags) 12–24 months hot, partitioned reprocessing after a calibration correction
Raw readings, archived 7–10 years Parquet, object storage audit and reanalysis
Hourly aggregates indefinite hot the working dataset for most queries
Daily aggregates indefinite hot trend and annual reporting
Rejected readings (flag 4, 9) 12 months hot, then dropped diagnostics; no analytical value
Interpolated values with the raw window hot reproducible from raw plus method
Registry and calibration history indefinite hot without it, no reading is interpretable

The last row is the one people miss when planning storage. The registry is kilobytes and it is the only thing that makes the terabytes meaningful.

Retention by what the data supports Matrix of six data classes against hot retention, archive retention and the reason each period was chosen. Retention by what the data supports Hot Archive Because Raw readings 12–24 months 7–10 years reprocessing, then reanalysis Rejected readings 12 months 2 years fault diagnosis Hourly aggregates indefinite the working dataset Daily aggregates indefinite trend reporting Interpolated surfaces 90 days regenerable Registry history indefinite nothing is interpretable without it
The last row is the one storage plans forget: the registry is kilobytes and it is the only thing that makes the terabytes mean anything.

Validation

  • Partition pruning actually happens. EXPLAIN a one-day query and confirm the plan touches one partition. A query whose predicate wraps observed_at in a function defeats pruning and scans everything.
  • Aggregate agreement. Recompute an hourly mean from raw readings for a sample of buckets and compare against the materialized view. A drift here means the aggregate’s refresh policy is missing late arrivals.
  • Archive round-trip. Read an archived Parquet partition back and compare row counts and a checksum of values against the source before the partition is dropped. This is the only test that matters for the tiering path.
  • Retention job dry-run. Run the retention job in a mode that logs what it would drop, and check the list by eye monthly. A retention bug is unrecoverable by definition.
  • Restore time. Time a restore of one archived month into a scratch table. If it takes hours, the archive is a backup rather than a queryable tier, and the difference matters when someone asks for a reanalysis.

Failure Modes and Edge Cases

Late data arriving after a partition is archived. Readings delayed by a store-and-forward link can arrive days late. Keep the raw partition attached for a grace period well beyond your worst observed arrival latency — the figure measured in choosing a watermark grace period.

A missing partition for the current month. If partition creation is a manual step, the first insert after month end fails. Automate creation several months ahead and alert if the newest partition is less than two months in the future.

Aggregates that were computed before a recalibration. Corrected raw data does not automatically correct a materialized aggregate. Any reprocessing must invalidate and rebuild the affected buckets.

Dropping raw data that a published figure depends on. If an annual report cites hourly means, the raw readings behind them may need to survive as long as the report is defensible — which can be longer than the raw retention window. Resolve this when writing the policy, not when asked.

Parquet files partitioned so finely that metadata dominates. Hive partitioning by day and by device produces millions of tiny files where a query spends its time listing rather than reading. Partition by year and month only, and let row groups do the rest.

Compression applied to a partition still receiving writes. TimescaleDB compression makes a chunk effectively read-only; late data then fails to insert. Compress only past the late-arrival grace period.


Integration

Storage sits at the end of the pipeline and its layout constrains everything upstream and downstream. Ingest writes into the current partition, so its throughput depends on the index choice made here. Spatial interpolation reads hourly aggregates rather than raw readings, which is what makes a daily surface tractable. And GeoJSON and QGIS export consumes the same aggregates, so a change to the bucket definition changes every published product at once.

The three guides below cover the parts with the most operational risk: continuous aggregates, the retention policy, and the Parquet archive tier.


FAQ

How large does a sensor archive actually get?

Multiply sensors by metrics by readings per day. Two hundred sensors reporting four metrics every minute is 1.15 million rows a day — 420 million a year, and roughly 60 GB in a row store with indexes. That is comfortably within one PostgreSQL instance, which is why most environmental networks never need anything more exotic than good partitioning.

Monthly or daily partitions?

Daily when you drop data daily or query single days; monthly otherwise. The constraint is partition count: PostgreSQL plans fine with a few hundred partitions and degrades noticeably past a couple of thousand, so seven years of daily partitions is a planning problem where monthly is not.

Do I still need raw readings after computing hourly aggregates?

Usually yes, for a bounded period. Reprocessing after a calibration correction needs the raw values, and so does any audit of how a published figure was derived. Keep raw data for the period in which reprocessing is plausible — one to two years is typical — then keep aggregates indefinitely.

Is a purpose-built time-series database worth it?

For most environmental networks, no. A TimescaleDB extension on the PostgreSQL you already run gives you partitioning, continuous aggregates and compression while keeping PostGIS in the same database, which matters because almost every query here is both temporal and spatial. Reach for a separate system only when the spatial half stops being needed.

What retention does a regulator actually require?

It varies by jurisdiction and by what the data supports, and it is a question for whoever owns the reporting obligation rather than for the pipeline. What the pipeline owes is a policy that names each data class, its minimum retention and its basis — and a job that enforces it, so retention is a property of the system rather than of whoever last remembered to run a delete.


Articles in This Section

Designing a Retention Policy for High-Frequency Telemetry

Write a retention policy for environmental sensor data that survives an audit — classifying data by what it supports, setting minimums from obligations rather than disk cost, and enforcing it with an interlocked, reversible job.

Read guide

Downsampling with TimescaleDB Continuous Aggregates

Precompute hourly and daily environmental sensor rollups that stay correct when late data arrives — continuous aggregate definitions, refresh policies, and the real-time view that hides the boundary between materialized and live rows.

Read guide

Writing Hive-Partitioned Parquet for Sensor Archives

Archive environmental sensor readings to columnar files that stay queryable — partition layout, row-group sizing, compression choice, schema evolution and the verification step that must run before anything is deleted.

Read guide