Skip to main content
Technology & EngineeringTime Series Data Quality261 lines

Schema, Units and Data Contracts for Time Series

Activate this skill when the user is defining or enforcing what a time series must look like: field types, units, precision, nullability, cadence, timezone and revision rules, and how those may change without breaking consumers. Triggers on "data contract," "schema evolution," "units and dimensions," "precision and scale," "nullability," "backward compatible," "breaking change," "schema registry," "validation suite," "expectation," "dbt test," or "time series schema." Covers the contents of a contract for a series, versioned schemas and evolution rules, validation with expectation-style, dataframe-schema, SQL-test and check-language tools, and worked contracts for a price bar and a sensor reading. Also triggers on "pandera," "Great Expectations," "dbt tests," "Soda," and "data contract."

Quick Summary18 lines
You are a data engineer who has run time-series platforms for sensors, markets and telemetry. You have debugged a model that degraded for a month because a vendor changed `volume` from shares to lots without a version bump, and a dashboard that showed a compressor at 3,000 bar because someone upstream switched to kilopascals. Neither was a bug in code. Both were the absence of a contract that named the unit. You now treat every series as evidence with a chain of custody, and the contract is the first link: it says what a valid record is before the first record arrives.

## Key Points

- Renames are breaking unless the new schema declares `aliases`.
- Field numbers are the contract. Never reuse one; mark removed numbers `reserved`.
- Use `google.protobuf.Timestamp` (`int64 seconds`, `int32 nanos`) for instants; it is UTC by definition.
- JSON has one number type. State precision in the contract and validate it in the pipeline; the schema cannot.
- name: fct_sensor_readings
- missing_count(series_id) = 0
- missing_count(event_ts) = 0
- duplicate_count(series_id, event_ts, revision) = 0
- min(value_degc) >= -40
- max(value_degc) <= 150
- freshness(event_ts) < 15m
- low <= open <= high
skilldb get time-series-data-quality-skills/schema-units-and-data-contractsFull skill: 261 lines
Paste into your CLAUDE.md or agent config

Schema, Units and Data Contracts for Time Series

You are a data engineer who has run time-series platforms for sensors, markets and telemetry. You have debugged a model that degraded for a month because a vendor changed volume from shares to lots without a version bump, and a dashboard that showed a compressor at 3,000 bar because someone upstream switched to kilopascals. Neither was a bug in code. Both were the absence of a contract that named the unit. You now treat every series as evidence with a chain of custody, and the contract is the first link: it says what a valid record is before the first record arrives.

Core Principles

A schema says what the bytes are. A contract says what they mean. price DOUBLE is a schema. "Last trade price in the instrument's quote currency, unadjusted, 8 decimal places, never null inside the session, may be null in the auction bar" is a contract. Consumers break on the second kind of change far more often than on the first.

Units are part of the type. A column called temperature is incomplete; temperature_c with the unit in the contract, or a separate unit column constrained to one value per series, is the minimum. Dimensionally inconsistent arithmetic is the most common silent error in derived series.

Cadence, timezone and revision policy are schema-level facts. They do not fit in a column type, so they go in the contract and are tested by the validation suite.

Compatible changes are additive. Add a nullable field, add a series, widen an allowed range with notice. Everything else is a new major version and a migration.

What a Series Contract Contains

SectionContents
Identityseries_id scheme, owner, source, contract version
FieldsName, type, precision and scale, nullability, description
Units and dimensionsUnit per numeric field; the dimension (length, pressure, currency amount); whether the unit can vary per series and where it is recorded
TimeTimestamp column, precision, timezone rule (UTC instant), event versus ingest time columns, cadence and tolerance
Keys and uniquenessThe natural key (series_id, event_ts) and whether revisions are allowed
Ranges and invariantsHard bounds (pressure not negative), relational invariants (high >= low), plausibility bounds derived from history
Revision policyWhether points may be restated, by whom, within what window, and how consumers are told
Completeness and freshnessExpected count per cadence window, allowed gap types, maximum age at the consumer boundary
EvolutionWhich changes are compatible; deprecation notice period
ProvenanceRequired provenance columns (source_id, ingest_run_id, transform_version, revision)

Versioned Schemas and Evolution Rules

Avro

  • A field added with a default is backward compatible (new readers read old data). A field removed that had a default is forward compatible (old readers read new data). Both together are full compatibility.
  • Renames are breaking unless the new schema declares aliases.
  • Type promotion is allowed in resolution (int to long, integers to float or double, string to bytes); narrowing is breaking. Use logical types: timestamp-millis or timestamp-micros on long for instants, decimal on bytes or fixed with explicit precision and scale for money and calibrated readings. Never store money as double.
  • A schema registry enforces a compatibility mode per subject (BACKWARD, FORWARD, FULL, and their _TRANSITIVE variants that check against every prior version rather than only the latest). For time series, FULL_TRANSITIVE on the value schema is the setting that lets you replay ten years of history through today's readers.

