1. JSON Support in SQL Server 2016+
SQL Server 2016 introduced first-class JSON support without adding a dedicated JSON column type. JSON is stored as NVARCHAR(MAX) — keeping compatibility with existing tooling — while a rich set of built-in functions lets you read, write, validate, and stream JSON within pure T-SQL.
Why Store JSON in SQL Server?
The pattern arises frequently in modern applications:
- API payloads — log the raw request/response body alongside the parsed relational columns.
- Flexible schemas — store per-customer configuration or metadata without adding dozens of nullable columns.
- Event sourcing — persist a JSON change payload in an audit table next to a temporal history record.
- Integration staging — import a JSON array from an external API into rows using
OPENJSON()before ETL processing.
Unlike PostgreSQL's
jsonb, SQL Server stores JSON as plain NVARCHAR(MAX). Use ISJSON() in a CHECK constraint to enforce valid JSON at write time, and add computed columns or indexed views on frequently queried JSON paths for performance.
Sample Table
All examples in sections 2–7 use this table:
CREATE TABLE dbo.Orders (
OrderID INT PRIMARY KEY,
CustomerID INT NOT NULL,
OrderDate DATE NOT NULL,
Details NVARCHAR(MAX) -- stores JSON
CONSTRAINT chk_Orders_Details_JSON CHECK (ISJSON(Details) = 1)
);
INSERT INTO dbo.Orders VALUES
(1, 101, '2026-01-10',
N'{"product":"Widget","qty":3,"price":9.99,"tags":["sale","bulk"]}'),
(2, 102, '2026-02-14',
N'{"product":"Gadget","qty":1,"price":49.00,"tags":["new"]}'),
(3, 101, '2026-03-22',
N'{"product":"Widget","qty":10,"price":8.99,"tags":["bulk","vip"]}');
2. JSON_VALUE(): Extracting Scalar Values
JSON_VALUE(expression, path) extracts a single scalar value (string, number, boolean) from a JSON document. The result type is NVARCHAR(4000) — cast it explicitly when you need a numeric or date type.
Basic Path Expressions
-- Extract product name and price from each order
SELECT
OrderID,
JSON_VALUE(Details, '$.product') AS Product,
CAST(JSON_VALUE(Details, '$.price') AS DECIMAL(10,2)) AS UnitPrice,
JSON_VALUE(Details, '$.qty') AS Quantity
FROM dbo.Orders;
-- Result:
-- OrderID Product UnitPrice Quantity
-- 1 Widget 9.99 3
-- 2 Gadget 49.00 1
-- 3 Widget 8.99 10
Strict vs Lax Mode
SQL Server path expressions support two modes prepended with a keyword:
- lax (default) — if the path does not exist or the value is an object/array,
JSON_VALUEreturnsNULLsilently. - strict — if the path does not exist or the value is not scalar, SQL Server raises an error.
-- lax mode (default) — returns NULL for missing path
SELECT JSON_VALUE(Details, 'lax $.discount') AS Discount
FROM dbo.Orders; -- returns NULL for all rows (path absent)
-- strict mode — raises error if path absent
SELECT JSON_VALUE(Details, 'strict $.discount') AS Discount
FROM dbo.Orders; -- ERROR: Property cannot be found
When querying documents with inconsistent schemas (e.g., some orders have a discount, some do not), keep the default
lax mode and use ISNULL() or COALESCE() to substitute a default value.
Indexing a JSON Path
-- Add a computed column, then index it
ALTER TABLE dbo.Orders
ADD ProductName AS JSON_VALUE(Details, '$.product') PERSISTED;
CREATE INDEX IX_Orders_ProductName ON dbo.Orders (ProductName);
-- Now this query gets an index seek:
SELECT OrderID, OrderDate
FROM dbo.Orders
WHERE JSON_VALUE(Details, '$.product') = 'Widget';
3. JSON_QUERY(): Extracting Objects and Arrays
Where JSON_VALUE extracts a scalar, JSON_QUERY(expression, path) extracts a JSON fragment — an object or array — and returns it as NVARCHAR(MAX). Attempting to use JSON_VALUE on an object/array path in lax mode returns NULL; JSON_QUERY is the correct tool.
-- Extract the full tags array as a JSON string
SELECT
OrderID,
JSON_QUERY(Details, '$.tags') AS TagsArray,
JSON_VALUE(Details, '$.tags') AS TagsValue -- returns NULL (array, not scalar)
FROM dbo.Orders;
-- OrderID TagsArray TagsValue
-- 1 ["sale","bulk"] NULL
-- 2 ["new"] NULL
-- 3 ["bulk","vip"] NULL
Nested Objects
-- Suppose Details contains a nested shipping object:
-- {"product":"Widget","qty":3,"price":9.99,
-- "shipping":{"carrier":"FedEx","tracking":"123XYZ"}}
SELECT
OrderID,
JSON_QUERY(Details, '$.shipping') AS ShippingObject,
JSON_VALUE(Details, '$.shipping.carrier') AS Carrier,
JSON_VALUE(Details, '$.shipping.tracking') AS TrackingNumber
FROM dbo.Orders
WHERE JSON_VALUE(Details, '$.shipping.carrier') IS NOT NULL;
4. JSON_MODIFY(): Updating JSON Values In Place
JSON_MODIFY(expression, path, newValue) returns a modified copy of the JSON string. It does not mutate in place — you assign the result back with UPDATE … SET col = JSON_MODIFY(col, …).
-- Raise the price by 10% for all Widget orders
UPDATE dbo.Orders
SET Details = JSON_MODIFY(
Details,
'$.price',
CAST(JSON_VALUE(Details, '$.price') AS DECIMAL(10,2)) * 1.10
)
WHERE JSON_VALUE(Details, '$.product') = 'Widget';
-- Add a new property (discount) that did not exist before
UPDATE dbo.Orders
SET Details = JSON_MODIFY(Details, '$.discount', 5.00)
WHERE OrderID = 1;
-- Delete a property by setting it to NULL with strict lax path
UPDATE dbo.Orders
SET Details = JSON_MODIFY(Details, '$.discount', NULL)
WHERE OrderID = 1;
Use the
append keyword in the path to push a new element onto an existing array without rewriting it:JSON_MODIFY(Details, 'append $.tags', 'clearance')
5. ISJSON() and JSON_PATH_EXISTS(): Validation and Conditional Queries
ISJSON()
ISJSON(expression) returns 1 if the string is valid JSON, 0 otherwise, and NULL if the input is NULL. Use it in CHECK constraints (shown above) or to filter a table that may hold mixed content:
SELECT OrderID, Details
FROM dbo.Orders
WHERE ISJSON(Details) = 1; -- skip any rows with malformed JSON
JSON_PATH_EXISTS() (SQL Server 2022)
SQL Server 2022 added JSON_PATH_EXISTS(expression, path) which returns 1 if the path exists in the document, 0 if not, and NULL on NULL input. Before 2022, the equivalent was checking JSON_VALUE(…) IS NOT NULL, which is ambiguous if the value really is null.
-- SQL Server 2022+
SELECT OrderID
FROM dbo.Orders
WHERE JSON_PATH_EXISTS(Details, '$.discount') = 1; -- only rows that have a discount key
-- Pre-2022 workaround (lax mode returns NULL for missing path OR for null value)
SELECT OrderID
FROM dbo.Orders
WHERE JSON_VALUE(Details, 'strict $.discount') IS NOT NULL; -- errors if path absent
-- safer pre-2022 pattern:
WHERE TRY_CAST(JSON_VALUE(Details, '$.discount') AS DECIMAL(10,2)) IS NOT NULL;
6. FOR JSON PATH vs FOR JSON AUTO: Serialising Results as JSON
The FOR JSON clause appended to a SELECT statement returns the result set as a JSON array instead of a tabular rowset — the reverse of OPENJSON.
FOR JSON AUTO
FOR JSON AUTO infers the JSON structure from the query's table aliases. Simple but inflexible — nesting depends on JOIN order.
SELECT o.OrderID, o.OrderDate,
c.CustomerID, c.CompanyName
FROM dbo.Orders o
JOIN dbo.Customers c ON c.CustomerID = o.CustomerID
FOR JSON AUTO;
-- Returns:
-- [{"OrderID":1,"OrderDate":"2026-01-10",
-- "c":[{"CustomerID":101,"CompanyName":"Acme Corp"}]},...]
FOR JSON PATH
FOR JSON PATH gives full control via column aliases. Use dot notation in the alias to create nested objects:
SELECT
o.OrderID AS "order.id",
o.OrderDate AS "order.date",
JSON_VALUE(o.Details, '$.product') AS "order.product",
CAST(JSON_VALUE(o.Details, '$.price') AS DECIMAL) AS "order.price",
o.CustomerID AS "customer.id"
FROM dbo.Orders o
FOR JSON PATH, ROOT('orders');
-- Returns:
-- {"orders":[
-- {"order":{"id":1,"date":"2026-01-10","product":"Widget","price":10},
-- "customer":{"id":101}},
-- ...
-- ]}
Add
ROOT('orders') to wrap the array in a named top-level key. Add INCLUDE_NULL_VALUES to include columns whose value is NULL as explicit "key":null entries (omitted by default).
7. OPENJSON(): Parsing JSON Arrays into Rows
OPENJSON(json_expression [, path]) is a table-valued function that shreds a JSON array or object into a relational rowset. It is the standard way to bulk-import JSON data into SQL Server tables.
Default Schema (Key/Value Pairs)
-- Parse a single document's top-level key-value pairs
SELECT [key], [value], [type]
FROM OPENJSON(
N'{"product":"Widget","qty":3,"price":9.99}'
);
-- key value type
-- product Widget 1 (string)
-- qty 3 2 (number)
-- price 9.99 2 (number)
WITH Clause (Explicit Schema)
The WITH clause maps JSON paths to typed columns — far more useful for real import scenarios:
-- Import a JSON array of new orders
DECLARE @json NVARCHAR(MAX) = N'[
{"id":10,"product":"Sprocket","qty":5,"price":2.50},
{"id":11,"product":"Cog","qty":2,"price":7.00},
{"id":12,"product":"Gear","qty":8,"price":3.25}
]';
INSERT INTO dbo.Orders (OrderID, CustomerID, OrderDate, Details)
SELECT
j.OrderID,
999, -- placeholder customer
CAST(GETDATE() AS DATE),
(SELECT j.Product, j.Qty, j.Price FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)
FROM OPENJSON(@json)
WITH (
OrderID INT '$.id',
Product NVARCHAR(100) '$.product',
Qty INT '$.qty',
Price DECIMAL(10,2) '$.price'
) AS j;
Expanding a JSON Array Column
-- Expand the tags array stored in each order's Details column
SELECT o.OrderID, tag.[value] AS Tag
FROM dbo.Orders o
CROSS APPLY OPENJSON(o.Details, '$.tags') AS tag;
-- OrderID Tag
-- 1 sale
-- 1 bulk
-- 2 new
-- 3 bulk
-- 3 vip
8. Temporal Tables (System-Versioned): CREATE TABLE with PERIOD FOR SYSTEM_TIME
System-versioned temporal tables, introduced in SQL Server 2016 (ISO/IEC SQL:2011), automatically maintain a full history of every row change. SQL Server handles history writes transparently — no triggers, no manual audit tables.
Creating a Temporal Table
Three additions to a normal CREATE TABLE statement are required:
- Two
DATETIME2columns to hold the validity period (ValidFrom,ValidTo). - A
PERIOD FOR SYSTEM_TIMEdeclaration linking those columns. WITH (SYSTEM_VERSIONING = ON), optionally naming the history table.
CREATE TABLE dbo.ProductPrices (
ProductID INT NOT NULL PRIMARY KEY,
ProductName NVARCHAR(200) NOT NULL,
ListPrice DECIMAL(10,2) NOT NULL,
Category NVARCHAR(100) NOT NULL,
-- System-period columns (auto-managed by SQL Server)
ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START NOT NULL,
ValidTo DATETIME2 GENERATED ALWAYS AS ROW END NOT NULL,
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
)
WITH (
SYSTEM_VERSIONING = ON (
HISTORY_TABLE = dbo.ProductPricesHistory,
DATA_CONSISTENCY_CHECK = ON
)
);
-- Check both tables exist
SELECT name, temporal_type_desc
FROM sys.tables
WHERE name LIKE 'ProductPrices%';
DML on a Temporal Table
-- INSERT: SQL Server sets ValidFrom = current UTC time, ValidTo = max datetime2
INSERT INTO dbo.ProductPrices VALUES (1, 'Widget Pro', 29.99, 'Hardware');
INSERT INTO dbo.ProductPrices VALUES (2, 'Gadget Max', 99.00, 'Electronics');
-- UPDATE: SQL Server copies the OLD row to history (with ValidTo = now),
-- then updates the current table (ValidFrom = now)
UPDATE dbo.ProductPrices SET ListPrice = 24.99 WHERE ProductID = 1;
-- DELETE: SQL Server moves the row to history with ValidTo = now.
-- The current table row is gone, but history retains it.
DELETE FROM dbo.ProductPrices WHERE ProductID = 2;
SQL Server stores
ValidFrom / ValidTo in UTC regardless of the server's time zone. When displaying timestamps to end users, convert with AT TIME ZONE: ValidFrom AT TIME ZONE 'UTC' AT TIME ZONE 'India Standard Time'.
9. Querying Temporal History: FOR SYSTEM_TIME Clauses
SQL Server extends the FROM clause with FOR SYSTEM_TIME sub-clauses that let you query the current table, the history table, or a union of both — without writing any JOIN to the history table yourself.
-- 1. AS OF — state of the table at a specific point in time
SELECT ProductID, ProductName, ListPrice
FROM dbo.ProductPrices
FOR SYSTEM_TIME AS OF '2026-03-01T00:00:00';
-- 2. FROM ... TO — all row versions that were active
-- at any point in the half-open interval [start, end)
SELECT ProductID, ProductName, ListPrice, ValidFrom, ValidTo
FROM dbo.ProductPrices
FOR SYSTEM_TIME FROM '2026-01-01' TO '2026-04-01';
-- 3. BETWEEN ... AND — closed interval (includes both endpoints)
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM dbo.ProductPrices
FOR SYSTEM_TIME BETWEEN '2026-01-01' AND '2026-06-01';
-- 4. CONTAINED IN — only rows FULLY contained within the interval
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM dbo.ProductPrices
FOR SYSTEM_TIME CONTAINED IN ('2026-02-01', '2026-05-01');
-- 5. ALL — union of current table and entire history
SELECT ProductID, ListPrice, ValidFrom, ValidTo
FROM dbo.ProductPrices
FOR SYSTEM_TIME ALL
ORDER BY ProductID, ValidFrom;
Price Change Audit Report
-- Show every price change for a product with the delta
SELECT
ProductID,
ProductName,
ListPrice AS NewPrice,
LAG(ListPrice) OVER (PARTITION BY ProductID ORDER BY ValidFrom)
AS OldPrice,
ListPrice - LAG(ListPrice) OVER (PARTITION BY ProductID ORDER BY ValidFrom)
AS PriceDelta,
ValidFrom AS ChangedAt
FROM dbo.ProductPrices
FOR SYSTEM_TIME ALL
WHERE ProductID = 1
ORDER BY ValidFrom;
10. Temporal Table Use Cases: Price History, Audit Log, Point-in-Time Restore
Price History and SLA Compliance
-- What was the price when each order was placed?
SELECT
o.OrderID,
o.OrderDate,
p.ProductName,
p.ListPrice AS PriceAtOrderTime
FROM dbo.Orders o
JOIN dbo.ProductPrices
FOR SYSTEM_TIME AS OF o.OrderDate AS p
ON p.ProductID = CAST(JSON_VALUE(o.Details, '$.productId') AS INT);
Point-in-Time Restore
-- Accidentally deleted rows? Recover from temporal history:
INSERT INTO dbo.ProductPrices (ProductID, ProductName, ListPrice, Category)
SELECT ProductID, ProductName, ListPrice, Category
FROM dbo.ProductPrices
FOR SYSTEM_TIME AS OF '2026-05-31T23:59:00' -- just before the bad DELETE
WHERE ProductID IN (2, 5, 9); -- rows that were deleted
Disabling System Versioning (Schema Changes)
-- You must disable versioning before ALTER TABLE
ALTER TABLE dbo.ProductPrices SET (SYSTEM_VERSIONING = OFF);
ALTER TABLE dbo.ProductPrices ADD Discount DECIMAL(5,2) NULL;
ALTER TABLE dbo.ProductPricesHistory ADD Discount DECIMAL(5,2) NULL;
ALTER TABLE dbo.ProductPrices
SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.ProductPricesHistory));
11. STRING_AGG(): Concatenating Rows into a String
STRING_AGG(expression, separator), available since SQL Server 2017, concatenates values from multiple rows into a single delimited string. It replaces the verbose and fragile FOR XML PATH('') hack that had been the standard for over a decade.
Basic Syntax
-- List all tags per order as a comma-separated string
-- First expand with OPENJSON, then re-aggregate with STRING_AGG
SELECT
o.OrderID,
STRING_AGG(t.[value], ', ') AS Tags
FROM dbo.Orders o
CROSS APPLY OPENJSON(o.Details, '$.tags') AS t
GROUP BY o.OrderID;
-- OrderID Tags
-- 1 sale, bulk
-- 2 new
-- 3 bulk, vip
WITHIN GROUP (ORDER BY)
Use the optional WITHIN GROUP clause to sort the concatenated values:
-- Employees per department, names alphabetically sorted
SELECT
DepartmentID,
STRING_AGG(LastName, '; ') WITHIN GROUP (ORDER BY LastName ASC) AS EmployeeList
FROM HR.Employees
GROUP BY DepartmentID;
Replacing the FOR XML PATH Hack
-- OLD pattern (SQL Server 2008–2016 era) — avoid in new code
SELECT
DepartmentID,
STUFF((
SELECT ', ' + LastName
FROM HR.Employees e2
WHERE e2.DepartmentID = e1.DepartmentID
FOR XML PATH(''), TYPE).value('.','NVARCHAR(MAX)'),
1, 2, '') AS EmployeeList
FROM HR.Employees e1
GROUP BY DepartmentID;
-- NEW pattern with STRING_AGG — cleaner, faster, NULL-safe
SELECT
DepartmentID,
STRING_AGG(LastName, ', ') WITHIN GROUP (ORDER BY LastName) AS EmployeeList
FROM HR.Employees
GROUP BY DepartmentID;
STRING_AGG silently ignores NULL values in the expression — they are not included in the result and do not produce a leading or trailing separator. This matches the behaviour of most aggregate functions.
12. STRING_SPLIT(): Splitting Delimited Strings
STRING_SPLIT(string, separator) is a table-valued function introduced in SQL Server 2016 that splits a delimited string into a single-column table. It pairs naturally with CROSS APPLY to normalise denormalised CSV-like data.
Basic Usage
-- Split a single CSV string into rows
SELECT [value]
FROM STRING_SPLIT(N'Widget,Gadget,Sprocket', ',');
-- value
-- Widget
-- Gadget
-- Sprocket
CROSS APPLY Pattern
-- Normalise a Tags column that stores comma-separated values
SELECT e.EmployeeID, e.Name, s.[value] AS Skill
FROM HR.Employees e
CROSS APPLY STRING_SPLIT(e.Skills, ',') AS s
WHERE LTRIM(RTRIM(s.[value])) <> ''; -- trim whitespace, skip blanks
Ordinal Position (SQL Server 2022)
SQL Server 2022 added a second result column ordinal when you pass enable_ordinal = 1:
-- SQL Server 2022+: get the position of each element
SELECT ordinal, [value]
FROM STRING_SPLIT(N'alpha,beta,gamma,delta', ',', 1) -- 3rd arg = enable_ordinal
ORDER BY ordinal;
-- ordinal value
-- 1 alpha
-- 2 beta
-- 3 gamma
-- 4 delta
Filtering with a Whitelist Table
-- Find orders that contain at least one tag from an approved list
DECLARE @AllowedTags TABLE (Tag NVARCHAR(50));
INSERT INTO @AllowedTags VALUES ('bulk'), ('vip'), ('sale');
SELECT DISTINCT o.OrderID
FROM dbo.Orders o
CROSS APPLY OPENJSON(o.Details, '$.tags') AS t
JOIN @AllowedTags a ON a.Tag = t.[value];
13. Modern String Functions: TRIM, CONCAT_WS, TRANSLATE, REPLACE
TRIM() — SQL Server 2017+
SQL Server 2017 added ANSI-standard TRIM() which removes leading and trailing spaces (or a specified set of characters) from a string, replacing the verbose LTRIM(RTRIM(…)) pattern:
-- Remove leading/trailing spaces
SELECT TRIM(' hello world '); -- 'hello world'
-- Remove specific characters (SQL Server 2022 enhanced syntax)
SELECT TRIM('.,! ' FROM '...Hello!'); -- 'Hello'
-- Pre-2017 equivalent
SELECT LTRIM(RTRIM(' hello world '));
CONCAT_WS() — SQL Server 2017+
CONCAT_WS(separator, arg1, arg2, …) concatenates values with a separator, automatically skipping NULL arguments (unlike CONCAT which includes NULLs as empty strings):
-- Build a mailing address, skipping NULL line 2
SELECT CONCAT_WS(', ',
AddressLine1,
AddressLine2, -- NULL rows automatically omitted
City,
PostalCode
) AS FullAddress
FROM Person.Address;
TRANSLATE() — SQL Server 2017+
TRANSLATE(string, characters, translations) performs many single-character substitutions in one pass, equivalent to calling REPLACE repeatedly:
-- Replace multiple bracket types with standard parentheses
SELECT TRANSLATE('[2+3]*{4}', '[]{', '()(');
-- Result: (2+3)*(4)
-- Normalise separators in a date string
SELECT TRANSLATE('2026.06.10', '.-/', '---');
-- Result: 2026-06-10
REPLACE Patterns for JSON-Safe Strings
-- Escape single quotes before inserting into dynamic SQL
DECLARE @UserInput NVARCHAR(200) = N'O''Brien';
SET @UserInput = REPLACE(@UserInput, '''', ''''''); -- double the single quotes
-- Remove all non-alphanumeric characters using a helper function pattern
-- (SQL Server lacks regex; TRANSLATE is the closest built-in for char substitution)
14. Sequences: CREATE SEQUENCE, NEXT VALUE FOR, OVER Clause
A SEQUENCE is a schema-bound object that generates a series of numeric values according to a specification. It was introduced in SQL Server 2012 and offers several advantages over the IDENTITY column property.
Creating a Sequence
CREATE SEQUENCE dbo.OrderSeq
AS INT
START WITH 1000
INCREMENT BY 1
MINVALUE 1000
MAXVALUE 9999999
CYCLE -- restart from MINVALUE when MAXVALUE is reached
CACHE 50; -- pre-allocate 50 values in memory for performance
NEXT VALUE FOR
-- Get the next sequence value
SELECT NEXT VALUE FOR dbo.OrderSeq; -- returns 1000 on first call
-- Use in an INSERT
INSERT INTO dbo.Orders (OrderID, CustomerID, OrderDate, Details)
VALUES (
NEXT VALUE FOR dbo.OrderSeq,
101,
CAST(GETDATE() AS DATE),
N'{"product":"NewItem","qty":1,"price":5.00}'
);
-- Use in a DEFAULT constraint (sequence assigned at INSERT time)
ALTER TABLE dbo.Orders
ADD CONSTRAINT DF_Orders_OrderID
DEFAULT (NEXT VALUE FOR dbo.OrderSeq) FOR OrderID;
NEXT VALUE FOR with OVER Clause
Uniquely, NEXT VALUE FOR supports an OVER (ORDER BY …) clause, which generates a block of ordered, contiguous sequence values in a single statement:
-- Assign sequence numbers to a staging table in ProductName order
UPDATE s
SET s.SequenceID = NEXT VALUE FOR dbo.OrderSeq OVER (ORDER BY s.ProductName)
FROM dbo.StagingImport s;
Sequence vs IDENTITY — Comparison
| Feature | IDENTITY | SEQUENCE |
|---|---|---|
| Scope | Single table column | Schema-level object, reusable across tables |
| Pre-generate values | No — only on INSERT | Yes — call NEXT VALUE FOR before INSERT |
| Reset / restart | DBCC CHECKIDENT | ALTER SEQUENCE RESTART WITH n |
| CYCLE support | No | Yes |
| Use in UPDATE | No | Yes (with OVER) |
| Multi-table counter | No | Yes — share one sequence across many tables |
| Gaps on rollback | Yes (same as sequence) | Yes |
Both
IDENTITY and SEQUENCE can produce gaps: a rolled-back transaction consumes values, cached values are lost on restart, and bulk operations pre-allocate ranges. Never write application logic that assumes consecutive, gap-free IDs.
15. Practical Use Case: Audit System with Temporal Table + JSON Change Payload
The most powerful pattern combining topics from this article is a self-auditing entity table: a temporal table that captures every row version automatically, plus an AuditLog table that stores a rich JSON diff payload written by an AFTER UPDATE trigger. This gives you both lightweight point-in-time queries (via FOR SYSTEM_TIME) and human-readable change descriptions.
Schema Setup
-- Main entity table — system-versioned
CREATE TABLE dbo.Contracts (
ContractID INT NOT NULL PRIMARY KEY,
ClientName NVARCHAR(200) NOT NULL,
Value DECIMAL(15,2) NOT NULL,
Status NVARCHAR(50) NOT NULL DEFAULT 'Draft',
ModifiedBy NVARCHAR(100) NOT NULL DEFAULT SYSTEM_USER,
ValidFrom DATETIME2 GENERATED ALWAYS AS ROW START NOT NULL,
ValidTo DATETIME2 GENERATED ALWAYS AS ROW END NOT NULL,
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
)
WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = dbo.ContractsHistory));
-- Audit log table — stores JSON diff
CREATE TABLE dbo.AuditLog (
AuditID INT IDENTITY PRIMARY KEY,
TableName NVARCHAR(128) NOT NULL,
RecordID INT NOT NULL,
ChangedBy NVARCHAR(100) NOT NULL DEFAULT SYSTEM_USER,
ChangedAt DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
ChangeType CHAR(1) NOT NULL, -- 'I','U','D'
OldValues NVARCHAR(MAX) NULL, -- JSON
NewValues NVARCHAR(MAX) NULL -- JSON
);
Audit Trigger with JSON Diff
CREATE OR ALTER TRIGGER dbo.trg_Contracts_Audit
ON dbo.Contracts
AFTER INSERT, UPDATE, DELETE
AS
BEGIN
SET NOCOUNT ON;
IF EXISTS (SELECT 1 FROM inserted) AND EXISTS (SELECT 1 FROM deleted)
BEGIN
-- UPDATE
INSERT INTO dbo.AuditLog (TableName, RecordID, ChangeType, OldValues, NewValues)
SELECT
'Contracts',
d.ContractID,
'U',
(SELECT d.ContractID AS id, d.ClientName, d.Value, d.Status
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER),
(SELECT i.ContractID AS id, i.ClientName, i.Value, i.Status
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)
FROM deleted d
JOIN inserted i ON i.ContractID = d.ContractID;
END
ELSE IF EXISTS (SELECT 1 FROM inserted)
BEGIN
-- INSERT
INSERT INTO dbo.AuditLog (TableName, RecordID, ChangeType, OldValues, NewValues)
SELECT 'Contracts', ContractID, 'I', NULL,
(SELECT ContractID AS id, ClientName, Value, Status
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)
FROM inserted;
END
ELSE
BEGIN
-- DELETE
INSERT INTO dbo.AuditLog (TableName, RecordID, ChangeType, OldValues, NewValues)
SELECT 'Contracts', ContractID, 'D',
(SELECT ContractID AS id, ClientName, Value, Status
FOR JSON PATH, WITHOUT_ARRAY_WRAPPER), NULL
FROM deleted;
END
END;
Querying the Combined Audit Trail
-- Full change history: temporal rows + audit log JSON side by side
SELECT
al.AuditID,
al.ChangeType,
al.ChangedBy,
al.ChangedAt,
JSON_VALUE(al.OldValues, '$.Value') AS OldValue,
JSON_VALUE(al.NewValues, '$.Value') AS NewValue,
JSON_VALUE(al.OldValues, '$.Status') AS OldStatus,
JSON_VALUE(al.NewValues, '$.Status') AS NewStatus
FROM dbo.AuditLog al
WHERE al.TableName = 'Contracts'
AND al.RecordID = 1
ORDER BY al.ChangedAt;
-- Cross-check with temporal table: what did the row look like on a specific date?
SELECT ContractID, ClientName, Value, Status, ValidFrom
FROM dbo.Contracts
FOR SYSTEM_TIME AS OF '2026-04-15T00:00:00'
WHERE ContractID = 1;
The temporal table gives you the lightest possible point-in-time query — one
FOR SYSTEM_TIME AS OF clause. The JSON audit log gives your application team a human-readable diff with a named user and a timestamp. Together they cover compliance, debugging, and product analytics with no additional infrastructure.