<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Master level
All lessons in this course

Lesson 51 of 54 17 minMaster

Temporal tables: history and point-in-time queries

Let SQL Server keep every old version of every row: system-versioned tables, the period columns and history table, FOR SYSTEM_TIME AS OF, FROM, BETWEEN, CONTAINED IN and ALL, undoing a bad update from history, retention, and the rules that come with it.

“What did the drill cost last March?” “Who changed this price, and what was it before?” “Someone ran an UPDATE without a WHERE; put the prices back.” An ordinary table cannot answer these, because an UPDATE overwrites the old value. A temporal table (also called a system-versioned table) keeps every previous version of every row, automatically, and lets you query the table as it was at any moment.

Creating a temporal table

A temporal table needs two datetime2 columns that SQL Server fills in itself, a PERIOD FOR SYSTEM_TIME naming them, and SYSTEM_VERSIONING = ON. This price list starts with three products copied from BuildMart:

Query.sql
CREATE TABLE dbo.PriceList (    ProductId int           NOT NULL PRIMARY KEY,    Name      nvarchar(60)  NOT NULL,    Price     decimal(10,2) NOT NULL,    ValidFrom datetime2 GENERATED ALWAYS AS ROW START HIDDEN NOT NULL,    ValidTo   datetime2 GENERATED ALWAYS AS ROW END   HIDDEN NOT NULL,    PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo))WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.PriceListHistory));GOINSERT INTO dbo.PriceList (ProductId, Name, Price)SELECT ProductId, Name, Price FROM dbo.Products WHERE ProductId <= 3;
  • ValidFrom (ROW START) is when this version of the row became current; ValidTo (ROW END) is when it stopped being current. A current row has ValidTo = 9999-12-31, the end of time.
  • SQL Server sets both. You cannot insert or update them, so nobody can fake the history.
  • HIDDEN keeps them out of SELECT *, so existing queries and INSERTs without a column list are not affected.
  • HISTORY_TABLE names the table that receives old versions. SQL Server creates it with the same columns.
Query.sql
SELECT * FROM dbo.PriceList; SELECT ProductId, Price, ValidFrom, ValidTo FROM dbo.PriceList;
Output
ProductId  Name              Price
---------  ----------------  -----
        1  Claw hammer       18.50
        2  Cordless drill    89.00
        3  Tape measure 8 m   9.75

ProductId  Price  ValidFrom                    ValidTo
---------  -----  ---------------------------  ---------------------------
        1  18.50  2026-11-09 20:31:50.6978749  9999-12-31 23:59:59.9999999
        2  89.00  2026-11-09 20:31:50.6978749  9999-12-31 23:59:59.9999999
        3   9.75  2026-11-09 20:31:50.6978749  9999-12-31 23:59:59.9999999

The times are the UTC time when the inserting transaction began; yours will be the moment you ran it. In the SSMS Object Explorer the table shows “(System-Versioned)” and has its history table nested under it.

Every change leaves a version behind

Now change the data normally. Each UPDATE or DELETE copies the old version of the row into the history table, with its ValidTo set to the time of the change:

Query.sql
UPDATE dbo.PriceList SET Price = 19.50 WHERE ProductId = 1;UPDATE dbo.PriceList SET Price = 20.00 WHERE ProductId = 1;DELETE FROM dbo.PriceList WHERE ProductId = 3; SELECT ProductId, Price FROM dbo.PriceList; SELECT ProductId, Price, ValidFrom, ValidToFROM dbo.PriceListHistoryORDER BY ProductId, ValidFrom;
Output
ProductId  Price
---------  -----
        1  20.00
        2  89.00

ProductId  Price  ValidFrom                    ValidTo
---------  -----  ---------------------------  ---------------------------
        1  18.50  2026-11-09 20:31:51.1285141  2026-11-09 20:31:51.1462404
        1  19.50  2026-11-09 20:31:51.1462404  2026-11-09 20:31:51.1462404
        3   9.75  2026-11-09 20:31:51.1285141  2026-11-09 20:31:51.1462404

The current table has the hammer at 20.00 and no tape measure. The history table holds the hammer’s two earlier prices and the deleted tape measure. Each history row’s ValidTo is the next version’s ValidFrom, so the versions fit together end to end with no gaps.

Querying the past: FOR SYSTEM_TIME

You rarely read the history table directly. Add FOR SYSTEM_TIME after the table name, and SQL Server looks in both tables and returns the versions that match. AS OF gives the table as it was at one moment. Normally you would type a time, such as AS OF '2026-03-01 09:00'. Here the script saves the times in variables, so it works whenever you run it:

Query.sql
DECLARE @BeforeRise datetime2 = SYSUTCDATETIME();WAITFOR DELAY '00:00:00.100'; UPDATE dbo.PriceList SET Price = Price * 1.10;          -- a 10% price rise SELECT ProductId, Name, Price FROM dbo.PriceList; SELECT ProductId, Name, PriceFROM dbo.PriceList FOR SYSTEM_TIME AS OF @BeforeRise;
Output
ProductId  Name              Price
---------  ----------------  -----
        1  Claw hammer       20.35
        2  Cordless drill    97.90
        3  Tape measure 8 m  10.73

ProductId  Name              Price
---------  ----------------  -----
        1  Claw hammer       18.50
        2  Cordless drill    89.00
        3  Tape measure 8 m   9.75

The first SELECT shows today’s prices; the second shows the prices just before the rise. AS OF returns each row that was current at that moment: ValidFrom <= @time AND ValidTo > @time. The short WAITFOR only makes sure the clock moves on before the UPDATE.

Ranges and ALL

ALL returns every version, current and historical, which is the easy way to see a row’s whole story. A window function puts the old price beside the new one:

Query.sql
WAITFOR DELAY '00:00:00.100';      -- let the clock move on between changesUPDATE dbo.PriceList SET Price = 19.50 WHERE ProductId = 1;WAITFOR DELAY '00:00:00.100';UPDATE dbo.PriceList SET Price = 20.00 WHERE ProductId = 1; SELECT Name, Price,       LAG(Price) OVER (ORDER BY ValidFrom) AS PreviousPrice,       CASE WHEN ValidTo = '9999-12-31 23:59:59.9999999' THEN 'current' ELSE 'old' END AS VersionFROM dbo.PriceList FOR SYSTEM_TIME ALLWHERE ProductId = 1ORDER BY ValidFrom;
Output
Name         Price  PreviousPrice  Version
-----------  -----  -------------  -------
Claw hammer  18.50           NULL  old
Claw hammer  19.50          18.50  old
Claw hammer  20.00          19.50  current

Three more forms take a range of time. They differ only in how they treat versions that touch the edges of the range:

ClauseReturns versions that…Condition
AS OF twere current at the moment tValidFrom <= t AND ValidTo > t
FROM a TO bwere current at any time in the range, ends excludedValidFrom < b AND ValidTo > a
BETWEEN a AND bas FROM … TO, but also a version that starts exactly at bValidFrom <= b AND ValidTo > a
CONTAINED IN (a, b)started and ended inside the rangeValidFrom >= a AND ValidTo <= b
ALLevery versionnone
Interactive

Which versions does FOR SYSTEM_TIME return?

16.0017.5018.5019.0020.00 (current)JanFebMarAprMayJunJulAugSepOctNovDecJan
SELECT Price, ValidFrom, ValidTo
FROM dbo.PriceList FOR SYSTEM_TIME AS OF '2024-03-01'
WHERE ProductId = 1
ORDER BY ValidFrom;

-- Price  ValidFrom   ValidTo
-- 17.50  2024-03-01  2024-06-01
At 1 Mar one version ends and the next begins. AS OF returns only the new one: ValidFrom <= t is true for it, and ValidTo > t is false for the old one.

Undoing a mistake from history

Someone means to put the hammer on sale and forgets the WHERE clause. Every price in the list is now 9.99:

Query.sql
DECLARE @BeforeMistake datetime2 = SYSUTCDATETIME();WAITFOR DELAY '00:00:00.100'; UPDATE dbo.PriceList SET Price = 9.99;                  -- oops: no WHERE UPDATE curSET Price = old.PriceFROM dbo.PriceList AS curJOIN dbo.PriceList FOR SYSTEM_TIME AS OF @BeforeMistake AS old  ON old.ProductId = cur.ProductId; SELECT ProductId, Name, Price FROM dbo.PriceList;
Output
ProductId  Name              Price
---------  ----------------  -----
        1  Claw hammer       18.50
        2  Cordless drill    89.00
        3  Tape measure 8 m   9.75

The repair joins the table to itself as it was before the mistake and copies the old prices back. The repair is itself an UPDATE, so the history now records the mistake and the repair too. Nothing is ever lost, which is exactly what auditors want.

The rules that come with history

The history is protected. While system versioning is on, you cannot change it or drop the table:

Query.sql
DELETE FROM dbo.PriceListHistory;GODROP TABLE dbo.PriceList;
Output
Msg 13560, Level 16, State 1, Line 1
Cannot delete rows from a temporal history table 'BuildMart.dbo.PriceListHistory'.
Msg 13552, Level 16, State 1, Line 1
Drop table operation failed on table 'BuildMart.dbo.PriceList' because it is not a supported operation on system-versioned temporal tables.
  • Schema changes to the current table, such as ALTER TABLE … ADD, are copied to the history table for you.
  • All rows changed in one transaction get the same time: the time the transaction began, not the moment of each statement. If a row changes twice in one transaction (or twice within the clock’s resolution), the version in between lasted no time at all. It stays in the history table, but FOR SYSTEM_TIME, even ALL, leaves it out.
  • TRUNCATE TABLE is not allowed on a system-versioned table. Use DELETE, which keeps history.
  • To change history on purpose (for example, to remove personal data), turn versioning off, change the history table, and turn it on again. Keep that permission for administrators only.