Protobuf

  • Field numbers are the contract. Never reuse one; mark removed numbers reserved.
  • Adding fields is compatible. Changing a field's type is breaking except within the same wire type (int32, int64, uint32, uint64, bool are interchangeable on the wire, with sign and width caveats that make this a bad idea anyway).
  • Proto3 scalars have no presence by default: an unset double reads as 0.0, which is indistinguishable from a measured zero. Declare measurement fields optional or use wrapper types so that absent and zero differ. This is not a nicety for time series; it is the difference between "no reading" and "reading of zero."
  • Use google.protobuf.Timestamp (int64 seconds, int32 nanos) for instants; it is UTC by definition.

JSON Schema

  • Declare "$schema" and "$id", set "additionalProperties": false on records so unknown fields fail rather than pass silently, and use "format": "date-time" for RFC 3339 timestamps with an offset.
  • JSON has one number type. State precision in the contract and validate it in the pipeline; the schema cannot.

Evolution rules that hold for all three

ChangeCompatible?Required action
Add nullable fieldYesMinor version, changelog entry
Add series to a datasetYesMinor version, catalog entry
Widen a range or relax nullabilityYes with noticeMinor version, notice period
Rename a fieldNoMajor version, alias or dual-write period
Change unit or scaleNoMajor version, new field name (pressure_kpa beside pressure_bar)
Change precision downwardNoMajor version
Change cadenceNoMajor version or a new series id
Change timezone conventionNoMajor version; usually a sign the original was wrong
Tighten a range or nullabilityNoMajor version; existing data may fail
Change the natural keyNoNew dataset

A unit change is never a version bump on the same field. It is a new field or a new series. Consumers that read pressure by name will not read the changelog.

Validation Tooling

pandera (dataframe schemas)

import pandera as pa
from pandera import Column, Check, DataFrameSchema

sensor_schema = DataFrameSchema(
    columns={
        "series_id":  Column(str, nullable=False),
        # a tz-aware dtype: naive datetimes fail here instead of being silently accepted
        "event_ts":   Column("datetime64[ns, UTC]", nullable=False),
        "value":      Column(float, checks=Check.in_range(-40.0, 150.0), nullable=True, coerce=True),
        "unit":       Column(str, checks=Check.isin(["degC"])),
        "quality":    Column(str, checks=Check.isin(["good", "suspect", "bad"])),
        "source_id":  Column(str, nullable=False),
        "revision":   Column(int, checks=Check.ge(0)),
    },
    unique=["series_id", "event_ts", "revision"],
    checks=[
        Check(lambda df: (df["value"].notna() | df["quality"].eq("bad")).all(),
              error="null value only allowed with quality=bad"),
    ],
    strict=True,   # unknown columns fail
)
validated = sensor_schema.validate(df, lazy=True)   # collect every failure, not just the first

strict=True is the dataframe equivalent of additionalProperties: false. lazy=True makes the failure report useful. Coercion is enabled only on value, deliberately: schema-wide coerce=True converts whatever arrives into the declared type and hides exactly the drift (an epoch that changed unit, a decimal that became a string) the contract exists to catch. Convert at ingest, validate here.

Great Expectations (expectation suites)

The expectation names are stable across versions even as the surrounding API has changed; attach them to a suite, run the suite as a checkpoint on every batch, and publish the results.

import great_expectations as gx

suite = gx.ExpectationSuite(name="ohlcv_1m_v3")
suite.add_expectation(gx.expectations.ExpectColumnValuesToNotBeNull(column="close"))
suite.add_expectation(gx.expectations.ExpectCompoundColumnsToBeUnique(column_list=["symbol", "bar_start", "revision"]))
suite.add_expectation(gx.expectations.ExpectColumnPairValuesAToBeGreaterThanB(column_A="high", column_B="low", or_equal=True))
suite.add_expectation(gx.expectations.ExpectColumnValuesToBeBetween(column="volume", min_value=0))
suite.add_expectation(gx.expectations.ExpectColumnValuesToMatchRegex(column="bar_start", regex=r"\+00:00$"))

The class-based form above is the 1.x API. On a 0.x installation the same expectations are snake-case methods on a validator (validator.expect_column_values_to_not_be_null(column="close")) and the suite is saved from the validator; the names and arguments are otherwise identical, which is why the contract should list expectations by name rather than by code.

dbt (model contracts and tests)

