Day 12: Temporal tables & time-based queries
On this page
- A. Lesson overview
- B. Theory explained clearly
- C. Dataset and starting state
- D. Worked examples
- E. Guided practical lab — Online delivery performance in working days
- F. Independent practice
- G. Solutions and explanations
- H. Knowledge check
- I. Troubleshooting and cleanup
- J. Completion checklist, recap and references
By the end of this lesson you can
- Choose the right date/time data type and write unambiguous date literals.
- Extract parts of dates with
YEAR,MONTH,DATEPARTandDATENAME, and explain which results depend on session settings. - Add intervals with
DATEADD, find month ends withEOMONTH, and truncate dates withDATETRUNC. - Explain that
DATEDIFFcounts boundaries crossed, and calculate ages and tenures correctly. - Filter date and date-time columns with half-open ranges (
>= start AND < next start). - Use a calendar table to fill gaps, count working days and compare like-for-like periods.
- Explain what a system-versioned temporal table records, and query it with
FOR SYSTEM_TIME AS OF,FROM … TO,BETWEEN … AND,CONTAINED INandALL. - Distinguish system time (when the database recorded a change) from business time (when something was true in the real world).
A. Lesson overview
| Week | 2 — Joins, set operations and advanced retrieval |
| Level | Intermediate → Advanced |
| Estimated time | 3 h essential + 30 min optional stretch |
| Prerequisites | Days 1–11; Day 4 (date series), Day 6 (DATEDIFF, LAG), Day 8 (outer joins) |
| Syllabus topic | Temporal Tables & Time-Based Queries — DATEPART, DATEADD, DATEDIFF, date ranges, calendar tables |
| Important distinction | This lesson covers two different things that share the word “temporal”: (1) calculating with dates and times — the syllabus’s key concepts — and (2) system-versioned temporal tables, a SQL Server feature that automatically keeps the history of every row. Both are taught |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Date and time data types, literals and current-time functions | 20 min |
Part 2 — Date functions and their traps (DATEPART, DATEADD, DATEDIFF, EOMONTH, DATETRUNC) |
25 min |
| Part 3 — Date ranges and calendar tables | 20 min |
| Part 4 — System-versioned temporal tables | 30 min |
| Setup script | 5 min |
| Worked examples | 25 min |
| Guided lab | 25 min |
| Independent practice | 25 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. Operations wants to know how quickly online orders are shipped, measured in working days (excluding weekends and bank holidays). HR’s tenure report gives the wrong answer for some people. Merchandising needs to answer “what was the list price of this tent in mid-2023?” and “which prices have changed since the start of 2023?” — a job for a temporal table that keeps price history automatically.
B. Theory explained clearly
Part 1 — Date and time types
| Type | Stores | Range / precision | Use for |
|---|---|---|---|
date |
Date only | 0001-01-01 to 9999-12-31 | Order dates, birthdays — no time needed |
time(n) |
Time of day | to 100 ns | Opening hours |
datetime2(n) |
Date + time | to 100 ns; n = 0–7 decimal places of seconds | Default choice for timestamps |
datetimeoffset(n) |
Date + time + UTC offset | to 100 ns | Events from several time zones |
datetime |
Date + time | 1753–9999, rounded to 0, 3 or 7 ms | Legacy code; avoid in new designs |
smalldatetime |
Date + time | to the minute | Legacy |
RetailLab stores business dates as date. Staging load times are datetime2(0).
Literals. '2025-12-31' is always read as 31 December 2025 when converted to date, datetime2 or datetimeoffset. For datetime, the dashed format can be misread under some language settings (e.g. British/French date order); '20251231' (no separators) is safe for every type. Never write '31/12/2025' or '12/31/2025' — their meaning depends on the session’s DATEFORMAT.
Current time functions
| Function | Returns | Notes |
|---|---|---|
SYSDATETIME() |
datetime2(7) local server time |
Most precise |
SYSUTCDATETIME() |
datetime2(7) UTC |
Use for audit columns |
SYSDATETIMEOFFSET() |
datetimeoffset(7) |
Includes the server’s offset |
GETDATE() / CURRENT_TIMESTAMP |
datetime local |
Older, less precise |
The course uses the fixed reference date 2025-12-31 instead of these functions, so results don’t change over time.
Part 2 — Date functions and their traps
| Function | Example (for 2025-11-20) |
Result |
|---|---|---|
YEAR(d), MONTH(d), DAY(d) |
MONTH('2025-11-20') |
11 |
DATEPART(part, d) |
DATEPART(QUARTER, d), DATEPART(ISO_WEEK, d) |
4, 47 |
DATENAME(part, d) |
DATENAME(WEEKDAY, d) |
Thursday (language-dependent) |
DATEADD(part, n, d) |
DATEADD(MONTH, 1, '2025-01-31') |
2025-02-28 (clamped to month end) |
DATEDIFF(part, start, end) |
DATEDIFF(DAY, '2025-11-20', '2025-12-31') |
41 |
EOMONTH(d [, months]) |
EOMONTH('2025-11-20') |
2025-11-30 |
DATEFROMPARTS(y, m, d) |
DATEFROMPARTS(2025, 11, 1) |
2025-11-01 |
DATETRUNC(part, d) (SQL Server 2022+) |
DATETRUNC(MONTH, d) |
2025-11-01 |
Trap 1 — DATEDIFF counts boundaries, not elapsed units
DATEDIFF(YEAR, '2025-12-31', '2026-01-01') is 1 — one year boundary was crossed — although only one day passed. DATEDIFF(MONTH, '2025-01-31', '2025-02-01') is 1 as well. For “completed” units, adjust:
-- Completed months between @Start and @End
DATEDIFF(MONTH, @Start, @End) - CASE WHEN DAY(@End) < DAY(@Start) THEN 1 ELSE 0 END
Divide completed months by 12 for completed years. (The rule treats e.g. 31 Jan → 28 Feb as not quite a month; adapt it if the business counts month-ends differently.)
Trap 2 — Weekday numbers depend on DATEFIRST
DATEPART(WEEKDAY, d) returns 1–7, but which day is 1 depends on the session’s SET DATEFIRST (US English default: Sunday = 1). DATENAME(WEEKDAY, d) depends on SET LANGUAGE. Code that must behave the same for everyone should use a calculation that ignores both, e.g. DATEDIFF(DAY, '19000101', d) % 7 + 1 gives 1 = Monday … 7 = Sunday (1 January 1900 was a Monday) — or simply look the value up in a calendar table.
Trap 3 — DATEADD with months clamps
DATEADD(MONTH, 1, '2025-01-31') is 28 February; adding another month gives 28 March, not 31 March. To step through month ends, compute each from a fixed start: EOMONTH('2025-01-31', n).
Trap 4 — Functions on columns in WHERE
WHERE YEAR(OrderDate) = 2025 is correct but prevents SQL Server from seeking an index on OrderDate (it must compute YEAR for every row). WHERE OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01' returns the same rows and can use an index. Day 24 calls this sargability.
FORMAT and CONVERT
FORMAT(d, 'dd MMM yyyy', 'en-GB') produces display strings using .NET formatting; it is convenient but noticeably slower than CONVERT on large result sets. CONVERT(char(8), d, 112) gives 20251231; style 23 gives 2025-12-31; style 103 gives 31/12/2025. Format dates in the presentation layer where possible.
Part 3 — Date ranges and calendar tables
Half-open ranges
For any column that can contain a time, filter a period as start inclusive, end exclusive:
WHERE CreatedAt >= '2025-12-01' AND CreatedAt < '2026-01-01'
BETWEEN '2025-12-01' AND '2025-12-31' on a datetime2 column misses everything after midnight on 31 December, because '2025-12-31' means 2025-12-31 00:00:00. Half-open ranges work for date and date-time types alike, and adjacent periods never overlap.
Calendar tables
A calendar table has one row per date with useful attributes pre-computed: year, quarter, month name, ISO week, weekday, weekend and bank-holiday flags, fiscal periods. Benefits:
- No gaps: joining facts to the calendar with a
LEFT JOINguarantees one row per day or month, even when nothing happened (Day 6’s moving-average caveat). - Business rules in data: working days, bank holidays and fiscal calendars live in one place instead of in every query.
- Settings-independent: names and weekday numbers are stored, not computed per session.
- Fast: a few thousand rows; easy to index.
Today’s setup script builds Lab.Calendar for 2024–2026 with England and Wales bank holidays.
Part 4 — System-versioned temporal tables
What they do
A system-versioned temporal table is a pair of tables managed by SQL Server:
- the current table, which you query and modify normally, and
- a history table, to which SQL Server automatically copies the old version of every row that is updated or deleted.
Each row carries two datetime2 period columns, here ValidFrom and ValidTo, maintained by SQL Server. They record the UTC time at which the database recorded the change (system time). You cannot write to them directly while versioning is on.
-- How such a table is normally declared (for reference; the setup script uses a migration method)
CREATE TABLE dbo.Example
(
ID int NOT NULL PRIMARY KEY,
Value decimal(10,2) NOT NULL,
ValidFrom datetime2(0) GENERATED ALWAYS AS ROW START NOT NULL,
ValidTo datetime2(0) GENERATED ALWAYS AS ROW END NOT NULL,
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.ExampleHistory));
Requirements: a primary key on the current table; exactly one PERIOD FOR SYSTEM_TIME with two non-nullable datetime2 columns; a history table with the same columns and types and no constraints. The “end” value of current rows is the maximum for the chosen precision (9999-12-31 23:59:59 for datetime2(0)).
Querying history: FOR SYSTEM_TIME
| Clause | Returns rows… | Typical question |
|---|---|---|
AS OF @t |
valid at instant t (ValidFrom <= @t AND ValidTo > @t) |
“What was the price list on 30 June 2023?” |
FROM @a TO @b |
active at any time in [a, b) (ValidFrom < @b AND ValidTo > @a) |
“Every version in effect during 2023” |
BETWEEN @a AND @b |
as FROM … TO, but includes rows that became active exactly at b (ValidFrom <= @b) |
Same, inclusive end |
CONTAINED IN (@a, @b) |
that began and ended within [a, b] | “Versions that came and went during 2023” |
ALL |
every version, current and historical | “Full history of product 1” |
SELECT * FROM Lab.ProductListPrice FOR SYSTEM_TIME AS OF '2023-06-30';
SQL Server combines the current and history tables for you. AS OF returns one version per key (or none, if the row did not exist then).
System time versus business time
Temporal tables record when the database changed, in UTC. That is ideal for auditing (“who could have seen which price when?”) and recovering accidentally changed data. It is not the same as business validity — “this price applies from 1 February” — if the change is entered before or after the date it takes effect. For effective-dated business data you model your own EffectiveFrom/EffectiveTo columns (Day 30’s slowly changing dimension does exactly that).
In Lab.ProductListPrice the history was loaded with chosen past dates so that the lesson is reproducible; any change you make today is stamped with the current UTC time.
Other things to know
- Altering or dropping a temporal table requires care: you must set
SYSTEM_VERSIONING = OFFbefore dropping it (the setup script shows how). - History grows with every update. Plan a retention policy (
HISTORY_RETENTION_PERIOD) for busy tables. TRUNCATE TABLEis not allowed on a system-versioned table.- Temporal tables work the same way in SQL Server, Azure SQL Database and Azure SQL Managed Instance; check the documentation before assuming the same in other platforms.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor) plus the Day 12 script |
| Database | RetailLab |
| Tables used | Sales.SalesOrder, Sales.SalesOrderLine, HR.Employee, Inventory.Product, Lab.Calendar, Lab.ProductListPrice (temporal) with Lab.ProductListPriceHistory |
| Permissions | CREATE TABLE and ALTER on schema Lab to run the script; SELECT/UPDATE on the Lab tables for the exercises |
| Objects created | Lab.Calendar (1,096 rows), Lab.ProductListPrice (29 current rows), Lab.ProductListPriceHistory (10 history rows) — keep them: Days 14, 29 and 30 use them |
Run the setup script
- Open
scripts/lessons/day12_calendar_and_temporal.sql(download it) in SSMS (save it next to your setup scripts, e.g.C:\RetailLab\scripts\lessons\). - Press F5.
- The last result grid should show:
1096,760,24,29,10,SYSTEM_VERSIONED_TEMPORAL_TABLE.
The script is safe to re-run: it drops and re-creates only these three Lab objects. Re-running it is also how you restore the price history after today’s update exercises.
What the script creates
| Object | Contents |
|---|---|
Lab.Calendar |
One row per date from 2024-01-01 to 2026-12-31: CalendarYear, CalendarQuarter, MonthNo, MonthName, MonthStart, DayOfMonth, IsoDayOfWeek (1 = Monday), DayName, IsoWeek, IsWeekend, IsBankHoliday, HolidayName, IsWorkingDay. 760 working days. Bank holidays are England and Wales dates as published on GOV.UK |
Lab.ProductListPrice |
Current list price of each of the 29 active products; ValidFrom = when that price took effect |
Lab.ProductListPriceHistory |
10 earlier versions: earlier prices for products 1, 2, 5, 9, 10, 11 and 22, and the bell tent (product 30), removed from the price list on 2025-01-01 |
Validation. Run this after the script; expected 3, 29, 3.
USE RetailLab;
GO
SELECT (SELECT COUNT(*) FROM Lab.ProductListPrice FOR SYSTEM_TIME ALL WHERE ProductID = 1) AS Product1Versions,
(SELECT COUNT(*) FROM Lab.ProductListPrice) AS CurrentRows,
(SELECT COUNT(*) FROM Lab.Calendar WHERE CalendarDate = '2025-12-25' AND IsWorkingDay = 0)
+ (SELECT COUNT(*) FROM Lab.Calendar WHERE CalendarDate = '2025-12-26' AND IsWorkingDay = 0)
+ (SELECT COUNT(*) FROM Lab.Calendar WHERE CalendarDate = '2025-12-27' AND IsWeekend = 1) AS ChristmasChecks;
D. Worked examples
Worked example 1 — Date functions on one date
DECLARE @d date = '2025-11-20';
SELECT YEAR(@d) AS Yr,
DATEPART(QUARTER, @d) AS Qtr,
DATEPART(ISO_WEEK, @d) AS IsoWeek,
DATEADD(DAY, 30, @d) AS Plus30Days,
DATEADD(MONTH, -1, @d) AS MinusOneMonth,
EOMONTH(@d) AS MonthEnd,
EOMONTH(@d, 1) AS NextMonthEnd,
DATEFROMPARTS(YEAR(@d), 1, 1) AS YearStart,
DATETRUNC(MONTH, @d) AS MonthStart,
DATEDIFF(DAY, @d, '2025-12-31') AS DaysToYearEnd;
Expected result: 2025, 4, 47, 2025-12-20, 2025-10-20, 2025-11-30, 2025-12-31, 2025-01-01, 2025-11-01, 41.
Worked example 2 — DATEDIFF counts boundaries
SELECT DATEDIFF(YEAR, '2025-12-31', '2026-01-01') AS YearsCrossed, -- 1
DATEDIFF(MONTH, '2025-01-31', '2025-02-01') AS MonthsCrossed, -- 1
DATEDIFF(DAY, '2025-01-31', '2025-02-01') AS DaysBetween, -- 1
DATEADD(MONTH, 1, CAST('2025-01-31' AS date)) AS OneMonthLater, -- 2025-02-28
DATEADD(MONTH, 1, DATEADD(MONTH, 1, CAST('2025-01-31' AS date))) AS TwoStepsLater; -- 2025-03-28
Expected result: 1, 1, 1, 2025-02-28, 2025-03-28. The last value shows month clamping accumulating: two steps of one month from 31 January land on 28 March.
Worked example 3 — BETWEEN misses the last day for date-time values
WITH Events AS
(
SELECT CAST(t AS datetime2(0)) AS EventTime
FROM (VALUES ('2025-12-30 09:15:00'), ('2025-12-31 00:00:00'),
('2025-12-31 17:45:00'), ('2026-01-01 00:00:00')) AS v(t)
)
SELECT (SELECT COUNT(*) FROM Events WHERE EventTime BETWEEN '2025-12-30' AND '2025-12-31') AS WithBetween,
(SELECT COUNT(*) FROM Events WHERE EventTime >= '2025-12-30' AND EventTime < '2026-01-01') AS HalfOpen;
Expected result: 2 and 3. BETWEEN excluded the 17:45 event on 31 December; the half-open range includes it and correctly excludes 1 January.
Worked example 4 — Orders per weekday, fairly (calendar table)
SELECT c.IsoDayOfWeek,
c.DayName,
COUNT(DISTINCT c.CalendarDate) AS DaysInYear,
COUNT(o.OrderID) AS Orders,
CAST(1.0 * COUNT(o.OrderID) / COUNT(DISTINCT c.CalendarDate) AS decimal(4,2)) AS OrdersPerDay
FROM Lab.Calendar AS c
LEFT JOIN Sales.SalesOrder AS o
ON o.OrderDate = c.CalendarDate
AND o.OrderStatus <> 'Cancelled'
WHERE c.CalendarYear = 2025
GROUP BY c.IsoDayOfWeek, c.DayName
ORDER BY c.IsoDayOfWeek;
Expected result
| IsoDayOfWeek | DayName | DaysInYear | Orders | OrdersPerDay |
|---|---|---|---|---|
| 1 | Monday | 52 | 35 | 0.67 |
| 2 | Tuesday | 52 | 31 | 0.60 |
| 3 | Wednesday | 53 | 30 | 0.57 |
| 4 | Thursday | 52 | 25 | 0.48 |
| 5 | Friday | 52 | 19 | 0.37 |
| 6 | Saturday | 52 | 32 | 0.62 |
| 7 | Sunday | 52 | 26 | 0.50 |
Why the calendar matters: 2025 has 53 Wednesdays and 52 of every other day. Dividing by the number of days avoids favouring Wednesday. The LEFT JOIN from the calendar counts days with no orders as zero-order days — the cancelled-order filter is in ON (Day 8).
Worked example 5 — Temporal table: current, as of, and full history
-- Current version (an ordinary query)
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM Lab.ProductListPrice
WHERE ProductID IN (1, 22)
ORDER BY ProductID;
-- The price list as it stood on 30 June 2023
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM Lab.ProductListPrice FOR SYSTEM_TIME AS OF '2023-06-30'
WHERE ProductID IN (1, 2, 9, 22, 30)
ORDER BY ProductID;
-- Every version of product 1
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM Lab.ProductListPrice FOR SYSTEM_TIME ALL
WHERE ProductID = 1
ORDER BY ValidFrom;
Expected result
- Current: product 1 at 189.00 (since 2024-01-01), product 22 at 299.00 (since 2023-11-15).
ValidTois9999-12-31 23:59:59. - As of 2023-06-30: product 1 179.00, product 2 329.00, product 9 5.99, product 22 319.00, product 30 599.00. The whole price list that day has 26 products (
SELECT COUNT(*) … AS OF '2023-06-30'). - All versions of product 1: 169.00 (2022-03-01 → 2023-03-01), 179.00 (2023-03-01 → 2024-01-01), 189.00 (2024-01-01 → 9999-12-31 23:59:59).
Worked example 6 — Change a price and watch history being written
DECLARE @Before datetime2(0) = SYSUTCDATETIME();
UPDATE Lab.ProductListPrice
SET ListPrice = 72.00
WHERE ProductID = 26; -- BPK-001 Daypack, was 69.00
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM Lab.ProductListPrice FOR SYSTEM_TIME ALL
WHERE ProductID = 26
ORDER BY ValidFrom;
SELECT ListPrice AS PriceJustBeforeUpdate
FROM Lab.ProductListPrice FOR SYSTEM_TIME AS OF @Before
WHERE ProductID = 26;
Expected result: two versions of product 26 — 69.00 valid from 2022-03-01 until the moment you ran the update (in UTC), and 72.00 valid from that moment until 9999-12-31 23:59:59. The AS OF @Before query returns 69.00. Your exact timestamps will differ from anyone else’s: system time is real time. Re-run the Day 12 script afterwards to restore the fixed history (the cleanup section reminds you).
If you try UPDATE Lab.ProductListPrice SET ValidFrom = '2020-01-01' … you get an error: period columns are maintained by SQL Server.
E. Guided practical lab — Online delivery performance in working days
Operations promises to ship online orders within 2 working days of the order date (the order day itself not counted). Measure 2025 performance by month.
Step 1 — The orders to measure
- Do: Select 2025 online orders that have a ship date.
- Run:
USE RetailLab; GO SELECT OrderID, OrderDate, ShippedDate, DATEDIFF(DAY, OrderDate, ShippedDate) AS CalendarDays FROM Sales.SalesOrder WHERE SalesChannel = 'Online' AND ShippedDate IS NOT NULL AND OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01' ORDER BY OrderDate, OrderID; - Observe: 106 rows (110 non-cancelled online orders minus 4 still pending).
- Why it matters: Cancelled and pending orders have no ship date and are excluded by
ShippedDate IS NOT NULL. - Confirm: 106 rows.
Step 2 — Count working days with the calendar
- Do: For each order, count calendar days after the order date up to and including the ship date that are working days.
- Run:
SELECT o.OrderID, o.OrderDate, o.ShippedDate, DATEDIFF(DAY, o.OrderDate, o.ShippedDate) AS CalendarDays, wd.WorkingDays FROM Sales.SalesOrder AS o CROSS APPLY ( SELECT COUNT(*) AS WorkingDays FROM Lab.Calendar AS c WHERE c.CalendarDate > o.OrderDate AND c.CalendarDate <= o.ShippedDate AND c.IsWorkingDay = 1 ) AS wd WHERE o.SalesChannel = 'Online' AND o.ShippedDate IS NOT NULL AND o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01' ORDER BY o.OrderDate, o.OrderID; - Observe: December rows such as order 1350 (ordered Wed 3 Dec, shipped Sun 7 Dec: 4 calendar days, 2 working days) and order 1360 (ordered Fri 19 Dec, shipped Sun 21 Dec: 2 calendar days, 0 working days).
- Why it matters: Calendar days and working days tell different stories; the calendar table encodes the rule once.
- Confirm: order 1350 → 4 and 2.
Step 3 — Flag the SLA and summarise by month
-
Do: Remove the December filter, wrap the step-2 query in a CTE and summarise.
-
Run:
WITH Shipping AS ( SELECT o.OrderID, o.OrderDate, DATEDIFF(DAY, o.OrderDate, o.ShippedDate) AS CalendarDays, wd.WorkingDays FROM Sales.SalesOrder AS o CROSS APPLY (SELECT COUNT(*) AS WorkingDays FROM Lab.Calendar AS c WHERE c.CalendarDate > o.OrderDate AND c.CalendarDate <= o.ShippedDate AND c.IsWorkingDay = 1) AS wd WHERE o.SalesChannel = 'Online' AND o.ShippedDate IS NOT NULL AND o.OrderDate >= '2025-01-01' AND o.OrderDate < '2026-01-01' ) SELECT MONTH(OrderDate) AS MonthNo, COUNT(*) AS ShippedOrders, SUM(CASE WHEN WorkingDays <= 2 THEN 1 ELSE 0 END) AS WithinSLA, CAST(100.0 * SUM(CASE WHEN WorkingDays <= 2 THEN 1 ELSE 0 END) / COUNT(*) AS decimal(5,1)) AS SLAPct, CAST(AVG(CAST(WorkingDays AS decimal(5,2))) AS decimal(4,1)) AS AvgWorkingDays FROM Shipping GROUP BY MONTH(OrderDate) ORDER BY MonthNo; -
Observe:
MonthNo ShippedOrders WithinSLA SLAPct AvgWorkingDays 1 5 5 100.0 1.4 2 5 4 80.0 1.8 3 12 8 66.7 2.3 4 8 6 75.0 2.0 5 7 6 85.7 1.4 6 11 9 81.8 1.5 7 13 11 84.6 1.5 8 7 6 85.7 1.6 9 9 9 100.0 1.0 10 8 4 50.0 2.1 11 7 6 85.7 1.7 12 14 14 100.0 0.9 -
Why it matters: October stands out at 50%. December’s average is under one working day partly because of weekend and bank-holiday shipping — a business question worth raising.
-
Confirm: 12 rows; total shipped orders 106.
Step 4 — The annual figure
- Do: Replace the final
SELECTwith a single-row summary. - Run:
SELECT COUNT(*) AS ShippedOrders, SUM(CASE WHEN WorkingDays <= 2 THEN 1 ELSE 0 END) AS WithinSLA_WorkingDays, CAST(100.0 * SUM(CASE WHEN WorkingDays <= 2 THEN 1 ELSE 0 END) / COUNT(*) AS decimal(5,1)) AS SLAPct, SUM(CASE WHEN CalendarDays <= 2 THEN 1 ELSE 0 END) AS WithinTwoCalendarDays FROM Shipping; - Observe:
106,88,83.0,65. - Why it matters: Measured in calendar days, only 65 orders (61%) would meet a “2-day” target; in working days, 88 (83%). The definition changes the headline — agree it before reporting.
- Confirm: 83.0%.
F. Independent practice
Task 1 — Tenure done properly
Using @AsAt date = '2025-06-30', return each employee hired on or before that date with EmployeeID, FullName, HireDate, TenureYears, TenureMonths (completed years and remaining completed months, up to the termination date for leavers) and NaiveYears (DATEDIFF(YEAR, HireDate, end date)). Sort by EmployeeID.
Success criteria: 15 rows (Oliver Grant joined later). Raj Patel: 5 years 9 months, naive 6. Five employees have NaiveYears one higher than TenureYears: 2, 6, 8, 11 and 13.
Task 2 — Price list changes since 2023
Using the temporal table, list products present on 1 January 2023 whose list price is different today, with ProductID, PriceOn20230101, CurrentPrice and ChangePct (1 decimal place).
Success criteria: 6 rows — products 1 (169.00 → 189.00), 2 (329.00 → 349.00), 5 (279.00 → 299.00), 9 (5.99 → 6.50), 10 and 11 (175.00 → 189.00).
Hint
Join Lab.ProductListPrice FOR SYSTEM_TIME AS OF '2023-01-01' to Lab.ProductListPrice (current) on ProductID. Product 9 changed price on 1 January 2023 — which version does AS OF return at exactly that instant?
Task 3 — Products added and removed since mid-2024
Compare the price list as of 2024-06-30 with the current one. Return ProductID, ProductCode and Change (Added or Removed).
Success criteria: 4 rows — 14 JKT-005, 28 BPK-003 and 29 NAV-004 Added; 30 TNT-004 Removed.
Hint
EXCEPT in both directions (Day 9) on ProductID, then join to Inventory.Product for the code.
Task 4 — Versions that came and went (challenging)
a) Return every price version that began and ended within 2023 (CONTAINED IN), with ProductID, ListPrice, ValidFrom, ValidTo.
b) For product 22 (Handheld GPS Unit), use LAG over FOR SYSTEM_TIME ALL to show each version with the previous price and the change.
Success criteria: (a) 3 rows: product 1 at 179.00 (2023-03-01 → 2024-01-01), product 9 at 5.99 (2023-01-01 → 2024-01-01) and product 22 at 319.00 (2023-06-01 → 2023-11-15). (b) 2 rows: 319.00 with no previous price; 299.00 with previous 319.00 and change −20.00 (unless you changed product 22 yourself today).
Stretch challenge — Sales per working day, year on year
For each month, compare 2025 with 2024 per working day: MonthNo, WorkingDays2024, NetPerWD2024, WorkingDays2025, NetPerWD2025, ChangePct. Use Lab.Calendar for working days.
Success criteria: 12 rows. January: 22 / 158.06 vs 22 / 170.44 (+7.8%). March: 20 / 200.77 vs 21 / 327.97 (+63.4%). August: 21 / 279.67 vs 20 / 120.97 (−56.7%). December: 20 / 467.19 vs 21 / 462.79 (−0.9%).
Hint
Two CTEs at the same grain — working days per (year, month) from the calendar, and net sales per (year, month) from orders — joined on year and month. Then pivot the two years with conditional aggregation or a self-join.
G. Solutions and explanations
Show solution 1
DECLARE @AsAt date = '2025-06-30';
SELECT e.EmployeeID,
CONCAT_WS(N' ', e.FirstName, e.LastName) AS FullName,
e.HireDate,
m.CompletedMonths / 12 AS TenureYears,
m.CompletedMonths % 12 AS TenureMonths,
DATEDIFF(YEAR, e.HireDate, x.EndDate) AS NaiveYears
FROM HR.Employee AS e
CROSS APPLY (VALUES (CASE WHEN e.TerminationDate < @AsAt THEN e.TerminationDate ELSE @AsAt END)) AS x (EndDate)
CROSS APPLY (VALUES (DATEDIFF(MONTH, e.HireDate, x.EndDate)
- CASE WHEN DAY(x.EndDate) < DAY(e.HireDate) THEN 1 ELSE 0 END)) AS m (CompletedMonths)
WHERE e.HireDate <= @AsAt
ORDER BY e.EmployeeID;
Result (selected rows): Eleanor Hughes 6 y 2 m (naive 6); Raj Patel 5 y 9 m (naive 6); Liam O’Connor 4 y 11 m (5); Daniel Okafor 2 y 10 m (3); Gareth Price 4 y 8 m (5); Marek Nowak 1 y 7 m (2); James Reid (left 2025-06-30) 3 y 5 m (3).
Reasoning. CROSS APPLY (VALUES …) names the end date and completed months once (Day 10). CASE WHEN e.TerminationDate < @AsAt is FALSE when TerminationDate is NULL, so current employees use @AsAt. At 31 December the naive formula happens to agree for everyone — which is exactly why boundary bugs survive testing.
Show solution 2
SELECT old.ProductID,
old.ListPrice AS PriceOn20230101,
cur.ListPrice AS CurrentPrice,
CAST(100.0 * (cur.ListPrice - old.ListPrice) / old.ListPrice AS decimal(5,1)) AS ChangePct
FROM Lab.ProductListPrice FOR SYSTEM_TIME AS OF '2023-01-01' AS old
JOIN Lab.ProductListPrice AS cur ON cur.ProductID = old.ProductID
WHERE cur.ListPrice <> old.ListPrice
ORDER BY old.ProductID;
| ProductID | PriceOn20230101 | CurrentPrice | ChangePct |
|---|---|---|---|
| 1 | 169.00 | 189.00 | 11.8 |
| 2 | 329.00 | 349.00 | 6.1 |
| 5 | 279.00 | 299.00 | 7.2 |
| 9 | 5.99 | 6.50 | 8.5 |
| 10 | 175.00 | 189.00 | 8.0 |
| 11 | 175.00 | 189.00 | 8.0 |
Reasoning. AS OF uses ValidFrom <= @t AND ValidTo > @t, so at exactly 2023-01-01 00:00:00 product 9’s new 5.99 version is already valid. The alias goes after the FOR SYSTEM_TIME clause. If you ran worked example 6 and have not yet re-run the Day 12 script, product 26 appears as a seventh row (69.00 → 72.00); re-run the script to get exactly 6 rows.
Show solution 3
WITH Then2024 AS (SELECT ProductID FROM Lab.ProductListPrice FOR SYSTEM_TIME AS OF '2024-06-30'),
NowList AS (SELECT ProductID FROM Lab.ProductListPrice),
Changes AS
(
SELECT ProductID, 'Added' AS Change FROM (SELECT ProductID FROM NowList EXCEPT SELECT ProductID FROM Then2024) AS a
UNION ALL
SELECT ProductID, 'Removed' FROM (SELECT ProductID FROM Then2024 EXCEPT SELECT ProductID FROM NowList) AS r
)
SELECT c.ProductID, p.ProductCode, c.Change
FROM Changes AS c
JOIN Inventory.Product AS p ON p.ProductID = c.ProductID
ORDER BY c.Change, c.ProductID;
Result: Added 14 JKT-005, 28 BPK-003, 29 NAV-004; Removed 30 TNT-004.
Reasoning. The bell tent’s row was “deleted” from the current table on 2025-01-01 — it now exists only in history. Products launched after mid-2024 have current rows whose ValidFrom is later than 2024-06-30, so AS OF does not see them.
Show solution 4
-- (a)
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM Lab.ProductListPrice FOR SYSTEM_TIME CONTAINED IN ('2023-01-01', '2024-01-01')
ORDER BY ProductID, ValidFrom;
-- (b)
SELECT ProductID, ListPrice, ValidFrom, ValidTo,
LAG(ListPrice) OVER (ORDER BY ValidFrom) AS PreviousPrice,
ListPrice - LAG(ListPrice) OVER (ORDER BY ValidFrom) AS PriceChange
FROM Lab.ProductListPrice FOR SYSTEM_TIME ALL
WHERE ProductID = 22
ORDER BY ValidFrom;
Result: (a) 3 rows as listed. CONTAINED IN includes rows with ValidFrom >= start AND ValidTo <= end, so versions ending exactly on 2024-01-01 count. (b) 319.00 / NULL / NULL; 299.00 / 319.00 / −20.00.
Reasoning. Temporal queries return ordinary result sets, so window functions, joins and aggregates all work on them. A price cut is as important to track as a rise.
Show the stretch solution
WITH WorkDays AS
(
SELECT CalendarYear, MonthNo, SUM(CAST(IsWorkingDay AS int)) AS WorkingDays
FROM Lab.Calendar
WHERE CalendarYear IN (2024, 2025)
GROUP BY CalendarYear, MonthNo
),
Sales AS
(
SELECT YEAR(o.OrderDate) AS Yr, MONTH(o.OrderDate) AS MonthNo,
SUM(l.Quantity * l.UnitPrice * (1 - l.DiscountPct)) AS NetSales
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
WHERE o.OrderStatus <> 'Cancelled'
GROUP BY YEAR(o.OrderDate), MONTH(o.OrderDate)
),
PerWD AS
(
SELECT w.CalendarYear, w.MonthNo, w.WorkingDays, s.NetSales / w.WorkingDays AS NetPerWD
FROM WorkDays AS w
JOIN Sales AS s ON s.Yr = w.CalendarYear AND s.MonthNo = w.MonthNo
)
SELECT a.MonthNo,
a.WorkingDays AS WorkingDays2024,
CAST(a.NetPerWD AS decimal(10,2)) AS NetPerWD2024,
b.WorkingDays AS WorkingDays2025,
CAST(b.NetPerWD AS decimal(10,2)) AS NetPerWD2025,
CAST(100.0 * (b.NetPerWD - a.NetPerWD) / a.NetPerWD AS decimal(6,1)) AS ChangePct
FROM PerWD AS a
JOIN PerWD AS b ON b.MonthNo = a.MonthNo AND b.CalendarYear = 2025
WHERE a.CalendarYear = 2024
ORDER BY a.MonthNo;
Result: 12 rows as described in the success criteria (2024 has 254 working days, 2025 has 253).
Reasoning. IsWorkingDay is a bit; SUM does not accept bit, so it is cast to int. Online orders are taken every day, so “per working day” is a modelling choice — useful for store staffing, debatable for web sales. Normalising by the right denominator is often the most important decision in a time comparison.
H. Knowledge check
-
Understanding. What does
DATEDIFF(MONTH, '2025-03-31', '2025-04-01')return, and why? -
Debugging. A report filters
WHERE CreatedAt BETWEEN '2025-12-01' AND '2025-12-31'on adatetime2column and misses some rows. Which ones, and what is the fix? -
Understanding. Why might
DATEPART(WEEKDAY, '2025-12-29')return different values for two colleagues? -
Query interpretation. What does
SELECT * FROM Lab.ProductListPrice FOR SYSTEM_TIME AS OF '2025-06-30' WHERE ProductID = 30;return? -
Understanding. A price change approved on 15 January to take effect on 1 February is entered into a temporal table on 15 January. What does
AS OF '2025-01-20'show, and why might that not be what Finance expects?
Show the answers
-
1— one month boundary is crossed, although only one day passed. -
Rows after midnight on 31 December (e.g.
2025-12-31 14:00), because'2025-12-31'means midnight. UseCreatedAt >= '2025-12-01' AND CreatedAt < '2026-01-01'. -
WEEKDAYnumbering depends on the session’sDATEFIRSTsetting (and therefore on the login’s language). Use a settings-independent calculation or a calendar table. -
No rows. The bell tent’s last version ended on 2025-01-01; on 30 June 2025 it was no longer in the price list.
-
The new price, because system time records when the database changed (15 January), not when the price is meant to apply. Finance means business time; that needs effective-date columns in the data model.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Conversion failed when converting date and/or time from character string. | Ambiguous or invalid literal ('31/12/2025') |
Use '2025-12-31' (or '20251231' for datetime) |
| Off-by-one ages or tenures | DATEDIFF counts boundaries |
Completed-months formula |
| Weekday numbers differ between users | DATEFIRST/language settings |
Calendar table or DATEDIFF(DAY, '19000101', d) % 7 + 1 |
| ‘DATETRUNC’ is not a recognized built-in function name. | Older server or compatibility level below 160 | RetailLab uses 170; elsewhere use DATEFROMPARTS(YEAR(d), MONTH(d), 1) |
| Cannot update GENERATED ALWAYS columns in table ‘Lab.ProductListPrice’. | Updating ValidFrom/ValidTo |
Period columns are system-maintained |
| Drop table operation failed on table … because it is not supported operation on system-versioned temporal tables. | Dropping without turning versioning off | ALTER TABLE … SET (SYSTEM_VERSIONING = OFF) first (as the script does) |
| Invalid object name ‘Lab.Calendar’. | Day 12 script not run, or RetailLab was reset |
Run scripts/lessons/day12_calendar_and_temporal.sql |
| Temporal results differ from the lesson | You changed prices (worked example 6) | Re-run the Day 12 script |
Cleanup. Restore the fixed price history by re-running the Day 12 script (it drops and re-creates the three Lab objects):
-- Open and execute: scripts/lessons/day12_calendar_and_temporal.sql
-- Expected last grid: 1096, 760, 24, 29, 10, SYSTEM_VERSIONED_TEMPORAL_TABLE
Keep Lab.Calendar, Lab.ProductListPrice and Lab.ProductListPriceHistory: Days 14, 29 and 30 use them. Core data is unchanged.
J. Completion checklist, recap and references
Completion checklist
- The Day 12 script ran and showed
1096, 760, 24, 29, 10, SYSTEM_VERSIONED_TEMPORAL_TABLE. - Worked example 3 returned 2 with
BETWEENand 3 with the half-open range. - Worked example 6 wrote a history row with today’s UTC time.
- The lab returned 106 shipped orders, 88 within SLA (83.0%).
- Tasks 1–4 match the success criteria; you re-ran the Day 12 script afterwards.
Recap
- Use
datefor dates anddatetime2(n)for timestamps; write'YYYY-MM-DD'literals. DATEDIFFcounts boundaries; compute completed months/years explicitly.DATEADDwith months clamps to month end.- Weekday numbers and names depend on session settings; calendars and fixed arithmetic don’t.
- Filter periods with half-open ranges; avoid functions on filtered columns.
- Calendar tables fill gaps and hold business-day rules.
- System-versioned temporal tables keep every previous row version automatically, stamped with UTC system time; query them with
FOR SYSTEM_TIME AS OF / FROM … TO / BETWEEN / CONTAINED IN / ALL. - System time is not business time.
Next: Day 13 — String functions and pattern matching: LIKE, PATINDEX, STRING_SPLIT, STRING_AGG, CONCAT and friends.
References (Microsoft Learn)
- Date and time data types and functions (Transact-SQL)
- DATEDIFF (Transact-SQL)
- DATEADD (Transact-SQL)
- DATETRUNC (Transact-SQL)
- SET DATEFIRST (Transact-SQL)
- Temporal tables
- Create a system-versioned temporal table — including migrating an existing solution
- Query data in a system-versioned temporal table
- UK bank holidays (GOV.UK) — source of the calendar’s holiday dates