Advanced Track
Temporal Tables & Time-Travel Queries
By the end of this lesson you'll be able to make the database keep a full, automatic history of every row — and then query "what did this look like last Tuesday?" with a single line. Temporal tables turn an ordinary table into a time machine you can rewind to any instant.
What You'll Learn
- ✓What a system-versioned temporal table is and why the DB keeps history for you
- ✓The difference between transaction-time (system) and valid-time (application)
- ✓Rewind a whole table with FOR SYSTEM_TIME AS OF
- ✓Pull a range of versions with BETWEEN and FROM..TO
- ✓Read a row's complete change history from the history table
- ✓Real use-cases: audit trails, compliance, and bug investigation
UPDATE/DELETE. This is an Advanced Track lesson. The in-browser editor lets you write and edit SQL; the FOR SYSTEM_TIME syntax shown here is real SQL Server / MariaDB code. To run it you'll need one of those engines (the free db-fiddle.com has MariaDB). Every example shows the expected result so you can check yourself either way.FOR SYSTEM_TIME syntax that SQL Server and MariaDB share.1. What Is a Temporal Table?
An ordinary table only remembers the present. When you UPDATE a row, the old value is overwritten and gone forever. A system-versioned temporal table fixes that: the database keeps every previous version of every row automatically, in a paired history table, so you can look up any past state on demand.
📓 Real-world analogy
A normal table is a whiteboard — erase and rewrite, and the old text is lost. A temporal table is a notebook: every edit starts a fresh line, and the previous lines stay on the page. You can always flip back to see what was written on any date.
"System-versioning" means the system (the DB engine), not you, manages the versioning. You keep writing normal INSERT, UPDATE, and DELETE statements; the engine quietly files away each superseded row for you.
2. Two Kinds of Time: Transaction-Time vs Valid-Time
"Temporal" is an umbrella term. There are two distinct clocks, and mixing them up is the most common beginner mistake:
- Transaction-time (a.k.a. system-time): when the database stored the row. This is what system-versioning tracks automatically with
valid_from/valid_tocolumns you never touch. It answers "what did our records say on date X?" - Valid-time (a.k.a. application-time): when a fact is true in the real world. You set these dates yourself — like a subscription that runs from June to December. It answers "what was actually in effect on date X?"
Create a system-versioned table
One CREATE statement gives you automatic history.
-- SQL Server: turn a normal table into a system-versioned temporal table.
-- The database will AUTOMATICALLY keep a full history of every change.
CREATE TABLE employees (
id INT PRIMARY KEY,
name VARCHAR(100),
salary DECIMAL(12,2),
department VARCHAR(50),
-- These two columns ARE the "system time" period.
-- The DB fills them in for you; you never write to them.
valid_from DATETIME2 GENERATED ALWAYS AS ROW START,
valid_to DA
...3. The History Table Mirrors the Main Table
When you switch on SYSTEM_VERSIONING, the engine creates (or attaches) a second table — here employees_history — with the exact same columns as employees. The two stay in lockstep: add a column to the main table and the history table gains it too.
The split is simple: employees holds only the current version of each row; employees_history holds every superseded version. Each row carries a valid_from and valid_to stamp marking the window during which it was the live row.
valid_to is set to the maximum date the engine supports (SQL Server uses 9999-12-31 23:59:59.9999999). That "max date" is how the engine flags a row as still active — there's no NULL to special-case.4. Watch One Row Change Over Time
Let's follow employee 7, Alice. You write three ordinary statements — one insert and two updates — and the engine records each version behind the scenes. You never write to the history table directly.
Three plain statements
Insert + two updates; history is saved automatically.
-- Every UPDATE/DELETE now saves the previous version to history.
-- You write plain SQL — no triggers, no extra steps.
-- 1) Alice is hired on a 50,000 salary
INSERT INTO employees (id, name, salary, department)
VALUES (7, 'Alice', 50000, 'Sales');
-- 2) Three months later she gets a raise to 60,000
UPDATE employees SET salary = 60000 WHERE id = 7;
-- 3) Later she moves to Marketing on 65,000
UPDATE employees SET salary = 65000, department = 'Marketing' WHERE id = 7;
-- The employees table
...After those three statements, the full timeline of Alice's row looks like this. Each version's valid_from/valid_to shows exactly when it was the live row, and the newest one runs to the engine's "max date":
Result — employee 7, every version (oldest first):
| name | salary | department | valid_from | valid_to |
|---|---|---|---|---|
| Alice | 50000 | Sales | 2024-01-01 09:00:00 | 2024-04-01 09:00:00 |
| Alice | 60000 | Sales | 2024-04-01 09:00:00 | 2024-08-01 09:00:00 |
| Alice | 65000 | Marketing | 2024-08-01 09:00:00 | 9999-12-31 23:59:59 |
The top two rows live in employees_history; the bottom row (ending 9999-12-31) is the current row in employees. Notice how each valid_to matches the next row's valid_from — there are no gaps.
5. Reading the Full History with FOR SYSTEM_TIME ALL
You don't have to query the history table by name. Add FOR SYSTEM_TIME ALL to a query on the main table and the engine transparently returns the current row plus every historical version, all with the same columns.
FOR SYSTEM_TIME ALL
Current row + every past version in one query.
-- Ask for the complete timeline of one row.
-- FOR SYSTEM_TIME ALL = current row + every historical version.
SELECT name, salary, department, valid_from, valid_to
FROM employees
FOR SYSTEM_TIME ALL
WHERE id = 7
ORDER BY valid_from;
-- Each row's valid_from / valid_to says exactly when that
-- version was the live one. The newest row ends at 9999-12-31
-- (a "max date" the engine uses to mean "still current").Result — 3 rows:
| name | salary | department |
|---|---|---|
| Alice | 50000 | Sales |
| Alice | 60000 | Sales |
| Alice | 65000 | Marketing |
Your Turn: time-travel to a date
Fill in the blanks to see Alice's row as it stood on 1 February 2024. The expected result is in the comments so you can check yourself.
🎯 Your Turn: FOR SYSTEM_TIME AS OF
Complete the AS OF query for employee 7.
-- 🎯 YOUR TURN — fill in the two blanks, then press "Try it Yourself"
-- Goal: see Alice's row as it was on 1 February 2024 (before any raise).
SELECT name, salary, department
FROM employees
FOR ___ AS OF '2024-02-01 09:00:00' -- 👉 the period name (two words, underscore)
WHERE id = ___; -- 👉 Alice's id
-- ✅ Expected: one row — Alice | 50000 | Sales
-- (on that date she was still the original hire)6. Time-Travel with FOR SYSTEM_TIME AS OF
This is the headline feature. FOR SYSTEM_TIME AS OF '<timestamp>' rewinds the entire table to a single instant and shows the version of each row that was live then. It's how you answer "what did this row look like last Tuesday?"
The query reads from employees as if it were a normal SELECT, but the engine silently swaps in the right historical row. Your application code barely changes — you just bolt FOR SYSTEM_TIME AS OF onto the table.
FOR SYSTEM_TIME AS OF
Rewind the table to a past instant.
-- Time-travel: see the table EXACTLY as it was at a past instant.
-- FOR SYSTEM_TIME AS OF '<timestamp>' rewinds the whole table.
SELECT name, salary, department
FROM employees
FOR SYSTEM_TIME AS OF '2024-04-01 09:00:00'
WHERE id = 7;
-- On 1 April 2024 Alice was still in Sales on 60,000,
-- so this returns the OLD values — not today's 65,000.
-- The DB transparently reads from history for you.Result — as of 2024-04-01 09:00:00:
| name | salary | department |
|---|---|---|
| Alice | 60000 | Sales |
At exactly 09:00 on 1 April the raise to 60,000 had just taken effect, but the move to Marketing hadn't — so you get the middle version, not today's 65,000.
7. Range Queries: BETWEEN and FROM..TO
Beyond a single instant, you can grab every version that was active across a window of time:
FOR SYSTEM_TIME BETWEEN 'a' AND 'b'— every version active at any point in the range, inclusive of both ends.FOR SYSTEM_TIME FROM 'a' TO 'b'— the same idea, but exclusive of the end instantb.FOR SYSTEM_TIME CONTAINED IN ('a', 'b')— only versions that opened and closed entirely inside the window.
BETWEEN (inclusive end) and FROM..TO (exclusive end) matters when a version flips over exactly on your boundary timestamp. Pick the one whose edge behaviour matches your report.BETWEEN & FROM..TO
Pull every version active across a window.
-- Range queries: every version that was active during a window.
-- BETWEEN '..a..' AND '..b..' -> versions active at any point in [a, b]
SELECT name, salary, valid_from, valid_to
FROM employees
FOR SYSTEM_TIME BETWEEN '2024-01-01' AND '2024-12-31'
WHERE id = 7
ORDER BY valid_from;
-- FROM '..a..' TO '..b..' -> same idea, but EXCLUSIVE of the end instant.
SELECT name, salary
FROM employees
FOR SYSTEM_TIME FROM '2024-01-01' TO '2024-07-01'
WHERE id = 7;
-- Use these for "what did this row lo
...Result — BETWEEN 2024-01-01 AND 2024-12-31:
| name | salary |
|---|---|
| Alice | 50000 |
| Alice | 60000 |
| Alice | 65000 |
Your Turn: list one row's change history
Two blanks this time — return the complete change history of employee 7, oldest version first.
🎯 Your Turn: full change history
Complete the FOR SYSTEM_TIME ALL query.
-- 🎯 YOUR TURN — fill in the two blanks to list the FULL change history
-- of employee 7 (current row + every old version), oldest first.
SELECT name, salary, department, valid_from, valid_to
FROM employees
FOR SYSTEM_TIME ___ -- 👉 the keyword that means "all versions"
WHERE id = 7
ORDER BY valid_from ___; -- 👉 sort direction so the oldest version is first
-- ✅ Expected: 3 rows for Alice —
-- 50000 / Sales -> 60000 / Sales -> 65000 / Marketing8. Valid-Time (Application-Time) by Hand
Sometimes you care about real-world validity, not storage time — insurance policies, hotel bookings, subscription plans. Here you own the dates, and you query them with a plain WHERE — no FOR SYSTEM_TIME, because the database isn't managing this clock.
Use 9999-12-31 (or 'infinity' on engines that support it) as the valid_to of the current, open-ended period. Then "what applies on date X?" is just valid_from <= X AND valid_to > X.
Valid-time with plain columns
You set the dates; query with a normal WHERE.
-- VALID time (a.k.a. application time) is a DIFFERENT idea.
-- It records when a fact is true in the REAL WORLD, and YOU set it.
-- System time = when the DB stored the row; valid time = when it applies.
CREATE TABLE subscriptions (
id INT PRIMARY KEY,
customer_id INT,
plan VARCHAR(50),
monthly_price DECIMAL(8,2),
-- You choose these dates; the database does not auto-fill them.
valid_from DATE NOT NULL,
valid_to DATE NOT NULL
);
INSERT
...Result — plan on 2024-07-15:
| plan | monthly_price |
|---|---|
| Pro | 19.99 |
Where Temporal Tables Earn Their Keep
- Audit trails: a complete, tamper-evident record of who-had-what-when, for free — no bespoke logging code.
- Regulatory compliance: answer "prove what data you held on 15 March" with one
AS OFquery. - Time-travel / bug investigation: reproduce "what did this customer's account look like when the error fired?"
- Slowly-changing dimensions: warehouses get point-in-time history without hand-rolled SCD plumbing.
- Accidental-change recovery: a bad bulk update is easy to inspect and reconstruct from history.
Common Errors (and the fix)
- Confusing valid-time with transaction-time: if a fact is true from Monday but you only enter it on Friday, system-time records Friday. Don't use
FOR SYSTEM_TIME AS OFto ask "what was true in reality" — that's valid-time, which you model with your own date columns. - Trying to delete or edit history: the engine guards the history table.
DELETE FROM employees_historyfails while versioning is on. To prune old rows you must firstALTER TABLE employees SET (SYSTEM_VERSIONING = OFF)— which removes the automatic protection, so do it deliberately. - Writing to
valid_from/valid_to: those columns areGENERATED ALWAYS.INSERT/UPDATEthem and you get an error — the engine owns those values. - Engine differences: the syntax is standard, but MySQL and PostgreSQL have no built-in support. Run
FOR SYSTEM_TIMEthere and it's a syntax error — you'd need a trigger-based history table instead. - Forgetting AS OF is a single instant:
AS OF '2024-06-15'means midnight on that day. If a change happened later that afternoon, you'll see the before value — add a time component when precision matters.
📘 Quick Reference
| Syntax | Purpose |
|---|---|
| SYSTEM_VERSIONING = ON | Turn on automatic history for a table |
| PERIOD FOR SYSTEM_TIME (f, t) | Declare the two period columns |
| GENERATED ALWAYS AS ROW START | DB-managed valid_from column |
| FOR SYSTEM_TIME AS OF 'ts' | Rewind the table to one instant |
| FOR SYSTEM_TIME ALL | Current row + every history version |
| BETWEEN 'a' AND 'b' | Versions active in [a, b] (inclusive) |
| FROM 'a' TO 'b' | Versions active in [a, b) (exclusive end) |
| valid_from <= X AND valid_to > X | Valid-time lookup (you manage it) |
Frequently Asked Questions
Q: Do I have to write the history myself?
No — that's the whole point of system-versioning. You keep writing normal INSERT/UPDATE/DELETE, and the engine files away each old version automatically. Manual triggers are only needed on engines without built-in support (MySQL, PostgreSQL).
Q: What's the difference between system-time and valid-time?
System-time (transaction-time) is when the database stored the row and is auto-managed. Valid-time (application-time) is when a fact is true in the real world and you set it yourself. A table tracking both is called bitemporal.
Q: Does keeping history slow my normal queries down?
Plain SELECTs read only the small current table, so they're unaffected. History lives in a separate table that's touched only when you add FOR SYSTEM_TIME. It does use more storage, so index and occasionally archive the history table on busy systems.
Q: Can I run this on MySQL or PostgreSQL?
Not with this exact syntax — neither has built-in temporal tables yet. On those you simulate it: add valid_from/valid_to columns and a trigger that copies the old row into a history table on every update. SQL Server 2016+, MariaDB 10.3+, Db2, and Oracle support it natively.
Mini-Challenge: Investigate Alice's Record
Put it all together — a brief, a blank canvas, and the expected results in the comments. Write the two queries, then run them on a SQL Server or MariaDB instance to confirm.
🎯 Mini-Challenge
An AS OF point query plus a BETWEEN history query.
-- 🎯 MINI-CHALLENGE
-- Using ONLY what this lesson covered (FOR SYSTEM_TIME AS OF / ALL / BETWEEN):
-- 1. Find Alice's salary as it stood on 2024-09-01 (after her move to Marketing)
-- 2. Then write a second query listing EVERY version of her row whose
-- valid_from falls between 2024-01-01 and 2024-12-31, oldest first
--
-- ✅ Expected (query 1): one row — salary 65000
-- ✅ Expected (query 2): 3 rows — 50000, 60000, 65000 in that order
-- your queries here🎉 Lesson Complete
- ✅ A system-versioned temporal table keeps a full history of every row automatically
- ✅ Transaction-time (system) is DB-managed; valid-time (application) is yours to set
- ✅
FOR SYSTEM_TIME AS OFrewinds the whole table to a single instant - ✅
ALL,BETWEEN, andFROM..TOpull ranges of versions from history - ✅ The history table mirrors the main table's columns and is guarded by the engine
- ✅ Next:
Full-Text Search— find rows by words and phrases, not exact matches
Sign up for free to track which lessons you've completed and get learning reminders.