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.
Creating a temporal table
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:
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. HIDDENkeeps them out ofSELECT *, so existing queries and INSERTs without a column list are not affected.HISTORY_TABLEnames the table that receives old versions. SQL Server creates it with the same columns.
SELECT * FROM dbo.PriceList; SELECT ProductId, Price, ValidFrom, ValidTo FROM dbo.PriceList;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.9999999Every change leaves a version behind
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;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.1462404Querying the past: FOR SYSTEM_TIME
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:
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;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.75ValidFrom <= @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:
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;Name Price PreviousPrice Version ----------- ----- ------------- ------- Claw hammer 18.50 NULL old Claw hammer 19.50 18.50 old Claw hammer 20.00 19.50 current
| Clause | Returns versions that… | Condition |
|---|---|---|
AS OF t | ||
FROM a TO b | ||
BETWEEN a AND b | ||
CONTAINED IN (a, b) | ||
ALL |
Undoing a mistake from history
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;ProductId Name Price
--------- ---------------- -----
1 Claw hammer 18.50
2 Cordless drill 89.00
3 Tape measure 8 m 9.75The rules that come with history
DELETE FROM dbo.PriceListHistory;GODROP TABLE dbo.PriceList;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 TABLEis 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.
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';name temporal_type_desc history_table history_retention_period history_retention_period_unit_desc --------- ------------------------------- ---------------- ------------------------ ---------------------------------- PriceList SYSTEM_VERSIONED_TEMPORAL_TABLE PriceListHistory 2 YEAR
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%';TablesLeft
----------
0Show a hint
Show the solution
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;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.