Time-Series Data Modeling: Schemas for Metrics, IoT, and Events at Scale
Time-series data has a personality unlike anything else youโll model. It arrives relentlessly and in order, it is almost never updated after the fact, recent data is read constantly while old data is barely touched, and it accumulates until it becomes โ by volume โ the biggest dataset your company owns. Model it like ordinary transactional data and you get a table thatโs unmanageable within a year. Model it with its personality in mind and even billions of points stay fast and affordable.
This post covers the schema decisions that matter: structure (tags vs. fields), the cardinality trap, partitioning, and the retention/downsampling lifecycle. It applies whether youโre on a dedicated engine (TimescaleDB, InfluxDB, Prometheus), a warehouse (Snowflake, BigQuery), or plain PostgreSQL.
What Makes Time-Series Different
Four properties drive every design decision:
- Append-only. Sensor readings and metrics record what happened; you insert, you donโt update. This frees the storage engine to optimize purely for ingest and range scans.
- Time-windowed reads. Nearly every query has a time predicate: โlast hour,โ โthis week vs. last week.โ Nobody asks for a random single reading from eight months ago.
- Recency bias. The last 24 hours might serve 95% of reads. Data value decays โ but rarely to zero, because trend analysis needs history.
- Volume. A modest fleet of 10,000 devices reporting every 10 seconds generates 86 million rows a day. Decisions that are harmless at web-app scale (a few extra bytes per row, an unpartitioned table) become budget line items here.
The Anatomy of a Well-Modeled Point
Every time-series record decomposes into three parts, and keeping them straight is the core skill:
- Timestamp โ when it happened. Store in UTC, always; convert at display time. Decide precision (seconds? milliseconds?) deliberately โ itโs your finest-grained truth forever after.
- Tags (dimensions) โ the identity of the series: which device, which region, which endpoint. Tags are what you filter and group by, and theyโre indexed.
- Fields (measures) โ the actual measurements: temperature, latency, count. Fields are what you aggregate, and theyโre typically not indexed.
measurement: device_metricstags: device_id=dev-4411, site=pune-plant, model=T300fields: temperature=71.2, vibration=0.03, battery=87time: 2026-07-06T09:15:00ZThe tag/field distinction sounds like engine trivia (it comes from InfluxDBโs vocabulary) but it encodes a universal design question: what defines a series, and what varies within it? Get it wrong in either direction and you pay:
- A measurement modeled as a tag (e.g.,
temperature=71.2as a tag) creates a new โseriesโ per unique value โ millions of them. - An identity modeled as a field (e.g.,
device_idas a field) means filtering to one device scans everything.
The Cardinality Trap
The most common way time-series systems die is tag cardinality explosion. The number of distinct series equals the product of distinct values across tags. 10,000 devices ร 50 sites ร 20 models is fine (the series count is bounded by devices anyway). But add one careless tag โ request_id, user_id, a container ID that changes on every deploy โ and you mint a new series per request or per pod. Index structures balloon, memory usage climbs, and queries crawl.
Rules of thumb:
- Tags should be bounded, low-to-medium cardinality identities (device, host, region, status code).
- Unbounded values (user IDs, request IDs, URLs with parameters) belong in fields โ or in a different system entirely (thatโs event data; ship it to the warehouse).
- Before adding a tag, estimate:
current series count ร new tag's distinct values. If the result makes you pause, donโt.
Partition by Time โ Always
Whatever the platform, the physical layout that makes time-series manageable is time-based partitioning: the table is split into chunks (hourly, daily, monthly) so that:
- Queries touching โthe last 6 hoursโ scan only those chunks โ partition pruning turns terabytes into megabytes.
- Retention becomes
DROP PARTITIONโ instant and free โ instead of aDELETEthat grinds for hours and bloats the table. - Old partitions can be compressed or moved to cheap storage independently.
In TimescaleDB this is automatic (create_hypertable), in vanilla PostgreSQL itโs declarative partitioning, in BigQuery itโs a partitioned table on the timestamp column, and in Parquet-based lakes itโs directory layout (/year=2026/month=07/day=06/). Pair time partitioning with ordering or clustering on your primary tag (device, host) so โthis device, this weekโ reads a thin, sorted slice.
-- TimescaleDB: the canonical shapeCREATE TABLE readings ( time TIMESTAMPTZ NOT NULL, device_id TEXT NOT NULL, temperature DOUBLE PRECISION, battery DOUBLE PRECISION);SELECT create_hypertable('readings', 'time');CREATE INDEX ON readings (device_id, time DESC);The Lifecycle: Raw โ Downsampled โ Gone
Hereโs the insight that separates sustainable time-series architectures from doomed ones: you donโt need raw resolution forever โ you need it recently. Nobody debugging a January incident in December needs 10-second granularity; they need hourly trends. So mature designs run a resolution ladder:
Each rollup stores aggregates per bucket โ and which aggregates you keep is a modeling decision you must make up front, because you canโt recompute what you didnโt keep. The safe set: min, max, sum, count, and a percentile sketch if latency matters. Store sum and count rather than avg โ averages of averages are wrong, but sums and counts re-aggregate correctly to any coarser bucket. (This is the same โstore facts, not conclusionsโ principle from the fundamentals post, applied at scale.)
Engines increasingly automate the ladder: TimescaleDBโs continuous aggregates plus retention policies, InfluxDB tasks, Prometheus recording rules with remote long-term storage. The automation is pleasant; the decisions โ resolutions, retention windows, which aggregates survive โ remain yours.
Practical Details That Bite Later
- Late and out-of-order data. Devices go offline and backfill. Decide your tolerance window (e.g., accept up to 48 hours late), and make downsampling jobs re-process windows that received late arrivals.
- Counter resets. Cumulative counters (bytes sent, requests served) reset when processes restart. Store the raw counter and compute rates with reset-aware functions at query time โ donโt โfixโ the data at ingest.
- Gaps are information. A missing reading might mean โdevice offlineโ โ often the most important signal you have. Donโt interpolate at storage time; keep gaps and let queries decide (
time_bucket_gapfilland friends). - Wide vs. narrow. One row per (time, device) with a column per metric (โwideโ) is compact and fast when metrics arrive together; one row per (time, device, metric_name, value) (โnarrowโ) handles heterogeneous fleets where devices report different metrics. Warehouses lean wide; monitoring systems lean narrow. Choose based on how uniform your sources are.
A Design Checklist
- Timestamp in UTC, precision chosen deliberately.
- Tags = bounded identities; fields = measurements; no unbounded tag anywhere.
- Estimated series cardinality written down and sanity-checked.
- Time partitioning enabled from day one, with clustering on the main tag.
- Retention ladder defined: raw window, rollup resolutions, kept aggregates (
sum+count, never justavg). - Policy for late data and counter resets documented.
Time-series modeling is unusual in how predictable the failure modes are: cardinality explosions, unpartitioned tables, and keep-everything-raw-forever plans account for nearly all of the wreckage. Design around those three on day one, and the biggest dataset you own becomes, oddly, one of the easiest to live with.