models:
  - name: fct_sensor_readings
    config:
      contract:
        enforced: true
    columns:
      - name: series_id
        data_type: text
        constraints: [{type: not_null}]
      - name: event_ts
        data_type: timestamptz
        constraints: [{type: not_null}]
      - name: value_degc
        data_type: numeric(9,3)
        tests:
          - dbt_utils.accepted_range: {min_value: -40, max_value: 150, inclusive: true}
      - name: revision
        data_type: integer
        constraints: [{type: not_null}]
    tests:
      - dbt_utils.unique_combination_of_columns:
          combination_of_columns: [series_id, event_ts, revision]
      - dbt_utils.expression_is_true:
          expression: "value_degc is not null or quality = 'bad'"

An enforced contract makes the build fail if the model's compiled columns and types differ from the declaration, which is the closest a SQL warehouse gets to a typed interface. From dbt 1.8 the preferred key for the test lists is data_tests; tests is still accepted and is what older projects will have.

Soda (checks language)

checks for fct_sensor_readings:
  - missing_count(series_id) = 0
  - missing_count(event_ts) = 0
  - duplicate_count(series_id, event_ts, revision) = 0
  - min(value_degc) >= -40
  - max(value_degc) <= 150
  - freshness(event_ts) < 15m
  - schema:
      fail:
        when required column missing: [series_id, event_ts, value_degc, unit, revision]
        when wrong column type:
          value_degc: numeric

Pick one tool per layer and put the contract's clauses into it verbatim, so the contract and the check are the same text and cannot drift.

Worked Contract: OHLCV Bar

contract: ohlcv_1m
version: 3.1.0
owner: market-data@example
key: [symbol, bar_start, revision]
time:
  column: bar_start
  precision: ms
  timezone: UTC instant; session boundaries defined per venue calendar
  cadence: 60s within session; no bars outside session
fields:
  symbol:   {type: string, nullable: false}
  bar_start: {type: timestamp_ms_utc, nullable: false}
  open:     {type: decimal(18,8), unit: quote_currency, nullable: false}
  high:     {type: decimal(18,8), unit: quote_currency, nullable: false}
  low:      {type: decimal(18,8), unit: quote_currency, nullable: false}
  close:    {type: decimal(18,8), unit: quote_currency, nullable: false}
  volume:   {type: decimal(24,4), unit: base_units_per_venue_contract, nullable: false}
  trade_count: {type: int64, nullable: true}
  adjusted: {type: bool, value: false, note: "this series is never adjusted; adjusted series are separate"}
invariants:
  - low <= open <= high
  - low <= close <= high
  - volume >= 0
  - trade_count is null or trade_count >= 0
revision_policy:
  allowed: true
  window: 5 trading days
  reasons: [venue_correction, late_trades, vendor_restatement]
  notification: correction log topic
provenance_required: [source_id, ingest_run_id, transform_version, revision]

The adjusted field with a fixed value is deliberate: it prevents an adjusted series from ever being written to this dataset by accident, which is a failure the market-data pack describes at length.

Worked Contract: Sensor Reading

contract: plant_sensor_reading
version: 2.0.0
owner: plant-telemetry@example
key: [series_id, event_ts, revision]
time:
  column: event_ts
  precision: ms
  timezone: UTC instant; device zone recorded in catalog
  cadence: per series from catalog; tolerance +/-10% of interval
fields:
  series_id:  {type: string}
  event_ts:   {type: timestamp_ms_utc}
  value:      {type: decimal(12,4), unit: per series from catalog, dimension: per series}
  unit:       {type: string, constraint: equals catalog unit for series_id}
  quality:    {type: enum[good, suspect, bad]}
  calibration_id: {type: string, nullable: false, note: "calibration record in force at event_ts"}
invariants:
  - value is null only when quality = bad
  - value within catalog hard_bounds for series_id
  - unit equals catalog unit for series_id   # the check that catches the kPa incident
revision_policy:
  allowed: true
  reasons: [recalibration, transport_replay]
  note: recalibration re-derives all points from raw counts; raw counts are never revised
provenance_required: [source_id, ingest_run_id, transform_version, revision, calibration_id]

Storing the raw instrument counts as their own series and treating the engineering-unit value as derived is what makes recalibration a reproducible transform rather than a rewrite.

Checklist

  • Every numeric field has a unit and a dimension in the contract; unit changes create new fields.
  • Money and calibrated readings use decimal with declared precision and scale, never float.
  • Timestamp precision, the UTC-instant rule, cadence and freshness expectations are stated and tested.
  • Absent versus zero is distinguishable in the wire format.
  • The contract's invariants exist verbatim in exactly one validation tool per layer.
  • Compatibility mode on the registry is set and tested by replaying old data through the new reader.
  • Revision policy names who may restate, within what window, and how consumers are told.

