Designing a Retention Policy for High-Frequency Telemetry

A retention policy that exists only as a DELETE statement in a cron job is not a policy — it is a liability with a schedule. The questions it must answer are not technical: which data supports a published figure, how long that figure must remain defensible, what a regulator or a funder requires, and what happens to the evidence when a sensor’s readings are excluded from a report. This guide turns those questions into a policy document and an enforcement job with an interlock, as part of time-series storage partitioning and retention.

Classify by What the Data Supports

Retention decisions get much easier once data is grouped by the claim it underwrites rather than by its table.

Data behind a published figure. If an annual report cites a site mean, the readings behind it have to survive as long as that report is something anyone might question. This is usually the longest requirement in the policy and the one most often overlooked, because the connection between a number in a PDF and a partition in a database is not recorded anywhere.

Data that could change a published figure. Raw readings within the window where reprocessing is plausible — after a calibration correction, a decoder fix, or a registry error. Once you are no longer willing to restate, this class can move to archive.

Diagnostic data. Rejected readings, out-of-range values, hardware-failure periods. Valuable for fault diagnosis and for justifying exclusions, worthless analytically, and highly compressible.

Derived data. Aggregates, quality scores, interpolated surfaces. Reproducible from raw data plus code plus registry — but only while all three survive. Once raw data is archived, an aggregate is effectively primary.

That last observation is the one that changes designs: the moment you delete raw readings, your hourly aggregates stop being a cache and become the record.

Classify by the claim the data underwrites Matrix of four data classes against what they support, who owns the retention decision, and what is lost if they are deleted early. Classify by the claim the data underwrites Supports Owner If deleted early Behind a published figure an annual report the reporting authority the figure is indefensible Could change a figure restatement the data owner no reprocessing possible Diagnostic fault analysis the platform team exclusions unjustifiable Derived dashboards the platform team regenerable — until raw goes
The fourth row stops being regenerable the moment the first row is archived, which is the transition most retention plans miss.

Production-Ready Implementation

Express the policy as data, not as code, so it can be reviewed by people who do not read SQL:

# python 3.11 · psycopg[binary]==3.1.18
from __future__ import annotations

from dataclasses import dataclass


@dataclass(frozen=True)
class RetentionRule:
    """One retention rule, with the basis that justifies it.

    `basis` is not documentation — it is the field an auditor reads, and a rule
    without one is a number somebody guessed.
    """

    data_class: str
    hot_days: int                 # in the queryable row store
    archive_years: int | None     # in columnar object storage; None = delete, no archive
    basis: str


POLICY = (
    RetentionRule("raw_readings", hot_days=548, archive_years=10,
                  basis="reprocessing window (18 months) + long-term reanalysis"),
    RetentionRule("rejected_readings", hot_days=365, archive_years=2,
                  basis="fault diagnosis and justification of exclusions"),
    RetentionRule("hourly_aggregates", hot_days=36_500, archive_years=None,
                  basis="primary analytical record once raw data is archived"),
    RetentionRule("daily_aggregates", hot_days=36_500, archive_years=None,
                  basis="annual and trend reporting"),
    RetentionRule("registry_history", hot_days=36_500, archive_years=None,
                  basis="without it no historical reading is interpretable"),
    RetentionRule("interpolated_surfaces", hot_days=90, archive_years=None,
                  basis="reproducible from aggregates plus method"),
)

Enforcement needs an interlock: nothing is deleted that has not been archived and verified.

import psycopg


def enforce(conn: psycopg.Connection, rules=POLICY, *, dry_run: bool = True) -> list[dict]:
    """Apply retention. Returns what was (or would be) dropped, always logging it.

    The archive interlock is the safety property: a partition is dropped only if
    archive_log records a VERIFIED export. A date-only condition will eventually
    delete a partition whose archive failed weeks earlier.
    """
    actions = []
    for rule in rules:
        if rule.hot_days >= 36_500:
            continue                       # kept indefinitely
        with conn.cursor() as cur:
            cur.execute(
                """
                SELECT partition_name, row_count, archive_ref
                FROM   archive_log
                WHERE  data_class = %s
                  AND  partition_end < now() - make_interval(days => %s)
                  AND  (%s::bool IS FALSE OR verified_at IS NOT NULL)
                ORDER  BY partition_end
                """,
                (rule.data_class, rule.hot_days, rule.archive_years is not None),
            )
            for name, rows, ref in cur.fetchall():
                actions.append({"rule": rule.data_class, "partition": name,
                                "rows": rows, "archive": ref, "basis": rule.basis})
                if not dry_run:
                    cur.execute(f'ALTER TABLE reading DETACH PARTITION "{name}"')
                    cur.execute(f'DROP TABLE "{name}"')
                    cur.execute(
                        "INSERT INTO retention_log (partition_name, data_class, rows_dropped, "
                        "archive_ref, basis, dropped_at) VALUES (%s, %s, %s, %s, %s, now())",
                        (name, rule.data_class, rows, ref, rule.basis),
                    )
        if not dry_run:
            conn.commit()
    return actions

