git clone https://github.com/Intense-Visions/harness-engineering
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/claude-code/db-temporal-data" ~/.claude/skills/intense-visions-harness-engineering-db-temporal-data-d1c92f && rm -rf "$T"
agents/skills/claude-code/db-temporal-data/SKILL.mdTemporal Data
Modeling when facts are true (valid-time), when they were recorded (transaction-time), or both (bitemporal), enabling time-travel queries and regulatory audit.
When to Use
- Insurance policies with effective dates that change over time
- Price histories where "what was the price on date X?" is a query requirement
- Employee role and salary changes tracked for HR reporting
- Regulatory requirements to reconstruct past states (financial compliance, audit)
- Any domain where historical accuracy matters and overwrites destroy information
Instructions
Key Concepts
Three temporal dimensions:
1. Valid-time (application time): When the fact is true in the real world. An employee salary might be effective 2024-01-01 to 2024-12-31. The database might record this fact on 2023-12-15 -- the valid time and the recording time differ.
2. Transaction-time (system time): When the row was stored in the database. Columns like
recorded_at and superseded_at track the database's knowledge. Transaction-time columns are never manually edited -- the system manages them.
3. Bitemporal: Both dimensions combined. Answers questions like "what did the system believe was true about time T, as of database time S?" Essential for late-arriving corrections in financial systems.
Slowly Changing Dimensions (SCD) types:
| Type | Strategy | History? |
|---|---|---|
| Type 1 | Overwrite the row | No |
| Type 2 | New row with date range | Yes |
| Type 3 | Previous/current columns | Partial |
| Type 6 | Hybrid (Type 1 + 2 + 3) | Yes |
SCD Type 2 is the most common for temporal data. In PostgreSQL, use range types:
CREATE EXTENSION IF NOT EXISTS btree_gist; CREATE TABLE employee_salaries ( id serial PRIMARY KEY, employee_id int NOT NULL, salary numeric(10,2) NOT NULL, valid_range tstzrange NOT NULL, EXCLUDE USING gist ( employee_id WITH =, valid_range WITH && ) );
The exclusion constraint prevents overlapping valid periods for the same employee -- the database enforces temporal integrity, not the application.
Worked Example
Insurance policy pricing with valid-time tracking:
-- Initial price: $500/month effective Jan 1 to Dec 31 INSERT INTO policy_prices (policy_id, monthly_rate, valid_range) VALUES (101, 500.00, '[2024-01-01, 2025-01-01)'); -- Price change mid-year: close the old range, insert new UPDATE policy_prices SET valid_range = '[2024-01-01, 2024-07-01)' WHERE policy_id = 101 AND valid_range @> '2024-06-15'::timestamptz; INSERT INTO policy_prices (policy_id, monthly_rate, valid_range) VALUES (101, 550.00, '[2024-07-01, 2025-01-01)');
Point-in-time query -- "what was the price on June 15?":
SELECT monthly_rate FROM policy_prices WHERE policy_id = 101 AND valid_range @> '2024-06-15'::timestamptz; -- Returns: 500.00
Bitemporal query -- "what did we think the price was on June 15, before the July correction?":
SELECT monthly_rate FROM policy_prices WHERE policy_id = 101 AND valid_range @> '2024-06-15'::timestamptz AND recorded_at < '2024-07-01'::timestamptz;
This is essential when auditors need to reconstruct what the system reported at a specific point in database history.
Anti-Patterns
- Using
as a substitute for temporal modeling.updated_at
only records the last change -- all history is lost. It cannot answer "what was true at time T?"updated_at - Overlapping valid periods without exclusion constraints. Two rows claiming the same employee had different salaries on the same date is corrupt data. Use exclusion constraints.
- Managing two timestamp columns manually instead of range types. Separate
andvalid_from
columns require application-level overlap checks. Range types with exclusion constraints push this to the database.valid_to - Using SCD Type 1 (overwrite) when audit trail is required. Overwriting destroys evidence. Use Type 2 or a separate audit table.
- NULL for "current" end date. Use range type upper-bound infinity instead:
-- the open upper bound means "still current" and works correctly with range operators.'[2024-07-01,)'
PostgreSQL Specifics
Range types (
tstzrange, daterange) provide built-in temporal operators:
-- contains a timestamp:@>valid_range @> '2024-06-15'::timestamptz
-- overlaps another range:&&range1 && range2
-- adjacent ranges:-|-range1 -|- range2
GiST index for range columns:
CREATE INDEX idx_prices_valid ON policy_prices USING gist (valid_range);
Exclusion constraints require the
btree_gist extension:
CREATE EXTENSION btree_gist; -- Prevents overlapping valid periods for the same policy EXCLUDE USING gist (policy_id WITH =, valid_range WITH &&)
SQL:2011 temporal support is partially implemented in PostgreSQL. System-versioned tables are not yet native -- use triggers or the
temporal_tables extension to approximate transaction-time tracking.
Details
Advanced Topics
SQL:2011 standard temporal syntax (
FOR SYSTEM_TIME AS OF) is supported in MariaDB and SQL Server but not yet in PostgreSQL. PostgreSQL approximates this with view-based patterns or the temporal_tables extension.
Gap detection in temporal data -- finding periods with no valid record:
SELECT upper(a.valid_range) AS gap_start, lower(b.valid_range) AS gap_end FROM policy_prices a JOIN policy_prices b ON a.policy_id = b.policy_id AND upper(a.valid_range) < lower(b.valid_range) WHERE NOT EXISTS ( SELECT 1 FROM policy_prices c WHERE c.policy_id = a.policy_id AND c.valid_range && tstzrange(upper(a.valid_range), lower(b.valid_range)) );
Temporal joins for combining two temporal tables:
SELECT e.name, s.salary, d.department_name FROM employees e JOIN employee_salaries s ON e.id = s.employee_id JOIN employee_departments d ON e.id = d.employee_id AND s.valid_range && d.valid_range;
Partitioning temporal tables by
valid_from enables archival of old time periods and efficient pruning of historical queries.
Engine Differences
MySQL lacks range types and exclusion constraints entirely. Temporal data in MySQL requires:
- Separate
andvalid_from DATETIME
columnsvalid_to DATETIME - Application-level overlap prevention (no database-enforced exclusion)
- Manual NULL handling for "current" records (no range infinity)
MySQL 8.0 does not support SQL:2011 temporal tables.
MariaDB 10.3+ is the notable exception -- it supports system-versioned tables (
WITH SYSTEM VERSIONING), making it the only major open-source engine with native transaction-time temporal support. MariaDB's temporal tables automatically track row history without triggers.
Real-World Case Studies
Financial compliance system tracking 10M account balances. Regulators require reconstructing the account balance at any historical date. SCD Type 2 implementation using
tstzrange with GiST index on (account_id, valid_range). Point-in-time queries ("what was balance on date X?") complete in 3ms. After an audit discovered late-arriving corrections (a deposit backdated to last month), the team added bitemporal columns to track both valid-time and transaction-time. This enabled the query "what did we report the balance was on March 15, before the April 2 correction?" -- critical for regulatory reconciliation.
Source
Process
- Read the key concepts to understand the three temporal dimensions and SCD types.
- Apply the correct temporal strategy based on whether you need valid-time, transaction-time, or bitemporal tracking.
- Verify that range types with exclusion constraints prevent overlapping periods and that point-in-time queries return correct results.
Harness Integration
- Type: knowledge -- this skill is a reference document, not a procedural workflow.
- No tools or state -- consumed as context by other skills and agents.
- related_skills: db-audit-trail, db-time-series, db-denormalization, microservices-event-sourcing
Success Criteria
- Temporal tables use range types with exclusion constraints (not manual timestamp pairs).
- SCD type is chosen based on business requirements (Type 2 for full history, Type 1 only when history is not needed).
- Point-in-time queries are verified to return correct results.
- GiST indexes are present on range columns.