History grows for ever unless you limit it. A retention period makes SQL Server delete history rows older than that, in the background:

Query.sql
ALTER TABLE dbo.PriceListSET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.PriceListHistory,                             HISTORY_RETENTION_PERIOD = 2 YEARS)); SELECT t.name, t.temporal_type_desc, h.name AS history_table,       t.history_retention_period, t.history_retention_period_unit_descFROM sys.tables AS tLEFT JOIN sys.tables AS h ON h.object_id = t.history_table_idWHERE t.name = 'PriceList';
Output
name       temporal_type_desc               history_table     history_retention_period  history_retention_period_unit_desc
---------  -------------------------------  ----------------  ------------------------  ----------------------------------
PriceList  SYSTEM_VERSIONED_TEMPORAL_TABLE  PriceListHistory                         2  YEAR

To get rid of a temporal table, switch versioning off first. The two tables then become ordinary tables you can drop:

Query.sql
ALTER TABLE dbo.PriceList SET (SYSTEM_VERSIONING = OFF);DROP TABLE dbo.PriceList, dbo.PriceListHistory; SELECT COUNT(*) AS TablesLeft FROM sys.tables WHERE name LIKE 'PriceList%';
Output
TablesLeft
----------
         0
Quick check

A price was 10.00 from 09:00 to 11:00 and 12.00 from 11:00 onwards. What does FOR SYSTEM_TIME AS OF '11:00' return?

Quick check

Why can a user not hide a change by updating ValidFrom in the current table?

Practice

Create a temporal table dbo.StockLevels (ProductId int primary key, Stock int) with a history table dbo.StockLevelsHistory, and copy in products 1 to 3 from dbo.Products. Change the hammer’s (product 1) stock to 35, then to 50. Then list every version of the hammer’s stock in order, oldest first, with a column saying which version is current. Finish by removing both tables.

Show a hint

Run in one go, the three statements may finish within one tick of the clock, and versions that lasted no time are not returned: put a short WAITFOR DELAY between them. Use FOR SYSTEM_TIME ALL with ORDER BY ValidFrom. The current version is the one whose ValidTo is 9999-12-31 23:59:59.9999999. Switch SYSTEM_VERSIONING OFF before the DROP.

Show the solution
Solution.sql
CREATE TABLE dbo.StockLevels (    ProductId int NOT NULL PRIMARY KEY,    Stock     int NOT NULL,    ValidFrom datetime2 GENERATED ALWAYS AS ROW START HIDDEN NOT NULL,    ValidTo   datetime2 GENERATED ALWAYS AS ROW END   HIDDEN NOT NULL,    PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo))WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.StockLevelsHistory));GOINSERT INTO dbo.StockLevels (ProductId, Stock)SELECT ProductId, Stock FROM dbo.Products WHERE ProductId <= 3;WAITFOR DELAY '00:00:00.050'; UPDATE dbo.StockLevels SET Stock = 35 WHERE ProductId = 1;WAITFOR DELAY '00:00:00.050'; UPDATE dbo.StockLevels SET Stock = 50 WHERE ProductId = 1; SELECT Stock,       CASE WHEN ValidTo = '9999-12-31 23:59:59.9999999' THEN 'yes' ELSE 'no' END AS IsCurrentFROM dbo.StockLevels FOR SYSTEM_TIME ALLWHERE ProductId = 1ORDER BY ValidFrom; ALTER TABLE dbo.StockLevels SET (SYSTEM_VERSIONING = OFF);DROP TABLE dbo.StockLevels, dbo.StockLevelsHistory;
Output
Stock  IsCurrent
-----  ---------
   40  no
   35  no
   50  yes

Remember

  • A temporal table keeps every old version of its rows in a history table, automatically.
  • It needs ValidFrom/ValidTo datetime2 columns GENERATED ALWAYS AS ROW START/END, a PERIOD FOR SYSTEM_TIME and SYSTEM_VERSIONING = ON.
  • Times are UTC and are set by SQL Server; HIDDEN keeps them out of SELECT *.
  • FOR SYSTEM_TIME AS OF t shows the table as it was at t; ALL shows every version.
  • FROM … TO, BETWEEN … AND and CONTAINED IN select versions in a range; they differ only at the edges.
  • Join a table to itself AS OF an earlier time to undo a mistaken change.
  • History cannot be changed and the table cannot be dropped while versioning is on; HISTORY_RETENTION_PERIOD limits its size.
0 of 54 lessons completed