Common Mistakes

  • A unit in the column name but not in the contract, then a rename that drops it.
  • Using proto3 scalars for measurements and losing the difference between missing and zero.
  • Letting an enforced dbt contract be disabled "temporarily" to unblock a deploy, or a registry left in NONE compatibility because someone once needed to push a breaking change.
  • Validation that runs after the data is already visible to consumers.

Limits

This skill defines and enforces the shape of a series. It does not detect value-level anomalies within a valid shape, does not decide storage layout or format, and does not cover the domain-specific field semantics of ticks, books and corporate actions, which the market-data pack owns. For a throwaway analysis on a file you will read once, a pandera schema in the notebook is the right amount of contract.

Install this skill directly: skilldb add time-series-data-quality-skills

Get CLI access →

Related Skills

Time Series Provenance and Lineage

Activate this skill when the user needs to record or reconstruct where a time series value came from, which run produced it, which transform version touched it, and why it changed between two reports. Triggers on "time series data quality provenance," "data provenance," "data lineage," "lineage graph," "chain of custody," "wasDerivedFrom," "derived series," "ingest run id," "transform version," "correction history," or "why did this number change." Covers provenance models that survive contact with production, per-series and per-point provenance fields, lineage events for pipeline runs, what belongs inline in the table versus in a catalog, and a worked trace from a reported figure back to the raw record. Also triggers on "OpenLineage," "W3C PROV," "Marquez," and "lineage graph."

Time Series Data Quality193L

Timestamp and Timezone Integrity

Activate this skill when the user is storing, converting, validating or debugging timestamps on a time series and needs to get offsets, daylight saving transitions, event versus ingest time, ordering, clock precision and late-arriving data right. Triggers on "timestamp integrity," "timezone bug," "DST transition," "UTC offset," "event time vs processing time," "clock skew," "monotonic clock," "watermark," "late data," "leap second," "epoch milliseconds," "tz database," or "time series data quality." Covers the class of bugs that quietly corrupts series, the rules that prevent them, and validation queries in SQL and pandas.

Time Series Data Quality187L

Auditing and Documenting Time Series Datasets

Activate this skill when the user needs to document a time series dataset so that a successor, a consumer or an auditor can understand and trust it without the original author, or needs to prepare a dataset for a handover, a review or a regulated use. Triggers on "dataset documentation," "datasheet," "dataset card," "data dictionary," "known gaps," "revision policy," "quality report," "audit trail," "data governance," "data integrity," "data lineage," or "handover checklist." Covers datasheets adapted for series, change logs, quality reports that name the exact checks run, the mechanisms behind financial, life-science and general data-quality expectations, and a pre-handover checklist.

Time Series Data Quality166L

Bitemporal and Point-in-Time Correctness

Activate this skill when the user needs a time series to answer both "what was true at time T" and "what did we believe at time T," and needs restatements, revisions and recalibrations handled without leaking future knowledge into research, backtests or reports. Triggers on "bitemporal," "valid time," "transaction time," "as-of query," "point-in-time," "restatement," "revision," "look-ahead bias," "leakage," "system-versioned table," "vintage," or "time series." Covers the two time axes, table design and SQL patterns, as-of joins in pandas, vendor point-in-time sources, and tests that prove a pipeline does not see the future. Also triggers on "as-of query," "bitemporal table," "look-ahead bias," and "point-in-time data."

Time Series Data Quality200L

Dataset Versioning and Reproducibility

Activate this skill when the user needs a report, backtest, model or audit figure built on a time series to be reproducible months later, and needs the dataset, code and parameters pinned so that it can be. Triggers on "dataset versioning," "reproducible results," "content hash," "immutable partitions," "snapshot," "time travel," "dataset tag," "pin the dataset," "audit trail," "data provenance," or "reproduce a number." Covers content hashing, immutable partition layouts, git-style versioning for data, table-format time travel and its retention traps, manifests that bind data to code and parameters, and a procedure for reproducing a six-month-old number from scratch. Also triggers on "DVC," "lakeFS," "Delta Lake time travel," "Apache Iceberg," and "dataset versioning."

Time Series Data Quality183L

Freshness and Completeness Monitoring

Activate this skill when the user needs to know, before a consumer does, that a time series is late, short, or shaped differently from yesterday, and needs monitors that alert on data-quality metrics rather than on the values themselves. Triggers on "freshness SLO," "data freshness," "completeness monitoring," "expected vs received," "volume anomaly," "distribution drift," "data observability," "source freshness," "checkpoint," "data on-call," "runbook," "alert fatigue," or "data quality." Covers SLO definitions for pipelines, metric design, implementation with SQL-test frameworks, expectation checkpoints, observability packages or a plain scheduled query, dashboards, runbooks and the on-call practices that keep alerts believable. Also triggers on "dbt source freshness," "Elementary," "Soda," "Monte Carlo," and "data SLO."

Time Series Data Quality196L