Day 25: Partitioning
On this page
By the end of this lesson you can
- Explain the management and performance benefits of table partitioning — and its limits.
- Create a partition function with correct boundaries and choose between
RANGE LEFTandRANGE RIGHT. - Create a partition scheme and a partitioned table with aligned indexes, and explain why the partitioning column must be in unique keys.
- Count rows per partition with
$PARTITIONand the catalog views. - Show partition elimination in an actual plan, and explain what prevents it.
- Run a sliding window: switch out the oldest year, merge its boundary, split a new one, and switch new data in.
A. Lesson overview
| Week | 4 — Performance and expert topics |
| Level | Advanced |
| Estimated time | 2 h 45 min essential + 30 min optional stretch |
| Prerequisites | Day 22 (plans, the Perf data), Day 23 (indexes) |
| Syllabus topic | Partitioning — partition functions and schemes, boundaries, partition elimination, sliding window |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — What partitioning is for (and not for) | 15 min |
Part 2 — Partition functions, boundaries, RANGE LEFT / RANGE RIGHT |
20 min |
| Part 3 — Partition schemes, aligned indexes, keys | 15 min |
| Part 4 — Partition elimination | 10 min |
Part 5 — SWITCH, SPLIT, MERGE and the sliding window |
20 min |
| Worked examples | 30 min |
| Guided lab (sliding window) | 30 min |
| Independent practice | 20 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. Order history grows by about 250,000 rows a year. Finance needs the last three full years online; older years go to an archive. Deleting a year row by row takes hours and fills the log. Partitioning by order year turns “archive 2022” into a metadata operation that takes moments.
B. Theory explained clearly
Part 1 — Why partition?
Table partitioning stores one table in several partitions by ranges of one column (the partitioning column), while queries still see one table.
| Benefit | Example |
|---|---|
| Data management — the main reason | Move a whole year in or out with SWITCH (metadata only); TRUNCATE a single partition; rebuild or compress one partition |
| Partition elimination | A query for one year reads only that year’s partition |
| Maintenance windows | Rebuild only the current, changing partition |
What it is not: a general speed-up. A query that doesn’t filter on the partitioning column must visit every partition, which can be slower than an unpartitioned table with a good index. For many workloads a good index (Day 23) is the better performance tool. Partitioning is available in all editions of SQL Server 2016 SP1 and later, including Standard Developer.
Part 2 — The partition function
A partition function maps values of the partitioning column to partition numbers using boundary values:
CREATE PARTITION FUNCTION pfOrderYear (date)
AS RANGE RIGHT FOR VALUES ('2023-01-01', '2024-01-01', '2025-01-01', '2026-01-01');
n boundaries create n + 1 partitions. RANGE RIGHT means each boundary belongs to the partition on its right — the boundary is the first value of a partition:
| Partition | RANGE RIGHT holds |
Our data |
|---|---|---|
| 1 | OrderDate < 2023-01-01 |
2022 |
| 2 | 2023-01-01 <= OrderDate < 2024-01-01 |
2023 |
| 3 | 2024-01-01 <= OrderDate < 2025-01-01 |
2024 |
| 4 | 2025-01-01 <= OrderDate < 2026-01-01 |
2025 |
| 5 | OrderDate >= 2026-01-01 |
empty |
With RANGE LEFT, each boundary is the last value of a partition — awkward for dates ('2023-12-31' misses times later that day for datetime2 columns). Use RANGE RIGHT with the first instant of each period for date ranges. Keep an empty partition at each end so you can split without moving data.
Part 3 — Scheme, table and indexes
The partition scheme maps partitions to filegroups. Production systems may spread partitions across filegroups (e.g. older years on cheaper storage); in the training database everything goes to PRIMARY:
CREATE PARTITION SCHEME psOrderYear AS PARTITION pfOrderYear ALL TO ([PRIMARY]);
A table or index is created on the scheme, naming the partitioning column:
CREATE TABLE Perf.SalesOrderPart ( … ,
CONSTRAINT PK_PerfSalesOrderPart PRIMARY KEY CLUSTERED (OrderDate, OrderID)
) ON psOrderYear (OrderDate);
- A unique index (including the primary key) on a partitioned table must include the partitioning column — SQL Server can only check uniqueness within a partition. Here the key is
(OrderDate, OrderID):OrderIDis still unique in practice, but the database only guarantees the pair. - An index partitioned on the same scheme and column is aligned.
SWITCHrequires every index on the table to be aligned.
Part 4 — Partition elimination
When a query filters on the partitioning column with a sargable predicate, SQL Server reads only the partitions that can contain matching rows. In the actual plan, the operator’s properties show Actual Partition Count and Actual Partitions Accessed. Elimination is lost when the column is wrapped in a function (YEAR(OrderDate) = 2025 — Day 24’s sargability again) or when the query doesn’t filter on that column at all.
Part 5 — The sliding window
| Operation | Statement | Cost |
|---|---|---|
| Move a partition out to an empty table | ALTER TABLE t SWITCH PARTITION 1 TO archive; |
Metadata only |
| Move a table’s rows in as a partition | ALTER TABLE stage SWITCH TO t PARTITION 5; |
Metadata only; the staging table needs a CHECK constraint proving its rows fit the partition |
| Remove a boundary | ALTER PARTITION FUNCTION pf() MERGE RANGE ('2023-01-01'); |
Cheap if one of the two partitions is empty; moves data otherwise |
| Add a boundary | ALTER PARTITION SCHEME ps NEXT USED [PRIMARY]; ALTER PARTITION FUNCTION pf() SPLIT RANGE ('2027-01-01'); |
Cheap if the partition being split is empty; moves data otherwise |
SWITCH rules: source and target have the same columns, types, nullability, indexes and filegroup; the target (or target partition) is empty.
Each year: switch out the oldest year, merge its now-empty boundary, split a new empty partition at the end, and switch in new data when it arrives.
C. Dataset and starting state
| Dataset | Perf schema (script 05) |
| Database | RetailLab |
| Read from | Perf.SalesOrder |
| Changed / created | pfOrderYear, psOrderYear, Perf.SalesOrderPart, Perf.SalesOrderArchive, Perf.SalesOrderStage2026 |
| Permissions | CREATE PARTITION FUNCTION/SCHEME (need ALTER ANY DATASPACE; database owner has it), CREATE TABLE, ALTER on the tables |
| Disk | About as much again as Perf.SalesOrder (one copy of the order table) |
Starting state. Perf.SalesOrder with 1,000,000 rows. If the partition objects exist from an earlier attempt, remove them first (safe to run at any time; destructive for these objects only):
USE RetailLab;
GO
DROP TABLE IF EXISTS Perf.SalesOrderPart, Perf.SalesOrderArchive, Perf.SalesOrderStage2026;
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'psOrderYear') DROP PARTITION SCHEME psOrderYear;
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pfOrderYear') DROP PARTITION FUNCTION pfOrderYear;
GO
Validation. Expected: 1000000, 0, 0.
SELECT (SELECT COUNT(*) FROM Perf.SalesOrder) AS Orders,
(SELECT COUNT(*) FROM sys.partition_functions WHERE name = 'pfOrderYear') AS Functions,
(SELECT COUNT(*) FROM sys.tables WHERE name = 'SalesOrderPart' AND schema_id = SCHEMA_ID('Perf')) AS PartTables;
D. Worked examples
Worked example 1 — Function, scheme, table, load
CREATE PARTITION FUNCTION pfOrderYear (date)
AS RANGE RIGHT FOR VALUES ('2023-01-01', '2024-01-01', '2025-01-01', '2026-01-01');
CREATE PARTITION SCHEME psOrderYear AS PARTITION pfOrderYear ALL TO ([PRIMARY]);
CREATE TABLE Perf.SalesOrderPart
(
OrderID int NOT NULL,
CustomerID int NOT NULL,
OrderDate date NOT NULL,
SalesChannel varchar(10) NOT NULL,
OrderStatus varchar(10) NOT NULL,
ShippingCost decimal(10,2) NOT NULL,
Notes nvarchar(200) NULL,
CONSTRAINT PK_PerfSalesOrderPart PRIMARY KEY CLUSTERED (OrderDate, OrderID)
) ON psOrderYear (OrderDate);
INSERT INTO Perf.SalesOrderPart WITH (TABLOCK)
(OrderID, CustomerID, OrderDate, SalesChannel, OrderStatus, ShippingCost, Notes)
SELECT OrderID, CustomerID, OrderDate, SalesChannel, OrderStatus, ShippingCost, Notes
FROM Perf.SalesOrder;
SELECT $PARTITION.pfOrderYear(OrderDate) AS PartitionNumber,
MIN(OrderDate) AS FirstDate, MAX(OrderDate) AS LastDate, COUNT(*) AS OrderRows
FROM Perf.SalesOrderPart
GROUP BY $PARTITION.pfOrderYear(OrderDate)
ORDER BY PartitionNumber;
Expected result: Partition scheme ‘psOrderYear’ has been created successfully. ‘PRIMARY’ is marked as the next used filegroup… then 4 rows:
| PartitionNumber | FirstDate | LastDate | OrderRows |
|---|---|---|---|
| 1 | 2022-01-01 | 2022-12-31 | 249834 |
| 2 | 2023-01-01 | 2023-12-31 | 249830 |
| 3 | 2024-01-01 | 2024-12-31 | 250510 |
| 4 | 2025-01-01 | 2025-12-31 | 249826 |
Partition 5 exists but is empty, so GROUP BY doesn’t show it. $PARTITION.pfOrderYear(value) tells you which partition any value belongs to — try SELECT $PARTITION.pfOrderYear('2026-06-30'); → 5.
Worked example 2 — The catalog view
SELECT p.partition_number, prv.value AS LowerBoundary, p.rows
FROM sys.partitions AS p
JOIN sys.indexes AS i ON i.object_id = p.object_id AND i.index_id = p.index_id
JOIN sys.partition_schemes AS ps ON ps.data_space_id = i.data_space_id
LEFT JOIN sys.partition_range_values AS prv
ON prv.function_id = ps.function_id AND prv.boundary_id = p.partition_number - 1
WHERE p.object_id = OBJECT_ID('Perf.SalesOrderPart') AND i.index_id = 1
ORDER BY p.partition_number;
Expected result: 5 rows — 1 NULL 249834, 2 2023-01-01 249830, 3 2024-01-01 250510, 4 2025-01-01 249826, 5 2026-01-01 0. For RANGE RIGHT, boundary n − 1 is partition n’s lower bound (inclusive). sys.partitions.rows is metadata — instant, no scan.
Worked example 3 — Partition elimination
Turn on Include Actual Execution Plan.
SET STATISTICS IO ON;
SELECT COUNT(*) FROM Perf.SalesOrderPart WHERE OrderDate >= '2024-12-01' AND OrderDate < '2025-01-01';
SELECT COUNT(*) FROM Perf.SalesOrderPart WHERE YEAR(OrderDate) = 2024 AND MONTH(OrderDate) = 12;
SELECT OrderID, OrderDate FROM Perf.SalesOrderPart WHERE OrderID = 123456;
SET STATISTICS IO OFF;
Expected result: 21217, 21217, and one row (123456, 2024-02-26). In each plan, open the properties of the clustered index operator:
| Query | Operator | Actual Partition Count | Actual Partitions Accessed |
|---|---|---|---|
| Date range | Clustered Index Seek | 1 | 3 |
YEAR()/MONTH() |
Clustered Index Scan | 5 | 1..5 |
OrderID |
Clustered Index Scan (or seek per partition) | 5 | 1..5 |
The date range touched only 2024’s partition. The function hid the column (no elimination), and the OrderID lookup had to search every partition because OrderID isn’t the leading key — a single-row lookup that was 3 reads on Perf.SalesOrder now costs far more. Partitioning has a price for queries that don’t use the partitioning column.
E. Guided practical lab — A sliding window
Archive 2022, remove its boundary, and prepare a partition for 2027.
Step 1 — Create the archive table
- Do: An empty, non-partitioned table with exactly the same structure, on the same filegroup.
- Run:
USE RetailLab; GO CREATE TABLE Perf.SalesOrderArchive ( OrderID int NOT NULL, CustomerID int NOT NULL, OrderDate date NOT NULL, SalesChannel varchar(10) NOT NULL, OrderStatus varchar(10) NOT NULL, ShippingCost decimal(10,2) NOT NULL, Notes nvarchar(200) NULL, CONSTRAINT PK_PerfSalesOrderArchive PRIMARY KEY CLUSTERED (OrderDate, OrderID) ) ON [PRIMARY]; - Observe: Commands completed successfully.
- Why it matters:
SWITCHonly re-points metadata, so the structures must match exactly. - Confirm:
SELECT COUNT(*) FROM Perf.SalesOrderArchive;→ 0.
Step 2 — Switch 2022 out
- Do: Move partition 1 to the archive table.
- Run:
SET STATISTICS TIME ON; ALTER TABLE Perf.SalesOrderPart SWITCH PARTITION 1 TO Perf.SalesOrderArchive; SET STATISTICS TIME OFF; SELECT (SELECT COUNT(*) FROM Perf.SalesOrderArchive) AS ArchivedRows, (SELECT MIN(OrderDate) FROM Perf.SalesOrderPart) AS FirstRemainingDate, (SELECT COUNT(*) FROM Perf.SalesOrderPart) AS RemainingRows; - Observe: elapsed time of a few milliseconds;
249834,2023-01-01,750166. - Why it matters: A quarter of a million rows moved in milliseconds, with almost no log — compare with a
DELETEof 249,834 rows. - Confirm: partition 1 of
Perf.SalesOrderParthas 0 rows (worked example 2’s query).
Step 3 — Merge the empty boundary
- Do: Remove the
2023-01-01boundary. - Run:
ALTER PARTITION FUNCTION pfOrderYear() MERGE RANGE ('2023-01-01'); - Observe: Run worked example 2’s query again: 4 partitions —
1 NULL 249830,2 2024-01-01 250510,3 2025-01-01 249826,4 2026-01-01 0. - Why it matters: The empty partition and the 2023 partition combined; partition numbers shifted down by one. Because one side was empty, nothing significant had to move.
- Confirm:
SELECT $PARTITION.pfOrderYear('2023-06-30');→ 1.
Step 4 — Split for 2027
- Do: Tell the scheme where the next partition goes, then add a boundary in the empty end partition.
- Run:
ALTER PARTITION SCHEME psOrderYear NEXT USED [PRIMARY]; ALTER PARTITION FUNCTION pfOrderYear() SPLIT RANGE ('2027-01-01'); - Observe: 5 partitions:
1 NULL 249830,2 2024-01-01 250510,3 2025-01-01 249826,4 2026-01-01 0,5 2027-01-01 0. - Why it matters: Splitting the empty last partition is instant. Splitting a partition full of data would physically move rows — always keep an empty partition at the end and split before data arrives.
- Confirm:
SELECT $PARTITION.pfOrderYear('2026-06-30');→ 4.
Step 5 — The table is still one table
- Do: Query across partitions.
- Run:
SELECT YEAR(OrderDate) AS OrderYear, COUNT(*) AS Orders FROM Perf.SalesOrderPart GROUP BY YEAR(OrderDate) ORDER BY OrderYear; - Observe:
2023 249830,2024 250510,2025 249826. - Why it matters: Applications don’t need to know about partitions.
- Confirm: 3 rows.
F. Independent practice
Task 1 — Switch new data in
Create Perf.SalesOrderStage2026 with the same structure as Perf.SalesOrderPart on [PRIMARY], plus CHECK (OrderDate >= '2026-01-01' AND OrderDate < '2027-01-01'). Insert three orders: IDs 1000001–1000003, customers 2, 3 and 4, dated 2026-01-02, Online, Pending, shipping 4.99. Switch the staging table into the 2026 partition.
Success criteria: after the switch, partition 4 has 3 rows; the staging table has 0; SELECT COUNT(*) FROM Perf.SalesOrderPart WHERE OrderDate >= '2026-01-01' → 3.
Task 2 — What blocks a switch?
Create an aligned nonclustered index on Perf.SalesOrderPart (CustomerID) (on psOrderYear (OrderDate)). Try to switch partition 1 (now 2023) to a new empty archive table Perf.SalesOrderArchive2023 that lacks that index. Read the error, add the matching index to the archive table, and switch again.
Success criteria: the first attempt fails with an error saying the target table lacks an index matching the source; the second succeeds with 249,830 rows archived.
Task 3 — Elimination with a join
Count customer 1’s orders in 2024 from Perf.SalesOrderPart joined to Perf.Customer, filtering on o.OrderDate. Then filter on a date range coming from a joined calendar-like table instead (e.g. JOIN (VALUES (CAST('2024-01-01' AS date), CAST('2025-01-01' AS date))) AS r(FromDate, ToDate) ON o.OrderDate >= r.FromDate AND o.OrderDate < r.ToDate). Compare Actual Partitions Accessed.
Success criteria: 12529 both times. The literal filter accesses only the 2024 partition. The join version may still eliminate (SQL Server can use dynamic elimination at run time) — check the plan rather than assuming; with the literal range, elimination is decided at compile time.
Stretch challenge — Automate the window
Write Perf.usp_SlideOrderWindow @ArchiveYear int that, in one transaction: checks that the oldest partition holds exactly @ArchiveYear, switches it to an empty archive table, merges its upper boundary, and splits a new boundary for the year after the current last boundary. Make it fail safely (Day 20 pattern) if anything doesn’t match.
Success criteria: after running it for 2023 (following tasks 1–2 you may need to reset — see section C), the oldest remaining year is 2024 and a boundary for 2028-01-01 exists. Running it again for 2023 fails with a clear error and changes nothing.
G. Solutions and explanations
Show solution 1
CREATE TABLE Perf.SalesOrderStage2026
(
OrderID int NOT NULL,
CustomerID int NOT NULL,
OrderDate date NOT NULL,
SalesChannel varchar(10) NOT NULL,
OrderStatus varchar(10) NOT NULL,
ShippingCost decimal(10,2) NOT NULL,
Notes nvarchar(200) NULL,
CONSTRAINT PK_PerfSalesOrderStage2026 PRIMARY KEY CLUSTERED (OrderDate, OrderID),
CONSTRAINT CK_PerfSalesOrderStage2026_Year CHECK (OrderDate >= '2026-01-01' AND OrderDate < '2027-01-01')
) ON [PRIMARY];
INSERT INTO Perf.SalesOrderStage2026 (OrderID, CustomerID, OrderDate, SalesChannel, OrderStatus, ShippingCost)
VALUES (1000001, 2, '2026-01-02', 'Online', 'Pending', 4.99),
(1000002, 3, '2026-01-02', 'Online', 'Pending', 4.99),
(1000003, 4, '2026-01-02', 'Online', 'Pending', 4.99);
ALTER TABLE Perf.SalesOrderStage2026 SWITCH TO Perf.SalesOrderPart PARTITION 4;
SELECT (SELECT COUNT(*) FROM Perf.SalesOrderStage2026) AS StageRows,
(SELECT COUNT(*) FROM Perf.SalesOrderPart WHERE OrderDate >= '2026-01-01') AS Rows2026;
Reasoning. The CHECK constraint is SQL Server’s proof that every staging row belongs in partition 4; without it (or if it’s not trusted — created WITH NOCHECK), the switch fails. Loading into a separate table and switching in means readers of the main table never see a half-loaded year. PARTITION 4 could also be written PARTITION $PARTITION.pfOrderYear('2026-01-01').
Show solution 2
CREATE INDEX IX_PerfSalesOrderPart_CustomerID ON Perf.SalesOrderPart (CustomerID) ON psOrderYear (OrderDate);
CREATE TABLE Perf.SalesOrderArchive2023
(
OrderID int NOT NULL, CustomerID int NOT NULL, OrderDate date NOT NULL,
SalesChannel varchar(10) NOT NULL, OrderStatus varchar(10) NOT NULL,
ShippingCost decimal(10,2) NOT NULL, Notes nvarchar(200) NULL,
CONSTRAINT PK_PerfSalesOrderArchive2023 PRIMARY KEY CLUSTERED (OrderDate, OrderID)
) ON [PRIMARY];
ALTER TABLE Perf.SalesOrderPart SWITCH PARTITION 1 TO Perf.SalesOrderArchive2023; -- fails
CREATE INDEX IX_PerfSalesOrderArchive2023_CustomerID ON Perf.SalesOrderArchive2023 (CustomerID);
ALTER TABLE Perf.SalesOrderPart SWITCH PARTITION 1 TO Perf.SalesOrderArchive2023; -- succeeds
SELECT COUNT(*) AS Archived2023 FROM Perf.SalesOrderArchive2023;
Reasoning. Every index is switched along with the data, so the target must have the same indexes. Creating the index ON psOrderYear (OrderDate) makes it aligned; a non-aligned index on the partitioned table would block every switch. After this task, merge the now-empty boundary as in the lab (MERGE RANGE ('2024-01-01')) if you continue.
Show solution 3
SELECT COUNT(*)
FROM Perf.SalesOrderPart AS o
JOIN Perf.Customer AS c ON c.CustomerID = o.CustomerID
WHERE c.CustomerID = 1 AND o.OrderDate >= '2024-01-01' AND o.OrderDate < '2025-01-01';
SELECT COUNT(*)
FROM Perf.SalesOrderPart AS o
JOIN Perf.Customer AS c ON c.CustomerID = o.CustomerID
JOIN (VALUES (CAST('2024-01-01' AS date), CAST('2025-01-01' AS date))) AS r (FromDate, ToDate)
ON o.OrderDate >= r.FromDate AND o.OrderDate < r.ToDate
WHERE c.CustomerID = 1;
Reasoning. Static elimination happens when the optimiser can see constant boundaries. With values from another table or parameters, SQL Server can still eliminate at execution time for seeks; the plan’s Actual Partitions Accessed is the evidence. (If you did task 2 and merged, 2024 may now be partition 1 — the count is the same.)
Show the stretch solution
CREATE OR ALTER PROCEDURE Perf.usp_SlideOrderWindow
@ArchiveYear int
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @Lower date = DATEFROMPARTS(@ArchiveYear, 1, 1),
@Upper date = DATEFROMPARTS(@ArchiveYear + 1, 1, 1),
@LastBoundary date, @NewBoundary date, @Err nvarchar(2048);
BEGIN TRY
BEGIN TRANSACTION;
-- 1. The oldest partition must hold exactly @ArchiveYear
IF $PARTITION.pfOrderYear(@Lower) <> 1
OR NOT EXISTS (SELECT 1 FROM sys.partition_range_values AS v
JOIN sys.partition_functions AS f ON f.function_id = v.function_id
WHERE f.name = 'pfOrderYear' AND v.boundary_id = 1 AND CAST(v.value AS date) = @Upper)
OR EXISTS (SELECT 1 FROM Perf.SalesOrderPart WHERE OrderDate < @Lower)
BEGIN
SET @Err = FORMATMESSAGE(N'The oldest partition does not hold exactly the year %d.', @ArchiveYear);
THROW 50500, @Err, 1;
END;
-- 2. Empty archive table with matching structure
DROP TABLE IF EXISTS Perf.SalesOrderArchive;
CREATE TABLE Perf.SalesOrderArchive
(
OrderID int NOT NULL, CustomerID int NOT NULL, OrderDate date NOT NULL,
SalesChannel varchar(10) NOT NULL, OrderStatus varchar(10) NOT NULL,
ShippingCost decimal(10,2) NOT NULL, Notes nvarchar(200) NULL,
CONSTRAINT PK_PerfSalesOrderArchive PRIMARY KEY CLUSTERED (OrderDate, OrderID)
) ON [PRIMARY];
-- 3. Switch out, merge, split
ALTER TABLE Perf.SalesOrderPart SWITCH PARTITION 1 TO Perf.SalesOrderArchive;
ALTER PARTITION FUNCTION pfOrderYear() MERGE RANGE (@Upper);
SELECT @LastBoundary = MAX(CAST(v.value AS date))
FROM sys.partition_range_values AS v
JOIN sys.partition_functions AS f ON f.function_id = v.function_id
WHERE f.name = 'pfOrderYear';
SET @NewBoundary = DATEADD(YEAR, 1, @LastBoundary);
ALTER PARTITION SCHEME psOrderYear NEXT USED [PRIMARY];
ALTER PARTITION FUNCTION pfOrderYear() SPLIT RANGE (@NewBoundary);
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO
-- Reset to the lab's end state if needed (section C, worked example 1, lab steps 1–4), then:
EXEC Perf.usp_SlideOrderWindow @ArchiveYear = 2023;
EXEC Perf.usp_SlideOrderWindow @ArchiveYear = 2023; -- fails: 50500, nothing changes
Reasoning. Boundary values accept variables. The safety check compares the function’s first boundary with the year’s upper bound and makes sure no older rows exist. Partition DDL is transactional, so a failure part-way rolls everything back. The procedure drops and recreates Perf.SalesOrderArchive — in production you’d keep each archived year (for example by switching into a per-year archive table, or by switching the archive into its own partitioned history table). The procedure assumes no extra nonclustered indexes on Perf.SalesOrderPart; drop task 2’s index first.
H. Knowledge check
-
Understanding. With
RANGE RIGHT FOR VALUES ('2024-01-01', '2025-01-01'), which partition holds2024-01-01, and how many partitions are there? -
Understanding. Why must the primary key of
Perf.SalesOrderPartincludeOrderDate? -
Query interpretation. A plan on a partitioned table shows Actual Partitions Accessed: 1..5 for a query filtering on
YEAR(OrderDate) = 2025. Why, and what’s the fix? -
Debugging.
ALTER TABLE stage SWITCH TO t PARTITION 4fails, complaining that aCHECKconstraint on the source doesn’t allow values outside the target partition’s range. The constraint exists. What do you check? -
Understanding. Why should you split a new boundary before data for that period arrives?
Show the answers
-
Partition 2 (
RANGE RIGHT: the boundary is the first value of the partition to its right). There are 3 partitions. -
SQL Server enforces uniqueness of a partitioned index within each partition, so a unique (or primary key) index must include the partitioning column to guarantee uniqueness across the table.
-
The function hides the partitioning column, so SQL Server can’t map the predicate to partitions (no elimination) — Day 24’s sargability. Use
OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01'. -
That the constraint’s range exactly fits the partition (
>=lower,<upper forRANGE RIGHT), that it disallows NULLs or the column isNOT NULL, and that it is trusted (sys.check_constraints.is_not_trusted = 0; recreate itWITH CHECKif not). -
Splitting an empty partition is a metadata change. Splitting a partition that already holds data moves rows into the new partition — slow, heavily logged and lock-intensive on a large table.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Column ‘OrderDate’ is partitioning column of the index… Partition columns for a unique index must be a subset of the index key. | Unique key without the partitioning column | Include OrderDate in the key |
| ALTER TABLE SWITCH statement failed. The target table … must be empty. | Archive table already holds rows | Use a new or emptied archive table |
SWITCH fails: index mismatch |
Indexes differ between source and target | Create matching indexes |
SWITCH fails: different filegroup |
Target on another filegroup | Create the target ON the same filegroup as the partition |
| The partition scheme … does not have any next used filegroup. | SPLIT without NEXT USED |
ALTER PARTITION SCHEME … NEXT USED [PRIMARY]; |
| Partition function … is being used by one or more partition schemes. (on drop) | Dropping in the wrong order | Drop tables, then the scheme, then the function |
Cleanup — end of the performance block. Remove today’s objects (destructive for these objects only):
DROP PROCEDURE IF EXISTS Perf.usp_SlideOrderWindow;
DROP TABLE IF EXISTS Perf.SalesOrderPart, Perf.SalesOrderArchive, Perf.SalesOrderArchive2023, Perf.SalesOrderStage2026;
IF EXISTS (SELECT 1 FROM sys.partition_schemes WHERE name = 'psOrderYear') DROP PARTITION SCHEME psOrderYear;
IF EXISTS (SELECT 1 FROM sys.partition_functions WHERE name = 'pfOrderYear') DROP PARTITION FUNCTION pfOrderYear;
Freeing disk space (optional). Day 30’s monitoring exercise works better with the Perf data present but doesn’t require it. If space is tight, drop all Perf tables by re-running the first part of script 05 — or simply leave them; script 05 can rebuild everything at any time. Lab.PerfBaseline can be dropped when you no longer need your measurements.
J. Completion checklist, recap and references
Completion checklist
- Rows per partition: 249834 / 249830 / 250510 / 249826 / 0.
- Elimination: the date range accessed 1 partition;
YEAR()andOrderIDaccessed all 5. - Switching out 2022 took milliseconds; the archive has 249,834 rows.
- After merge and split: boundaries 2024-01-01, 2025-01-01, 2026-01-01, 2027-01-01.
- Task 1 switched 3 rows into the 2026 partition.
Recap
- Partitioning is mainly a data-management tool; it speeds up queries that filter on the partitioning column and can slow those that don’t.
RANGE RIGHTwith period start dates; keep empty partitions at both ends.- Unique keys must include the partitioning column; aligned indexes are needed for
SWITCH. - Elimination needs sargable predicates on the partitioning column — check Actual Partitions Accessed.
- Sliding window: switch out, merge an empty boundary,
NEXT USED+ split an empty partition, switch in with a trustedCHECK.
Next: Day 26 — Advanced T-SQL features: sequences, synonyms, schemas and permissions, computed and persisted columns, and Dynamic Data Masking.