SQL Server 2017 Complete Playground
June 2026 | 35 min read | SQL Server, T-SQL, Database | — views
A hands-on reference covering every major SQL Server topic — from database design to In-Memory OLTP
This article walks you through a complete SQL Server 2017 playground built from scratch: a real-world database called TechoralDB with schemas, data, and T-SQL scripts covering every topic that matters — whether you are preparing for interviews, upskilling as a developer, or sharpening your DBA toolkit. Every snippet below is copy-paste-ready and runnable in SSMS.
New to SQL Server? First read the SQL Server Download & Install Guide — free Developer Edition setup, SSMS, Azure Data Studio and post-install config, all in under 30 minutes.
Filter by skill level:
What We Build
TechoralDB — The Playground Schema
- HR schema — Departments, Employees (with hierarchy)
- Sales schema — Customers, Orders, OrderItems, FactOrders
- Inventory schema — Products (with JSON Specs)
- Audit schema — EmployeeAudit, OrderAudit
- Reports schema — search procedures, views
Topics Covered (14 script files)
- Database & schema setup
- Tables, constraints, data types
- INSERT, MERGE, OUTPUT clause
- All JOIN types + set operators
- CTEs including recursive
- PIVOT
- Window / analytic functions
- Stored procedures + TVP
- UDFs + inline/multi-stmt TVFs
- Views + indexed views
- DML & DDL triggers
- Transactions + isolation levels
- Indexes + Query Store
- JSON, XML, Temporal tables
- Security: RLS, DDM, Audit
- Backup & Point-in-Time Restore
- Table partitioning + Full-Text
- In-Memory OLTP (Hekaton) + CDC
1. Database & Schema Setup
We create TechoralDB with two data filegroups (primary + archive),
a dedicated log file, Query Store enabled, and five business schemas.
Create the database with multiple filegroups
CREATE DATABASE TechoralDB
ON PRIMARY
(
NAME = 'TechoralDB_Data',
FILENAME = 'C:\...\TechoralDB.mdf',
SIZE = 64MB, MAXSIZE = 1GB, FILEGROWTH = 64MB
),
FILEGROUP FG_Archive
(
NAME = 'TechoralDB_Archive',
FILENAME = 'C:\...\TechoralDB_archive.ndf',
SIZE = 32MB, MAXSIZE = 512MB, FILEGROWTH = 32MB
)
LOG ON
(
NAME = 'TechoralDB_Log',
FILENAME = 'C:\...\TechoralDB.ldf',
SIZE = 32MB, MAXSIZE = 512MB, FILEGROWTH = 32MB
);
Enable Query Store + set compatibility level
ALTER DATABASE TechoralDB SET COMPATIBILITY_LEVEL = 140; -- SQL Server 2017
ALTER DATABASE TechoralDB SET RECOVERY SIMPLE;
ALTER DATABASE TechoralDB SET QUERY_STORE = ON
(
OPERATION_MODE = READ_WRITE,
CLEANUP_POLICY = (STALE_QUERY_THRESHOLD_DAYS = 30),
MAX_STORAGE_SIZE_MB = 100
);
-- Business schemas
CREATE SCHEMA Sales; CREATE SCHEMA HR;
CREATE SCHEMA Inventory; CREATE SCHEMA Audit;
CREATE SCHEMA Reports;
2. Tables, Constraints & Data Types
Good schema design starts with the right constraints. We use every type: PRIMARY KEY, FOREIGN KEY (including self-referencing), UNIQUE, CHECK, DEFAULT, computed (persisted) columns, IDENTITY, and SEQUENCE.
Employees table — self-referencing FK + computed column
CREATE TABLE HR.Employees
(
EmpID INT NOT NULL IDENTITY(1000,1),
DeptID INT NOT NULL,
FirstName NVARCHAR(50) NOT NULL,
LastName NVARCHAR(50) NOT NULL,
Salary DECIMAL(12,2) NOT NULL,
ManagerID INT NULL, -- self-referencing FK
FullName AS (FirstName + ' ' + LastName) PERSISTED, -- computed
IsActive BIT NOT NULL DEFAULT 1,
UpdatedAt DATETIME2(3) NOT NULL DEFAULT SYSDATETIME(),
CONSTRAINT PK_Employees PRIMARY KEY (EmpID),
CONSTRAINT FK_Emp_Dept FOREIGN KEY (DeptID) REFERENCES HR.Departments(DeptID),
CONSTRAINT FK_Emp_Mgr FOREIGN KEY (ManagerID) REFERENCES HR.Employees(EmpID),
CONSTRAINT UQ_Email UNIQUE (Email),
CONSTRAINT CK_Salary CHECK (Salary > 0)
);
SEQUENCE — reusable auto-number (alternative to IDENTITY)
CREATE SEQUENCE Sales.OrderNumberSeq
START WITH 100001 INCREMENT BY 1
MINVALUE 100001 MAXVALUE 9999999
CYCLE;
-- Use it as a default:
CREATE TABLE Sales.Orders
(
OrderID INT NOT NULL DEFAULT (NEXT VALUE FOR Sales.OrderNumberSeq),
RowVersion ROWVERSION, -- for optimistic concurrency
...
);
Computed (persisted) line total in OrderItems
LineTotal AS (Quantity * UnitPrice * (1 - Discount)) PERSISTED
PERSISTED stores the value physically — it is updated on write
and read instantly without recalculation. Omit PERSISTED to compute on
every read instead.
3. MERGE (Upsert) & OUTPUT Clause
MERGE lets you INSERT, UPDATE, and DELETE in a single statement based on
a source. The OUTPUT clause captures what was actually changed — great for
audit trails.
MERGE — stock upsert
MERGE Inventory.Products AS target
USING #StockUpdate AS source
ON target.ProductCode = source.ProductCode
WHEN MATCHED THEN
UPDATE SET StockQty = source.NewQty
WHEN NOT MATCHED BY TARGET THEN
INSERT (ProductCode, ProductName, Category, UnitPrice, StockQty, ReorderLevel)
VALUES (source.ProductCode, 'Unknown', 'Other', 0, source.NewQty, 5)
OUTPUT $action AS MergeAction,
INSERTED.ProductCode,
INSERTED.StockQty;
OUTPUT — capture inserted IDs into a table variable
DECLARE @InsertedOrders TABLE (OrderID INT, CustomerID INT);
INSERT INTO Sales.Orders (CustomerID, EmpID, Status)
OUTPUT INSERTED.OrderID, INSERTED.CustomerID INTO @InsertedOrders
VALUES (1, 1003, 'Pending');
SELECT * FROM @InsertedOrders;
4. JOINs, CTEs, Recursive Queries & PIVOT
Mastering JOIN types and CTEs is the foundation of analytical SQL. Below are the patterns you will use every day — plus a recursive CTE for org-chart traversal and a PIVOT for cross-tab reports.
All JOIN types in one query
-- INNER: employees with department + manager name
SELECT e.FullName, d.DeptName, m.FullName AS Manager
FROM HR.Employees e
INNER JOIN HR.Departments d ON e.DeptID = d.DeptID
LEFT JOIN HR.Employees m ON e.ManagerID = m.EmpID;
-- FULL OUTER: products and order items — unmatched on both sides
SELECT p.ProductCode, oi.OrderID, oi.Quantity
FROM Inventory.Products p
FULL OUTER JOIN Sales.OrderItems oi ON p.ProductID = oi.ProductID;
-- CROSS JOIN: cartesian product (every employee × department)
SELECT e.FullName, d.DeptName
FROM HR.Employees e CROSS JOIN HR.Departments d;
Recursive CTE — org-chart traversal
WITH OrgChart AS
(
-- Anchor: top-level (no manager)
SELECT EmpID, FullName, JobTitle, ManagerID, 0 AS Level,
CAST(FullName AS NVARCHAR(1000)) AS Path
FROM HR.Employees WHERE ManagerID IS NULL
UNION ALL
-- Recursive: reports to someone already in result
SELECT e.EmpID, e.FullName, e.JobTitle, e.ManagerID,
oc.Level + 1,
CAST(oc.Path + ' > ' + e.FullName AS NVARCHAR(1000))
FROM HR.Employees e
INNER JOIN OrgChart oc ON e.ManagerID = oc.EmpID
)
SELECT REPLICATE(' ', Level) + FullName AS OrgTree,
JobTitle, Level, Path
FROM OrgChart
OPTION (MAXRECURSION 10);
PIVOT — monthly revenue by product category
SELECT *
FROM
(
SELECT p.Category,
DATENAME(MONTH, o.OrderDate) AS SaleMonth,
oi.LineTotal
FROM Sales.OrderItems oi
INNER JOIN Sales.Orders o ON oi.OrderID = o.OrderID
INNER JOIN Inventory.Products p ON oi.ProductID = p.ProductID
WHERE o.Status <> 'Cancelled'
) AS src
PIVOT
(
SUM(LineTotal)
FOR SaleMonth IN ([January],[February],[March],[April],[May],[June])
) AS pvt;
5. Window / Analytic Functions
Window functions operate on a set of rows related to the current row without collapsing them into a single result — they are the most powerful SQL feature for analytics.
Ranking functions within departments
SELECT
e.FullName, d.DeptName, e.Salary,
ROW_NUMBER() OVER (PARTITION BY e.DeptID ORDER BY e.Salary DESC) AS RowNum,
RANK() OVER (PARTITION BY e.DeptID ORDER BY e.Salary DESC) AS SalaryRank,
DENSE_RANK() OVER (PARTITION BY e.DeptID ORDER BY e.Salary DESC) AS DenseRank,
NTILE(4) OVER (PARTITION BY e.DeptID ORDER BY e.Salary DESC) AS Quartile
FROM HR.Employees e
INNER JOIN HR.Departments d ON e.DeptID = d.DeptID;
LEAD / LAG — month-over-month revenue change
WITH Monthly AS (
SELECT YEAR(OrderDate) yr, MONTH(OrderDate) mo,
SUM(TotalAmount) Revenue
FROM Sales.Orders WHERE Status <> 'Cancelled'
GROUP BY YEAR(OrderDate), MONTH(OrderDate)
)
SELECT yr, mo, Revenue,
LAG(Revenue) OVER (ORDER BY yr, mo) AS PrevMonth,
LEAD(Revenue) OVER (ORDER BY yr, mo) AS NextMonth,
Revenue - LAG(Revenue) OVER (ORDER BY yr, mo) AS MoMChange,
FORMAT(100.0 * (Revenue - LAG(Revenue) OVER (ORDER BY yr, mo))
/ NULLIF(LAG(Revenue) OVER (ORDER BY yr, mo), 0), 'N2') + '%' AS MoMPct
FROM Monthly ORDER BY yr, mo;
Running total + 3-day moving average
SELECT SaleDate, DailyRevenue,
SUM(DailyRevenue) OVER (
ORDER BY SaleDate ROWS UNBOUNDED PRECEDING
) AS CumulativeRevenue,
AVG(DailyRevenue) OVER (
ORDER BY SaleDate ROWS BETWEEN 2 PRECEDING AND CURRENT ROW
) AS Avg3Day
FROM DailySales;
6. Stored Procedures
Stored procedures encapsulate business logic server-side. Key patterns:
OUTPUT parameters, TRY/CATCH with transactions, safe dynamic SQL via
sp_executesql, and Table-Valued Parameters (TVP) for bulk operations.
TRY/CATCH + transaction + THROW
CREATE OR ALTER PROCEDURE Sales.usp_AddOrderItem
@OrderID INT, @ProductID INT, @Quantity INT
AS
BEGIN
SET NOCOUNT ON;
BEGIN TRY
BEGIN TRANSACTION;
DECLARE @Price DECIMAL(12,4), @Stock INT;
SELECT @Price = UnitPrice, @Stock = StockQty
FROM Inventory.Products WITH (UPDLOCK, ROWLOCK)
WHERE ProductID = @ProductID;
IF @Price IS NULL THROW 50010, 'Product not found.', 1;
IF @Stock < @Quantity THROW 50011, 'Insufficient stock.', 1;
INSERT INTO Sales.OrderItems (OrderID, ProductID, Quantity, UnitPrice)
VALUES (@OrderID, @ProductID, @Quantity, @Price);
UPDATE Inventory.Products SET StockQty -= @Quantity WHERE ProductID = @ProductID;
UPDATE Sales.Orders
SET TotalAmount = (SELECT SUM(LineTotal) FROM Sales.OrderItems WHERE OrderID = @OrderID)
WHERE OrderID = @OrderID;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW; -- re-raise the original error
END CATCH;
END;
Safe dynamic SQL with sp_executesql (parameterised — no injection)
CREATE OR ALTER PROCEDURE Reports.usp_SearchEmployees
@DeptID INT = NULL, @JobTitle NVARCHAR(100) = NULL,
@MinSalary DECIMAL(12,2) = NULL
AS
BEGIN
DECLARE @sql NVARCHAR(MAX) = N'
SELECT e.FullName, e.JobTitle, e.Salary, d.DeptName
FROM HR.Employees e INNER JOIN HR.Departments d ON e.DeptID = d.DeptID
WHERE e.IsActive = 1';
IF @DeptID IS NOT NULL SET @sql += N' AND e.DeptID = @pDeptID';
IF @JobTitle IS NOT NULL SET @sql += N' AND e.JobTitle LIKE @pJobTitle';
IF @MinSalary IS NOT NULL SET @sql += N' AND e.Salary >= @pMinSalary';
EXEC sp_executesql @sql,
N'@pDeptID INT, @pJobTitle NVARCHAR(100), @pMinSalary DECIMAL(12,2)',
@pDeptID = @DeptID, @pJobTitle = @JobTitle, @pMinSalary = @MinSalary;
END;
Table-Valued Parameter (TVP) — bulk insert order items in one call
-- Step 1: define the type
CREATE TYPE Sales.OrderItemsTableType AS TABLE
(
ProductID INT, Quantity INT, Discount DECIMAL(5,4) DEFAULT 0
);
-- Step 2: procedure accepts it as READONLY
CREATE OR ALTER PROCEDURE Sales.usp_AddBulkOrderItems
@OrderID INT, @Items Sales.OrderItemsTableType READONLY
AS
BEGIN
INSERT INTO Sales.OrderItems (OrderID, ProductID, Quantity, UnitPrice, Discount)
SELECT @OrderID, i.ProductID, i.Quantity, p.UnitPrice, i.Discount
FROM @Items i INNER JOIN Inventory.Products p ON i.ProductID = p.ProductID;
END;
-- Step 3: call from client/SSMS
DECLARE @items Sales.OrderItemsTableType;
INSERT INTO @items VALUES (1, 2, 0), (3, 1, 0.05);
EXEC Sales.usp_AddBulkOrderItems @OrderID = 100001, @Items = @items;
7. Functions, APPLY & Indexed Views
Prefer inline TVFs over scalar UDFs wherever possible — the optimizer
can inline them into the query plan. Use CROSS APPLY / OUTER APPLY
to call them per-row like a correlated subquery. Indexed (materialised) views speed up
repeated aggregate reads.
Inline TVF + CROSS APPLY
-- Inline TVF
CREATE OR ALTER FUNCTION Sales.fn_CustomerOrders (@CustomerID INT)
RETURNS TABLE WITH SCHEMABINDING AS
RETURN (
SELECT o.OrderID, o.OrderDate, o.Status, o.TotalAmount,
COUNT(oi.OrderItemID) AS ItemCount
FROM Sales.Orders o
LEFT JOIN Sales.OrderItems oi ON o.OrderID = oi.OrderID
WHERE o.CustomerID = @CustomerID
GROUP BY o.OrderID, o.OrderDate, o.Status, o.TotalAmount
);
-- CROSS APPLY: call per customer row (like a correlated subquery)
SELECT c.FirstName, c.LastName, co.*
FROM Sales.Customers c
CROSS APPLY Sales.fn_CustomerOrders(c.CustomerID) co;
-- OUTER APPLY: include customers with zero orders
SELECT c.FirstName, co.OrderID
FROM Sales.Customers c
OUTER APPLY Sales.fn_CustomerOrders(c.CustomerID) co;
Indexed (materialised) view — pre-aggregate order stats
CREATE OR ALTER VIEW Sales.vw_ProductSalesSummary
WITH SCHEMABINDING AS
SELECT oi.ProductID,
COUNT_BIG(*) AS OrderLineCount,
SUM(oi.Quantity) AS TotalQtySold,
SUM(oi.LineTotal) AS TotalRevenue
FROM Sales.OrderItems oi
GROUP BY oi.ProductID;
-- The unique clustered index materialises it on disk
CREATE UNIQUE CLUSTERED INDEX IX_vw_ProductSalesSummary
ON Sales.vw_ProductSalesSummary (ProductID);
SQL Server automatically uses this view when the optimizer
detects a matching aggregate query on OrderItems — even if your query
doesn't reference the view directly.
Get all 14 SQL scripts
Copy-paste ready .sql files covering every topic in this article. Run them in SSMS in order and have a full playground in minutes.
8. Triggers
Triggers fire automatically on DML or DDL events. The special
INSERTED and DELETED pseudo-tables hold the new and old
row values respectively.
AFTER UPDATE trigger — full JSON audit snapshot
CREATE OR ALTER TRIGGER HR.trg_Employee_Audit
ON HR.Employees AFTER INSERT, UPDATE, DELETE
AS
BEGIN
SET NOCOUNT ON;
-- UPDATE: capture salary / title changes
IF EXISTS (SELECT 1 FROM inserted) AND EXISTS (SELECT 1 FROM deleted)
BEGIN
INSERT INTO Audit.EmployeeAudit
(EmpID, Action, OldSalary, NewSalary, OldValues, NewValues)
SELECT i.EmpID, 'UPDATE', d.Salary, i.Salary,
(SELECT d.FullName, d.JobTitle, d.Salary
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
(SELECT i.FullName, i.JobTitle, i.Salary
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)
FROM inserted i INNER JOIN deleted d ON i.EmpID = d.EmpID
WHERE i.Salary <> d.Salary OR i.JobTitle <> d.JobTitle;
END
END;
DDL trigger — protect production schema from accidental drops
CREATE OR ALTER TRIGGER Safety.trg_PreventDrop
ON DATABASE FOR DROP_TABLE, ALTER_TABLE
AS
BEGIN
RAISERROR('Schema changes are locked. Contact the DBA.', 16, 1);
ROLLBACK;
END;
-- Disable it when you need to make changes:
DISABLE TRIGGER Safety.trg_PreventDrop ON DATABASE;
9. Transactions & Isolation Levels
SQL Server supports five isolation levels. Understanding the difference between locking-based and row-versioning-based isolation is critical for high-concurrency applications.
SAVEPOINT — partial rollback within a transaction
BEGIN TRANSACTION;
UPDATE HR.Employees SET Salary = 300000 WHERE EmpID = 1000;
SAVE TRANSACTION sp_ceo_raise; -- mark a rollback point
UPDATE HR.Departments SET Budget += 500000 WHERE DeptCode = 'ENGG';
IF (SELECT Budget FROM HR.Departments WHERE DeptCode = 'ENGG') > 99999999
BEGIN
ROLLBACK TRANSACTION sp_ceo_raise; -- only undo the budget change
PRINT 'Budget rolled back, CEO raise preserved.';
END
COMMIT TRANSACTION;
Isolation level quick reference
-- READ UNCOMMITTED: dirty reads, fastest
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- READ COMMITTED (default): only committed rows
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- REPEATABLE READ: rows you've read can't be changed by others
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- SERIALIZABLE: strictest — no phantom rows
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- SNAPSHOT: readers don't block writers (row versioning)
ALTER DATABASE TechoralDB SET ALLOW_SNAPSHOT_ISOLATION ON;
ALTER DATABASE TechoralDB SET READ_COMMITTED_SNAPSHOT ON;
-- Once RCSI is ON, no code changes needed — READ COMMITTED
-- automatically uses row versions. Zero blocking.
Optimistic concurrency with ROWVERSION
DECLARE @rv BINARY(8);
DECLARE @oid INT = 100001;
-- Read and snapshot the version token
SELECT @rv = RowVersion FROM Sales.Orders WHERE OrderID = @oid;
-- Update only if nobody else changed the row in between
UPDATE Sales.Orders
SET Status = 'Confirmed'
WHERE OrderID = @oid AND RowVersion = @rv;
IF @@ROWCOUNT = 0
PRINT 'Conflict: row was modified by another session!';
10. Indexes & Performance Tuning
Index design is where most query performance wins (and losses) happen. Know when to use covering indexes, filtered indexes, and columnstore — and how to find what's missing using DMVs.
Covering index + filtered index
-- Covering: INCLUDE columns avoid a key lookup
CREATE NONCLUSTERED INDEX IX_Orders_Customer_Date
ON Sales.Orders (CustomerID, OrderDate DESC)
INCLUDE (Status, TotalAmount);
-- Filtered: only index open orders (much smaller, faster)
CREATE NONCLUSTERED INDEX IX_Orders_Open
ON Sales.Orders (OrderDate, CustomerID)
INCLUDE (Status, TotalAmount)
WHERE Status IN ('Pending', 'Confirmed');
Columnstore index — 10–100× faster analytics
-- Non-clustered columnstore on the fact table
-- (coexists with row-store — no sacrifice for OLTP)
CREATE NONCLUSTERED COLUMNSTORE INDEX NCI_FactOrders_Analytics
ON Sales.FactOrders (OrderDate, Category, Country, LineTotal, Quantity);
-- Analytical query now uses batch-mode execution
SELECT Category, Country, YEAR(OrderDate),
SUM(Quantity), SUM(LineTotal)
FROM Sales.FactOrders
GROUP BY Category, Country, YEAR(OrderDate)
ORDER BY SUM(LineTotal) DESC;
Find missing index recommendations (DMV)
SELECT TOP 10
mid.statement AS TableName,
mid.equality_columns, mid.inequality_columns,
mid.included_columns,
migs.user_seeks,
migs.avg_total_user_cost * migs.user_seeks AS ImpactScore,
migs.avg_user_impact AS EstimatedImprovement
FROM sys.dm_db_missing_index_groups mig
INNER JOIN sys.dm_db_missing_index_group_stats migs
ON mig.index_group_handle = migs.group_handle
INNER JOIN sys.dm_db_missing_index_details mid
ON mig.index_handle = mid.index_handle
ORDER BY ImpactScore DESC;
Fragmentation check + maintenance recommendation
SELECT OBJECT_NAME(ips.object_id) AS TableName,
i.name AS IndexName,
ips.avg_fragmentation_in_percent,
CASE
WHEN ips.avg_fragmentation_in_percent > 30 THEN 'REBUILD'
WHEN ips.avg_fragmentation_in_percent > 10 THEN 'REORGANIZE'
ELSE 'OK'
END AS Action
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
INNER JOIN sys.indexes i
ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.page_count > 100
ORDER BY ips.avg_fragmentation_in_percent DESC;
-- Act on the recommendation:
ALTER INDEX IX_Orders_Customer_Date ON Sales.Orders REBUILD;
11. JSON, XML & Temporal Tables
SQL Server 2016+ has native JSON functions. Temporal tables (system-versioned) give you automatic history and time-travel queries with zero application code.
JSON — read, modify, export
-- Read individual fields from a JSON column
SELECT ProductName,
JSON_VALUE(Specs, '$.duration') AS Duration,
JSON_VALUE(Specs, '$.certify') AS Certifies
FROM Inventory.Products WHERE Specs IS NOT NULL;
-- Shred a JSON array into rows (OPENJSON)
SELECT id, name, role, salary
FROM OPENJSON(@json)
WITH (id INT '$.id', name NVARCHAR(50) '$.name',
role NVARCHAR(50) '$.role', salary INT '$.salary');
-- Export query result as JSON with nested arrays
SELECT o.OrderID, o.Status,
(SELECT oi.ProductID, oi.Quantity, oi.LineTotal
FROM Sales.OrderItems oi WHERE oi.OrderID = o.OrderID
FOR JSON PATH) AS Items
FROM Sales.Orders o
FOR JSON PATH, ROOT('Orders');
Temporal table — define once, get free history
CREATE TABLE HR.EmployeeSalaryHistory
(
EmpID INT NOT NULL,
Salary DECIMAL(12,2) NOT NULL,
JobTitle NVARCHAR(100) NOT NULL,
ValidFrom DATETIME2(7) GENERATED ALWAYS AS ROW START NOT NULL,
ValidTo DATETIME2(7) GENERATED ALWAYS AS ROW END NOT NULL,
CONSTRAINT PK_EmpSalaryHistory PRIMARY KEY (EmpID),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
)
WITH (SYSTEM_VERSIONING = ON (
HISTORY_TABLE = HR.EmployeeSalaryHistory_History
));
Time-travel queries — see data as it was
-- State 5 seconds ago
SELECT * FROM HR.EmployeeSalaryHistory
FOR SYSTEM_TIME AS OF DATEADD(SECOND, -5, SYSDATETIME());
-- Full version history for one employee
SELECT EmpID, Salary, JobTitle, ValidFrom, ValidTo
FROM HR.EmployeeSalaryHistory
FOR SYSTEM_TIME ALL
WHERE EmpID = 1001 ORDER BY ValidFrom;
-- What changed in the last 10 seconds
SELECT * FROM HR.EmployeeSalaryHistory
FOR SYSTEM_TIME BETWEEN
DATEADD(SECOND, -10, SYSDATETIME()) AND SYSDATETIME();
12. Security
SQL Server has a rich security model. These are the four features that matter most in production: Row-Level Security (RLS), Dynamic Data Masking (DDM), Column-level permissions, and SQL Server Audit.
Dynamic Data Masking — hide sensitive fields from low-priv users
-- Mask email so unauthorised users see a***@b***.com
ALTER TABLE Sales.Customers
ALTER COLUMN Email ADD MASKED WITH (FUNCTION = 'email()');
-- Mask salary (show 0)
ALTER TABLE HR.Employees
ALTER COLUMN Salary ADD MASKED WITH (FUNCTION = 'default()');
-- Mask phone — show only last 4 digits
ALTER TABLE HR.Employees
ALTER COLUMN Phone ADD MASKED WITH
(FUNCTION = 'partial(0,"XXXX-XXXX-",4)');
-- DBAs still see real data
GRANT UNMASK TO dba_user;
Row-Level Security — sales reps see only their own orders
-- Predicate function (returns 1 = allow access)
CREATE FUNCTION Security2.fn_OrderFilter (@EmpID INT)
RETURNS TABLE WITH SCHEMABINDING AS
RETURN (
SELECT 1 AS AccessGranted
WHERE @EmpID = TRY_CAST(SESSION_CONTEXT(N'EmpID') AS INT)
OR IS_MEMBER('db_owner') = 1
);
-- Attach to the table as a security policy
CREATE SECURITY POLICY Sales.OrderRLSPolicy
ADD FILTER PREDICATE Security2.fn_OrderFilter(EmpID) ON Sales.Orders,
ADD BLOCK PREDICATE Security2.fn_OrderFilter(EmpID) ON Sales.Orders AFTER INSERT
WITH (STATE = ON);
-- Set session context (e.g. from your application login)
EXEC sp_set_session_context N'EmpID', 1004;
SELECT * FROM Sales.Orders; -- automatically sees only EmpID=1004's orders
13. Backup & Point-in-Time Restore
A backup strategy you have never tested is not a backup strategy. Here is the complete chain: FULL → LOG → tail-log → point-in-time restore.
Full backup with compression + checksum
BACKUP DATABASE TechoralDB
TO DISK = 'C:\Temp\TechoralDB_Full.bak'
WITH FORMAT, COMPRESSION, CHECKSUM, STATS = 10,
NAME = 'TechoralDB Full Backup';
Point-in-time restore (3-step process)
-- 1. Tail-log backup (captures last log before restore)
BACKUP LOG TechoralDB
TO DISK = 'C:\Temp\TechoralDB_TailLog.bak'
WITH NORECOVERY, COMPRESSION, CHECKSUM;
-- 2. Restore full backup — leave in RESTORING state
RESTORE DATABASE TechoralDB_Recovered
FROM DISK = 'C:\Temp\TechoralDB_Full.bak'
WITH MOVE '...' TO '...restored.mdf',
NORECOVERY, STATS = 10;
-- 3. Restore log — stop BEFORE the disaster timestamp
RESTORE LOG TechoralDB_Recovered
FROM DISK = 'C:\Temp\TechoralDB_TailLog.bak'
WITH RECOVERY,
STOPAT = '2026-06-03T09:59:59'; -- 1 second before the accident
Integrity check + backup history
-- Full integrity check (run weekly in production)
DBCC CHECKDB ('TechoralDB') WITH NO_INFOMSGS, ALL_ERRORMSGS;
-- View backup history
SELECT bs.backup_start_date,
CASE bs.type WHEN 'D' THEN 'Full'
WHEN 'I' THEN 'Diff'
WHEN 'L' THEN 'Log' END AS BackupType,
bs.compressed_backup_size / 1048576.0 AS CompressedMB
FROM msdb.dbo.backupset bs
WHERE bs.database_name = 'TechoralDB'
ORDER BY bs.backup_start_date DESC;
14. Advanced Topics
Table Partitioning
Partition large tables to prune scans to a single date range — the optimizer will skip irrelevant partitions entirely.
Range partition by year
-- Partition function: defines the boundary values
CREATE PARTITION FUNCTION pf_OrderYear (DATE)
AS RANGE RIGHT FOR VALUES ('2025-01-01','2026-01-01','2027-01-01');
-- Partition scheme: maps partitions to filegroups
CREATE PARTITION SCHEME ps_OrderYear
AS PARTITION pf_OrderYear ALL TO ([PRIMARY]);
-- Partitioned table
CREATE TABLE Sales.OrdersPartitioned (
OrderID INT NOT NULL,
OrderDate DATE NOT NULL,
...
CONSTRAINT PK_OrdersPartitioned PRIMARY KEY (OrderID, OrderDate)
) ON ps_OrderYear (OrderDate);
-- Partition elimination: query touches ONLY the 2026 partition
SELECT COUNT(*), SUM(TotalAmount)
FROM Sales.OrdersPartitioned
WHERE OrderDate >= '2026-01-01' AND OrderDate < '2027-01-01';
Change Data Capture (CDC)
CDC records every INSERT / UPDATE / DELETE on a table into a system change table — without modifying your application code.
Enable CDC and query changes
EXEC sys.sp_cdc_enable_db;
EXEC sys.sp_cdc_enable_table
@source_schema = N'Sales', @source_name = N'Orders',
@role_name = NULL, @supports_net_changes = 1;
-- Query all changes since last LSN
DECLARE @from_lsn BINARY(10) = sys.fn_cdc_get_min_lsn('Sales_Orders');
DECLARE @to_lsn BINARY(10) = sys.fn_cdc_get_max_lsn();
SELECT
CASE __$operation
WHEN 1 THEN 'Delete' WHEN 2 THEN 'Insert'
WHEN 3 THEN 'Before U' WHEN 4 THEN 'After U' END AS Op,
OrderID, Status, TotalAmount
FROM cdc.fn_cdc_get_all_changes_Sales_Orders(@from_lsn, @to_lsn, 'all');
In-Memory OLTP (Hekaton)
Memory-optimised tables and natively compiled stored procedures can deliver 10–30× higher throughput for OLTP hot paths — with no disk I/O for reads or writes.
Memory-optimised table + natively compiled procedure
-- Memory-optimised table (SCHEMA_ONLY = fastest, no durability)
CREATE TABLE Sales.OrdersInMem (
OrderID INT NOT NULL,
CustomerID INT NOT NULL,
Status VARCHAR(20) NOT NULL,
Amount DECIMAL(15,2) NOT NULL,
INDEX IX_Cust NONCLUSTERED (CustomerID),
CONSTRAINT PK_OrdersInMem PRIMARY KEY NONCLUSTERED (OrderID)
) WITH (MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_ONLY);
-- Natively compiled proc — compiles to machine code at CREATE time
CREATE OR ALTER PROCEDURE Sales.usp_InsertOrderInMem
@OrderID INT, @CustomerID INT, @Status VARCHAR(20), @Amount DECIMAL(15,2)
WITH NATIVE_COMPILATION, SCHEMABINDING, EXECUTE AS OWNER
AS
BEGIN ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT,
LANGUAGE = N'us_english')
INSERT INTO Sales.OrdersInMem (OrderID, CustomerID, Status, Amount)
VALUES (@OrderID, @CustomerID, @Status, @Amount);
END;
DMV Monitoring Toolkit
Active sessions + current SQL (DBA first-responder query)
SELECT s.session_id, s.status, s.login_name,
r.wait_type, r.wait_time / 1000.0 AS WaitSec,
r.cpu_time, r.logical_reads,
SUBSTRING(t.text,
(r.statement_start_offset/2)+1,
((CASE r.statement_end_offset WHEN -1 THEN DATALENGTH(t.text)
ELSE r.statement_end_offset END
- r.statement_start_offset)/2)+1) AS CurrentSQL
FROM sys.dm_exec_sessions s
LEFT JOIN sys.dm_exec_requests r ON s.session_id = r.session_id
OUTER APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE s.is_user_process = 1
ORDER BY r.cpu_time DESC;
Quick Reference — All 14 Scripts
| # | Script | Key Topics |
|---|---|---|
| 01 | setup_database | CREATE DATABASE, filegroups, Query Store, schemas |
| 02 | tables_constraints | PK/FK/UNIQUE/CHECK/DEFAULT, computed cols, IDENTITY, SEQUENCE |
| 03 | seed_data | Bulk INSERT, OUTPUT, MERGE upsert, TVP demo data |
| 04 | queries_joins_cte | All JOIN types, EXISTS, recursive CTE, PIVOT, INTERSECT/EXCEPT |
| 05 | window_functions | RANK, LEAD/LAG, FIRST/LAST VALUE, running totals, PERCENT_RANK |
| 06 | stored_procedures | OUTPUT params, TRY/CATCH, sp_executesql, TVP |
| 07 | functions_views | Scalar UDF, inline/multi-stmt TVF, APPLY, indexed view |
| 08 | triggers | DML triggers, INSERTED/DELETED, JSON audit, DDL trigger |
| 09 | transactions_isolation | All isolation levels, SAVEPOINT, SNAPSHOT, ROWVERSION |
| 10 | indexes_performance | Covering/filtered/columnstore, missing index DMVs, Query Store |
| 11 | json_xml_temporal | FOR JSON, OPENJSON, FOR XML, temporal time-travel |
| 12 | security | RLS, Dynamic Data Masking, column DENY, SQL Audit, EXECUTE AS |
| 13 | backup_restore | Full/Diff/Log backup, tail-log, PITR, DBCC CHECKDB |
| 14 | advanced_topics | Partitioning, full-text, CDC, In-Memory OLTP, DMV toolkit |
Key Takeaways
- Always use sp_executesql with parameters for dynamic SQL — never concatenate user input.
- RCSI (Read Committed Snapshot Isolation) eliminates most reader-writer blocking with zero application changes.
- Inline TVFs beat scalar UDFs — the optimizer can unfold them.
- Indexed views materialise aggregates — make repeated analytic reads near-instant.
- Temporal tables give you a full audit history with a single DDL change.
- Columnstore indexes can coexist with row-store and cut analytical query time by 10–100×.
- ROWVERSION is the clean way to do optimistic concurrency without application-side timestamps.
- Always test your backup chain. A tail-log backup before restore is the difference between full recovery and data loss.
Test Your SQL Server Knowledge
5 quick questions. How well do you know T-SQL?
Q1. What does DENSE_RANK() return after two rows tied at rank 1?
Q2. Which isolation level uses row-versioning and requires no code changes for existing queries?
Q3. You need to store every historical version of a row automatically. Which SQL Server feature do you use?
Q4. What is the primary advantage of an inline TVF over a scalar UDF?
Q5. Which index type gives the biggest analytical query speedup for aggregations over millions of rows?
Frequently Asked Questions
Was this article helpful?
Related Articles
Quick Navigation
- Overview & schema
- 1. Database setup
- 2. Tables & constraints
- 3. MERGE & OUTPUT
- 4. JOINs, CTEs, PIVOT
- 5. Window functions
- 6. Stored procedures
- 7. Functions & views
- 8. Triggers
- 9. Transactions & isolation
- 10. Indexes & performance
- 11. JSON, XML & Temporal
- 12. Security
- 13. Backup & Restore
- 14. Advanced topics
- Quick reference table
Article Info
- Level: Beginner → Advanced
- Database: SQL Server 2017+
- Scripts: 14 ready-to-run .sql files
- Topics: 25+ SQL Server features
- Tool: SSMS or Azure Data Studio
Setup First
Don't have SQL Server yet? Install it free in under 30 minutes.
Install Guide →