This chapter covers temporal tables in Azure SQL Database and SQL Server, a feature that automatically tracks full history of data changes. Temporal tables are a key concept for the DP-900 exam under objective 2.3, which focuses on relational data workloads. Expect 1-2 questions on temporal tables, typically asking about their purpose, the required columns, or how to query historical data. Understanding this feature is critical because it represents a built-in, system-versioned way to implement slowly changing dimensions without custom coding.
Jump to a section
Imagine a company that keeps a daily log of every employee's badge swipe at the entrance. This log records who entered, when, and whether it was entry or exit. The log is append-only: new records are added, old records are never changed or deleted. This is like a temporal table's history table – a growing record of every change. Now, suppose the company also takes a full photo of the office layout every day at 9 AM, showing exactly where each employee is sitting. That photo is like the current state of a regular table. But if you want to know who was sitting at desk 42 last Tuesday at 3 PM, the photo alone won't help because it's been overwritten. However, by combining the photo from Tuesday 9 AM with the badge swipe log between 9 AM and 3 PM, you can reconstruct exactly who was at each desk at 3 PM. This reconstruction is what SQL Server's temporal tables do: they store the current data in the main table (like the daily photo) and every change in a history table (like the badge log). The system uses two datetime2 columns (ValidFrom and ValidTo) to mark the period each row is considered valid. When you query with FOR SYSTEM_TIME AS OF a specific time, the database engine automatically merges the current and history rows to return the state as of that moment – just as the company would replay badge swipes from the log to adjust the morning photo.
What Are Temporal Tables and Why Do They Exist?
Temporal tables, also known as system-versioned temporal tables, are a database feature that automatically records the full history of every data change in a separate history table. They are defined by the SQL:2011 standard and are fully supported in Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2016+. The core idea is that each row in a temporal table has a period of validity defined by two datetime2 columns: ValidFrom (start time) and ValidTo (end time). When a row is updated or deleted, the old version is moved to the history table with its ValidTo set to the time of the change. The current table always contains only the latest version. This gives you the ability to query the data as it existed at any point in time – a capability called point-in-time analysis or time travel.
How Temporal Tables Work Internally
When you create a temporal table, you must specify a history table (or let the system create one automatically). The current table must have a primary key and exactly two datetime2 columns defined with GENERATED ALWAYS AS ROW START and GENERATED ALWAYS AS ROW END. These columns are populated automatically by the engine:
- ValidFrom: Set to the transaction's start time when the row is inserted or updated.
- ValidTo: Set to '9999-12-31 23:59:59.9999999' for current rows; when a row is updated or deleted, the old row's ValidTo is set to the transaction's start time.
Internally, the engine uses a transaction-based mechanism. When an UPDATE or DELETE occurs, the engine first copies the old row to the history table, setting its ValidTo to the current system time (UTC). Then it performs the modification on the current table, updating the ValidFrom of the new row (for UPDATE) or removing the row (for DELETE). Both operations happen within the same transaction, ensuring consistency. The history table is append-only and cannot be directly modified by users – it is system-managed. The engine automatically partitions the history table if it grows large, but you can also manage partitioning manually.
Key Components, Values, Defaults, and Timers
Required columns: Exactly two datetime2 columns with GENERATED ALWAYS AS ROW START and GENERATED ALWAYS AS ROW END. The precision of datetime2 can be specified; default is 7 digits (100 nanoseconds). Common choices: DATETIME2(0) (1 second precision) or DATETIME2(3) (1 millisecond).
History table: Can be named explicitly or auto-generated with a name like MSSQL_TemporalHistoryFor_<object_id>. It must have the same schema as the current table, except for the period columns, and cannot have foreign keys, triggers, or identity columns.
SYSTEM_VERSIONING: Must be set to ON to enable temporal. Default is OFF.
Time zone: The system uses UTC for the period columns. Queries that use AS OF should specify UTC times.
Retention: There is no automatic cleanup of history data. You must implement your own retention policy using a scheduled job or by partitioning the history table and dropping old partitions.
Constraints: The current table must have a primary key. The history table cannot have a primary key, but it can have a clustered index (typically on the period columns).
Configuration and Verification Commands
To create a temporal table:
CREATE TABLE Employee (
EmployeeID INT PRIMARY KEY,
Name NVARCHAR(100),
Position NVARCHAR(100),
Salary DECIMAL(10,2),
ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START,
ValidTo DATETIME2 GENERATED ALWAYS AS ROW END,
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.EmployeeHistory));To query historical data:
SELECT * FROM Employee
FOR SYSTEM_TIME AS OF '2023-01-01 12:00:00'
WHERE EmployeeID = 1;Other temporal clauses:
- FOR SYSTEM_TIME FROM <start> TO <end> – returns all row versions that were active within a period.
- FOR SYSTEM_TIME BETWEEN <start> AND <end> – includes rows that were active at the start and end boundaries.
- FOR SYSTEM_TIME CONTAINED IN (<start>, <end>) – only rows that were entirely within the period.
- FOR SYSTEM_TIME ALL – returns all rows from both current and history tables.
To verify temporal table properties:
SELECT * FROM sys.tables WHERE temporal_type_desc = 'SYSTEM_VERSIONED_TEMPORAL_TABLE';
SELECT * FROM sys.tables WHERE object_id = OBJECT_ID('EmployeeHistory');Interaction with Related Technologies
Azure SQL Database: Fully supports temporal tables. Backups and point-in-time restore work naturally: restoring a database to a point in time will restore both current and history data to that point. However, temporal tables are not automatically included in geo-replication; you must enable them on the secondary.
SQL Server on-premises: Same syntax and behavior. History table can be placed on a different filegroup for performance.
Azure Synapse Analytics: Does NOT support temporal tables (dedicated SQL pool). Use manual slowly changing dimension patterns instead.
Entity Framework Core: Supports temporal tables via Fluent API (e.g., .ToTable(t => t.IsTemporal())).
Power BI: Can query temporal tables using custom SQL, but direct query may have limitations. Best to create views that use AS OF queries.
Performance Considerations
Write performance: Each DML operation on the current table also writes to the history table, doubling the write IO. For high-throughput tables, consider batching updates or using a separate history table on faster storage.
Read performance: Queries with AS OF are efficient because the engine can use the period columns to filter both tables. Without proper indexes on the history table (e.g., on ValidTo and ValidFrom), scans may be expensive.
History table growth: The history table can grow quickly. Plan for retention and implement periodic cleanup using partition switching.
Transaction log impact: Both current and history writes are logged, increasing log throughput.
Limitations and Restrictions
The current table cannot have INSTEAD OF triggers. AFTER triggers are allowed.
The history table cannot be dropped while SYSTEM_VERSIONING is ON.
TRUNCATE TABLE is not allowed on temporal tables. Use DELETE instead.
You cannot directly update or delete rows in the history table.
The period columns cannot be altered after creation.
Foreign keys from other tables can reference the current table, but not the history table.
Identity columns are allowed in the current table but not in the history table.
Common Use Cases
Auditing: Track who changed what and when (requires additional columns like ModifiedBy).
Slowly Changing Dimension (SCD) Type 2: Temporal tables implement SCD Type 2 automatically.
Data recovery: Recover accidentally updated or deleted rows by querying history.
Trend analysis: Analyze how data changed over time.
Exam-Relevant Details
DP-900 tests recognition: You need to know that temporal tables automatically track history, require two datetime2 columns, and use FOR SYSTEM_TIME to query historical data.
Common wrong answer: Choosing a manually created audit table with triggers is not temporal – the exam expects the built-in feature.
Numbers: The default precision is 7 digits; the maximum datetime2 value for current rows is '9999-12-31 23:59:59.9999999'.
The history table is system-versioned and cannot be modified directly.
Temporal tables are supported in Azure SQL Database and SQL Server 2016+.
Step-by-Step: Creating and Querying a Temporal Table
Step 1: Create the current table with period columns
Define the table with two datetime2 columns marked as GENERATED ALWAYS AS ROW START and GENERATED ALWAYS AS ROW END, and include a PERIOD FOR SYSTEM_TIME clause.
Step 2: Enable SYSTEM_VERSIONING
Specify WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = ...)). The history table can be named explicitly or created automatically.
Step 3: Insert and modify data Insert rows normally. Each UPDATE or DELETE automatically moves the old version to the history table.
Step 4: Query historical data
Use FOR SYSTEM_TIME AS OF @time to see the state at a specific point.
Step 5: Query all changes
Use FOR SYSTEM_TIME ALL to combine current and history rows.
Step 6: Disable temporal (if needed)
Use ALTER TABLE ... SET (SYSTEM_VERSIONING = OFF) to stop tracking. This detaches the history table (which remains as a regular table).
Step 7: Manage history retention Implement a cleanup job that deletes rows older than a threshold, or use partition switching to drop old partitions.
Example Scenario
A company wants to track employee salary changes. They create a temporal table EmployeeSalary. After a year, they want to know the salary of employee 101 on June 1, 2023. They run:
SELECT Salary FROM EmployeeSalary
FOR SYSTEM_TIME AS OF '2023-06-01 10:00:00'
WHERE EmployeeID = 101;The engine returns the correct salary by looking at both the current table and history table, using the period columns to determine which row was active at that moment.
Trap Patterns
Confusing temporal tables with change tracking or change data capture: Temporal tables store full row versions, not just changes. Change tracking only marks which rows changed. Change data capture logs the changed data but requires additional configuration.
Thinking you can modify history table directly: You cannot. The system manages it.
Assuming `AS OF` works with local time: The period columns are in UTC. Always convert your query time to UTC.
Believing temporal tables are available in all Azure SQL services: They are not supported in Azure Synapse Analytics or Azure SQL Data Warehouse.
Summary of Key Points
Temporal tables automatically track full row history.
Two datetime2 columns define the validity period.
FOR SYSTEM_TIME AS OF queries point-in-time state.
History table is system-managed and append-only.
Supported in Azure SQL Database and SQL Server 2016+.
Not supported in Azure Synapse Analytics.
Create the current table
First, define the main table with a primary key and exactly two datetime2 columns that are marked as GENERATED ALWAYS AS ROW START and GENERATED ALWAYS AS ROW END. Include a PERIOD FOR SYSTEM_TIME clause that references these two columns. For example: CREATE TABLE Employee (EmployeeID INT PRIMARY KEY, Name NVARCHAR(100), ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START, ValidTo DATETIME2 GENERATED ALWAYS AS ROW END, PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)). The table must have a primary key; otherwise, temporal versioning cannot be enabled.
Enable SYSTEM_VERSIONING
After creating the table, alter it to turn on system versioning by specifying the history table. Use: ALTER TABLE Employee SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.EmployeeHistory)). If you omit the HISTORY_TABLE clause, the system automatically creates a history table with a name like MSSQL_TemporalHistoryFor_<object_id>. The history table inherits the same schema as the current table except for constraints, triggers, and identity properties. Once enabled, every INSERT, UPDATE, or DELETE on the current table automatically records the old row version in the history table.
Perform DML operations
Now you can insert, update, and delete rows normally. When you insert a new row, the ValidFrom is set to the transaction start time (UTC) and ValidTo is set to '9999-12-31 23:59:59.9999999'. When you update a row, the old row is copied to the history table with its ValidTo set to the transaction start time. Then the current row is updated with new values and a new ValidFrom set to the same transaction start time. When you delete a row, the old row is moved to the history table with its ValidTo set to the transaction start time, and the row is removed from the current table. All operations are transactional.
Query point-in-time data
To retrieve data as it existed at a specific moment, use the FOR SYSTEM_TIME AS OF clause. For example: SELECT * FROM Employee FOR SYSTEM_TIME AS OF '2023-06-01 10:00:00'. The engine automatically combines rows from the current table and the history table, returning only those rows where the specified time falls between ValidFrom and ValidTo. The time must be in UTC. This query is efficient because the engine can use the period columns to filter both tables.
Query all row versions
To see the full history of all rows, use FOR SYSTEM_TIME ALL. This returns all rows from both the current table and the history table, with each row showing its validity period. You can also use other temporal clauses like FOR SYSTEM_TIME FROM ... TO ... or FOR SYSTEM_TIME BETWEEN ... AND ... to get rows active within a range. These queries are useful for auditing and trend analysis.
Disable temporal versioning
If you need to stop temporal tracking, use ALTER TABLE Employee SET (SYSTEM_VERSIONING = OFF). This detaches the history table, which becomes a regular table. You can then drop or modify it. Note that you cannot drop the current table while SYSTEM_VERSIONING is ON. Disabling temporal does not delete the history data; it remains in the history table as a regular table.
Manage history retention
The history table can grow indefinitely. To manage size, implement a retention policy. Common approaches: (1) Use a scheduled job to delete rows older than a threshold (e.g., DELETE FROM EmployeeHistory WHERE ValidTo < DATEADD(YEAR, -1, GETUTCDATE())). (2) Use partition switching: create the history table with a partition scheme on ValidTo, then switch out old partitions to a staging table and drop them. This is more efficient for large tables. Remember that the history table is append-only, so you must use DELETE or partition switching.
Enterprise Scenario 1: HR Salary Audit
A multinational corporation with 50,000 employees uses Azure SQL Database to store HR data. They need to comply with GDPR and internal audit requirements to show salary history for any employee over the past 7 years. They implement a temporal table EmployeeSalary with columns EmployeeID, Salary, EffectiveDate (as period start), and EndDate (as period end). The history table grows by about 10,000 rows per day. They run a nightly job to delete history older than 7 years using a DELETE statement. Performance is acceptable because the history table has a clustered index on ValidTo and ValidFrom. Auditors can query: SELECT * FROM EmployeeSalary FOR SYSTEM_TIME AS OF '2020-01-01' WHERE EmployeeID = 12345. This returns the exact salary at that date. Without temporal, they would need custom audit tables and triggers, which are error-prone and harder to maintain.
Enterprise Scenario 2: Product Catalog Versioning
An e-commerce company with 1 million products uses temporal tables to track changes to product prices and descriptions. They enable temporal on the Product table. When a product price changes, the old price is automatically saved. They use FOR SYSTEM_TIME ALL to generate reports showing price trends. A common issue is that the history table grows very fast (100,000 rows per day). They implement partition switching: the history table is partitioned monthly on ValidTo. Each month, they switch the oldest partition to a staging table and then drop it. This keeps the history table manageable. They also use a view that queries FOR SYSTEM_TIME AS OF GETUTCDATE() for the current product catalog, which performs well because it only reads the current table.
Enterprise Scenario 3: Legal Document Tracking
A law firm uses temporal tables to track changes to legal documents stored as metadata in Azure SQL. Each document has a DocumentVersion table that is temporal. Lawyers can query the state of a document as of a specific date for discovery purposes. The main challenge is that the history table includes all intermediate versions, which can be hundreds per document. They use a retention policy of 10 years as required by law. They also set up alerts when the history table exceeds 100 GB. One misconfiguration they encountered: initially they used DATETIME2 with default precision (7 digits), which caused some rows to have the same timestamp for rapid updates. They changed to DATETIME2(3) (millisecond precision) to avoid ambiguity. Another mistake: they forgot to convert query times to UTC, leading to incorrect results. They now always use GETUTCDATE() in temporal queries.
Exactly What DP-900 Tests on This Topic
DP-900 objective 2.3 covers "describe relational data workloads." Temporal tables fall under the sub-topic of "identify appropriate data storage solutions." The exam expects you to:
Recognize that temporal tables automatically track data changes over time.
Know that they require two datetime2 columns (ValidFrom, ValidTo).
Know that the FOR SYSTEM_TIME AS OF clause is used to query historical data.
Understand that temporal tables are supported in Azure SQL Database and SQL Server 2016+.
Distinguish temporal tables from other change tracking features like Change Tracking and Change Data Capture.
Common Wrong Answers and Why Candidates Choose Them
"Temporal tables are the same as Change Data Capture (CDC)." Candidates confuse the two because both track changes. Reality: CDC logs changes in a separate table but requires explicit configuration and does not store full row versions per time. Temporal tables store full row versions with validity periods and are simpler to use.
"You can query temporal tables using regular SELECT without special syntax." Candidates think history is automatically included. Reality: You must use FOR SYSTEM_TIME clauses to access historical data. Regular SELECT only returns current rows.
"Temporal tables are available in Azure Synapse Analytics." Candidates assume all Azure SQL services support them. Reality: Synapse Analytics does not support temporal tables. Only Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2016+.
"The history table can be updated directly." Candidates think they can fix errors by modifying history. Reality: The history table is system-managed and cannot be modified directly. You must disable temporal, make changes, and re-enable.
Specific Numbers, Values, and Terms on the Exam
The default precision for datetime2 is 7 digits (100 nanoseconds).
The maximum ValidTo value is '9999-12-31 23:59:59.9999999'.
The syntax for querying historical state: FOR SYSTEM_TIME AS OF 'YYYY-MM-DD HH:MM:SS'.
The clause PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo) is required.
The history table is automatically created with a name like MSSQL_TemporalHistoryFor_<object_id>.
Temporal tables are part of the SQL:2011 standard.
Edge Cases and Exceptions the Exam Loves
Temporal tables with triggers: The current table cannot have INSTEAD OF triggers. AFTER triggers are allowed.
TRUNCATE: You cannot use TRUNCATE on a temporal table. Use DELETE instead.
ALTER TABLE: You cannot drop a temporal table directly; you must disable SYSTEM_VERSIONING first.
Time zone: The period columns store UTC. Queries must use UTC times.
Partitioning: The history table can be partitioned, but the current table cannot be partitioned if temporal is enabled.
How to Eliminate Wrong Answers
If the question asks about automatic history tracking and mentions FOR SYSTEM_TIME, it's temporal tables.
If the answer option says "requires triggers or stored procedures," it's not temporal (temporal is built-in).
If the answer option mentions "only available in Azure SQL Database," check if the question specifies on-premises or other services. Temporal is also in SQL Server 2016+.
If the answer option says "you can modify history table," eliminate it.
Temporal tables automatically track full row history using two datetime2 columns (ValidFrom, ValidTo).
Use FOR SYSTEM_TIME AS OF to query data as of a specific UTC time.
The history table cannot be modified directly; it is system-managed.
Temporal tables require a primary key and a PERIOD FOR SYSTEM_TIME clause.
Supported in Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2016+ (not in Azure Synapse).
The default datetime2 precision is 7 digits; ValidTo default is '9999-12-31 23:59:59.9999999'.
You cannot use TRUNCATE on a temporal table; use DELETE instead.
Temporal tables implement SCD Type 2 automatically without custom code.
These come up on the exam all the time. Here's how to tell them apart.
Temporal Tables (System-Versioned)
Stores full row versions with validity period (ValidFrom, ValidTo).
Built-in feature, no triggers or external agents required.
Point-in-time queries using FOR SYSTEM_TIME AS OF.
History table is append-only and system-managed.
Supported in Azure SQL Database and SQL Server 2016+.
Change Data Capture (CDC)
Logs only the changed columns and metadata (before/after).
Requires enabling CDC on database and table; uses log reader agent.
No built-in point-in-time query; must join change tables manually.
Change tables are accessible and can be queried directly.
Supported in Azure SQL Database and SQL Server (but not in Azure Synapse).
Mistake
Temporal tables and Change Data Capture are the same thing.
Correct
Temporal tables store full row versions with time validity and allow point-in-time queries. Change Data Capture logs only the changed data (before and after values) in a separate table and does not provide built-in time travel queries. Temporal is simpler for auditing full history; CDC is for incremental loading.
Mistake
You can query temporal tables without any special syntax.
Correct
A regular SELECT on a temporal table returns only the current rows. To see historical data, you must use FOR SYSTEM_TIME clauses such as AS OF, FROM...TO, BETWEEN, CONTAINED IN, or ALL.
Mistake
The history table can be manually updated or deleted to correct errors.
Correct
The history table is system-versioned and cannot be modified directly. To correct historical data, you must disable SYSTEM_VERSIONING, make changes, then re-enable it.
Mistake
Temporal tables are supported in all Azure data services.
Correct
Temporal tables are supported in Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2016+. They are NOT supported in Azure Synapse Analytics (dedicated SQL pool) or Azure SQL Data Warehouse.
Mistake
The period columns must be named ValidFrom and ValidTo.
Correct
You can name the period columns anything you like, as long as they are datetime2 and marked with GENERATED ALWAYS AS ROW START and GENERATED ALWAYS AS ROW END. Common names include SysStartTime and SysEndTime.
Reveal each answer, then mark whether you got it right. Score 60%+ to unlock the next chapter.
A temporal table is a system-versioned table that automatically tracks the full history of data changes. It includes two datetime2 columns (ValidFrom and ValidTo) that define the period when a row is valid. The current table holds the latest version, and a history table stores older versions. You can query historical data using FOR SYSTEM_TIME AS OF. It is supported in Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2016+.
To create a temporal table, define a table with a primary key and two datetime2 columns marked as GENERATED ALWAYS AS ROW START and GENERATED ALWAYS AS ROW END. Include a PERIOD FOR SYSTEM_TIME clause. Then enable SYSTEM_VERSIONING with a history table. Example: CREATE TABLE dbo.Employee (EmployeeID INT PRIMARY KEY, Name NVARCHAR(100), ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START, ValidTo DATETIME2 GENERATED ALWAYS AS ROW END, PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.EmployeeHistory));
Use the FOR SYSTEM_TIME clause with a subclause such as AS OF, FROM...TO, BETWEEN, CONTAINED IN, or ALL. For example: SELECT * FROM Employee FOR SYSTEM_TIME AS OF '2023-06-01 10:00:00' WHERE EmployeeID = 1. This returns the state of the row as it existed at that specific UTC time. The engine merges current and history rows based on the period columns.
No, the history table is system-managed and cannot be modified directly. All changes must go through the current table. If you need to correct historical data, you must disable SYSTEM_VERSIONING, make changes to the history table (which becomes a regular table), then re-enable temporal.
Temporal tables store full row versions with time validity and allow point-in-time queries. CDC logs only the changed data (before and after values) and is used for incremental data loading. Temporal is simpler for auditing and history tracking; CDC is more for ETL and replication. Both are supported in Azure SQL Database, but temporal is built-in without agents.
No, temporal tables are not supported in Azure Synapse Analytics (dedicated SQL pool). They are only supported in Azure SQL Database, Azure SQL Managed Instance, and SQL Server 2016+. In Synapse, you must implement custom slowly changing dimension patterns.
Implement a retention policy using DELETE or partition switching. For small tables, schedule a DELETE job to remove rows older than a threshold. For large tables, partition the history table on ValidTo and switch out old partitions to a staging table, then drop the staging table. This is more efficient and minimizes log growth.
You've just covered Temporal Tables in Azure SQL — now see how well it sticks with free DP-900 practice questions. Full explanations included, no account needed.
Done with this chapter?