Running with dry_run=True on a schedule and mailing the result is worth more than any test: a human sees what is about to disappear, monthly, before it does.

Nothing is deleted that has not been verified Flow diagram of the retention job: select partitions past their window, require a verified archive record, drop the partition, and log what was dropped with its authorising rule. Nothing is deleted that has not been verified Select by age per data class from the policy Require verified archive checksum recorded the interlock Detach and drop metadata operation space reclaimed Log the deletion rows, basis, archive ref compliance evidence A date-only condition eventually deletes a partition whose archive failed silently three weeks earlier.
Run it in dry-run mode monthly and mail the result — a human seeing the list before it disappears is worth more than any test.

Parameter Tuning Guide

Data class Hot retention Archive Storage per year (200×4 network)
Raw readings 18 months 10 years, Parquet 40 GB hot / 5 GB archived
Rejected readings 12 months 2 years 1.2 GB / 0.1 GB
Hourly aggregates indefinite 0.9 GB
Daily aggregates indefinite 0.04 GB
Quality scores with raw with raw included above
Registry history indefinite < 0.01 GB
Interpolated rasters 90 days on demand 12 GB / regenerated
Obligation type Typical floor Who owns the answer
Regulatory air quality reporting 5–10 years the reporting authority
Research grant data management plan 5–10 years after project end the principal investigator
Planning or permit evidence life of the permit + appeal period legal
Internal operations 1–2 years the platform team
Public open-data commitment as published the data owner

The right-hand column is the point of the table. None of these numbers is a pipeline decision, and a policy that does not name an owner per row is a policy the platform team invented.

Where a year of storage actually goes Horizontal bar chart of annual storage by data class for a 200-sensor network, showing raw readings dominating and every other class being small enough to keep indefinitely. Where a year of storage actually goes 200 sensors · 4 metrics · 1 min cadence Raw readings (hot) 40 GB/year Raw readings (archived) 3.5 GB/year Rejected readings 1.2 GB/year Hourly aggregates 0.9 GB/year Daily aggregates 0.04 GB/year Registry history 0.01 GB/year
Only the top bar is large enough for cost to enter the decision. Everything below it should be kept because deleting it saves nothing and costs interpretability.

Verification and Testing

# python 3.11 · pytest==8.2.0
def test_nothing_is_dropped_without_a_verified_archive(conn):
    """The interlock: an unverified archive must block deletion."""
    seed_partition(conn, name="reading_2024_01", data_class="raw_readings",
                   archived=True, verified=False)
    actions = enforce(conn, dry_run=True)
    assert not any(a["partition"] == "reading_2024_01" for a in actions)


def test_indefinite_classes_are_never_selected(conn):
    seed_partition(conn, name="hourly_2019", data_class="hourly_aggregates",
                   archived=False, verified=False, age_days=3000)
    assert not any(a["rule"] == "hourly_aggregates" for a in enforce(conn, dry_run=True))


def test_every_deletion_is_logged_with_its_basis(conn):
    seed_partition(conn, name="reading_2023_01", data_class="raw_readings",
                   archived=True, verified=True, age_days=900)
    enforce(conn, dry_run=False)
    with conn.cursor() as cur:
        cur.execute("SELECT basis, archive_ref FROM retention_log WHERE partition_name = %s",
                    ("reading_2023_01",))
        basis, ref = cur.fetchone()
    assert basis and ref

Run a quarterly restore drill as well: pick an archived partition at random, restore it to a scratch table, and compare a checksum against the retention log’s recorded row count. An archive nobody has ever read is a hypothesis.

Gotchas

Deleting on a date with no archive interlock. The failure is silent and total: the archive job failed three weeks ago, the retention job did not know, and the data is gone.

A policy with no owner per rule. When someone asks why raw data is kept for eighteen months, “it seemed reasonable” is not an answer that survives an audit. Name the obligation and the person.

Forgetting that aggregates become primary. Once raw data is archived to object storage, the hourly aggregate is what everyone actually queries. It needs the same backup treatment as any primary record, not the treatment of a cache.

Retention that ignores the registry. Deleting registry history to save space makes every archived reading uninterpretable — you will have kept the terabytes and thrown away the kilobytes that explain them.


FAQ

What should drive the retention period — cost or obligation?

Obligation sets the floor and cost sets the ceiling, and the two rarely conflict as much as people expect. Aggregates are small enough to keep indefinitely; only raw high-frequency readings are large enough for cost to matter, and their retention is usually decided by how far back reprocessing is plausible.

Can I delete readings that were flagged as bad?

After a diagnostic window, yes — but not immediately. Rejected readings are the evidence for why a sensor was excluded from a report, and they are what a field team uses to diagnose a recurring fault. Twelve months is a common compromise, and they compress extremely well because they are repetitive.

How do I prove that data was deleted according to policy?

Log every deletion: what was dropped, which policy rule authorised it, the row count, and the archive reference for the copy that survives. A retention job that deletes silently cannot demonstrate compliance, which for a regulated dataset is nearly as bad as not complying.