Multiplicative Aggregates with the PRODUCT Function in SQL Server 2025

This blog post explores the new PRODUCT function in SQL Server 2025, which calculates the product of a set of numeric values — similar to how SUM and AVG work for addition and averaging, but for multiplication.

Prior to SQL Server 2025, SQL Server lacked a built-in way to compute the product of values in a set. You had to use workarounds like looping or user-defined aggregates. With PRODUCT, this is now a simple one-line expression.

PRODUCT supports both aggregate and analytic (windowed) forms and works with both ALL values (default) and DISTINCT values. Nulls are ignored, and the function is compatible with all numeric types except bit.

Compute Product of Prices for Each Product

The first example illustrates how to use the new PRODUCT aggregate function in SQL Server 2025 to calculate the cumulative product of prices for each product across multiple orders. It also shows how to compute the product considering only distinct price values.

CREATE TABLE OrderDetail (
OrderId int,
ProductId int,
Price decimal(10, 4)
)
INSERT INTO OrderDetail
(OrderId, ProductId, Price) VALUES
(1, 101, 136.87),
(1, 102, 29.57),
(1, 103, 396.85),
(2, 101, 136.87),
(2, 102, 29.57),
(3, 101, 136.87),
(3, 102, 29.57),
(4, 101, 149.22),
(4, 102, 29.57)
-- Compute product of all prices and distinct prices for each ProductId
SELECT
ProductId,
ProductOfPrices = PRODUCT(Price),
ProductOfDistinctPrices = PRODUCT(DISTINCT Price)
FROM
OrderDetail
GROUP BY
ProductId

Result:

ProductIdProductOfPricesProductOfDistinctPrices
101382606053.82916220423.741400
102764548.95334829.570000
103396.850000396.850000

Alternative using OVER (PARTITION BY ...)

This version computes the product for each row using a windowed aggregate (that is, using OVER rather than GROUP BY). This allows you to retain the detail rows (which were lost in the previous GROUP BY query) while also showing the total product per partition.

SELECT
ProductId,
OrderId,
Price,
ProductOfPrices = PRODUCT(Price) OVER (PARTITION BY ProductId)
FROM
OrderDetail
ORDER BY
ProductId,
OrderId

Result:

ProductIdOrderIdPriceProductOfPrices
1011136.8700382606053.829162
1012136.8700382606053.829162
1013136.8700382606053.829162
1014149.2200382606053.829162
102129.5700764548.953348
102229.5700764548.953348
102329.5700764548.953348
102429.5700764548.953348
1031396.8500396.850000

Compounded Return from Periodic Rates

The next example uses PRODUCT to compute the compounded return for financial instruments over multiple time periods.

CREATE TABLE Instrument (
InstrumentId varchar(10),
Period tinyint,
RateOfReturn decimal(10, 4)
)
INSERT INTO Instrument
(InstrumentId, Period, RateOfReturn) VALUES
('BOND1', 1, 0.035),
('BOND1', 2, 0.0275),
('BOND1', 3, 0.0325),
('ETF1', 1, 0.08),
('ETF1', 2, -0.045),
('ETF1', 3, 0.06),
('STOCK1', 1, 0.125),
('STOCK1', 2, 0.095),
('STOCK1', 3, 0.113)
-- Compute compounded return for each instrument
SELECT
InstrumentId,
CompoundedReturn = PRODUCT(1 + RateOfReturn) - 1,
CompoundedReturnPercentage = FORMAT((PRODUCT(1 + RateOfReturn) - 1) * 100, 'N1') || '%'
FROM
Instrument
GROUP BY
InstrumentId

Result:

InstrumentIdCompoundedReturnCompoundedReturnPercentage
BOND10.0980269.8%
ETF10.0932849.3%
STOCK10.37107737.1%

The above query calculates the compounded return for each instrument by taking the product of (1 + RateOfReturn) for all periods and then subtracting 1 to return the CompoundedReturn column. The CompoundedReturnPercentage column shows the same value formatted for display as a percentage with one decimal place.

Using BOND1 as an example, the calculation would be:

(1 + 0.035) = 1.035*Period 1 return
(1 + 0.0275) = 1.0275*Period 2 return
(1 + 0.0325) = 1.0325=Period 3 return
1.098026– 1 =Growth factor (includes the original principal $1)
0.098026=Compounded return (i.e., the percentage gain)
9.8%Isolated profit/loss percentage

Alternative using OVER (PARTITION BY ...)

Like the first example, this version uses windowing with OVER to calculate the compounded return for each individual row.

SELECT
InstrumentId,
Period,
RateOfReturn,
CompoundedReturn = PRODUCT(1 + RateOfReturn) OVER (PARTITION BY InstrumentId) - 1,
CompoundedReturnPercentage = FORMAT((PRODUCT(1 + RateOfReturn) OVER (PARTITION BY InstrumentId) - 1) * 100,'N1') || '%'
FROM
Instrument
ORDER BY
InstrumentId,
Period

Result:

InstrumentIdPeriodRateOfReturnCompoundedReturnCompoundedReturnPercentage
BOND110.03500.0980269.8%
BOND120.02750.0980269.8%
BOND130.03250.0980269.8%
ETF110.08000.0932849.3%
ETF12-0.04500.0932849.3%
ETF130.06000.0932849.3%
STOCK110.12500.37107737.1%
STOCK120.09500.37107737.1%
STOCK130.11300.37107737.1%

Summary

The new PRODUCT function in SQL Server 2025 brings native multiplicative aggregation to T-SQL, eliminating the need for workarounds when calculating the product of a set of numeric values. It supports standard aggregation with GROUP BY, including DISTINCT, as well as analytic calculations using OVER (PARTITION BY ...) to preserve individual detail rows. As we demonstrated with product prices and compounded investment returns, PRODUCT makes calculations that depend on multiplying values across a set simpler and more expressive.

Happy coding!

Base64 Encoding and Decoding in SQL Server 2025 and Azure SQL Database

Base64 encoding is a method of converting binary data into an ASCII string format by translating it into a radix-64 representation. Base64 decoding is the reverse process, converting an ASCII string back into binary data. Encoding is often used to safely transmit binary data over text-based protocols, such as HTTP or email, though note that encoding binary data increases its size by approximately 33%.

This blog post demonstrates how to use the new BASE64_ENCODE and BASE64_DECODE functions in T-SQL. The examples include both standard Base64 encoding (which may include characters like + and / that are not URL-safe) and URL-safe Base64 encoding (which replaces + with - and / with _).

Encoding with BASE64_ENCODE and Decoding with BASE64_DECODE

First let’s perform standard Base64 encoding and decoding. Run this code snippet to encode a binary value into a string using BASE64_ENCODE:

DECLARE @Binary varbinary(max) = 0xCAFECAFE
SELECT StandardEncoded = BASE64_ENCODE(@Binary)

Result:

yv7K/g==

Now use BASE64_DECODE to convert the encoded string back to its original binary form:

DECLARE @StandardEncoded varchar(max) = 'yv7K/g=='
SELECT Binary = BASE64_DECODE(@StandardEncoded)

Result:

0xCAFECAFE

Notice how the encoded string for the same binary content contains characters like / and +, which are not safe for use in URLs.

URL-Safe Base64 Encoding

To create a URL-safe version, use the second parameter of BASE64_ENCODE (where 1 means to perform URL-safe encoding):

DECLARE @Binary varbinary(max) = 0xCAFECAFE
SELECT UrlSafeEncoded = BASE64_ENCODE(@Binary, 1)

Result:

yv7K_g

Notice that the URL-safe encoded string replaces / with _ (it would also replace + with -). And now, use BASE64_DECODE to convert the URL-safe encoded string back to its original binary form:

DECLARE @UrlSafeDecoded varchar(max) = 'yv7K_g'
SELECT Binary = BASE64_DECODE(@UrlSafeDecoded)

Result:

0xCAFECAFE

You can see that both the standard and URL-safe encoded strings decode back to the same original binary data.

So then, why not always use URL-safe Base64 encoding? Although BASE64_DECODE can handle both standard and URL-safe Base64 formats in SQL Server, standard Base64 remains the default because it aligns with established standards (RFC 4648) and ensures compatibility with most systems, libraries, and protocols that expect the traditional +, /, and = characters. The URL-safe variant should be used only when the encoded data will appear in contexts like URLs, filenames, or cookies that restrict those characters. In short, use standard Base64 for interoperability and URL-safe Base64 only when required by the target environment.

Practical Examples

Here is a more practical example of using Base64 encoding and decoding in T-SQL. In this case, we will construct a JSON object that includes an embedded image. With the binary image content embedded as a Base64-encoded string, the JSON object can be easily transmitted or stored in text-based formats. In this case, the JSON object could be used in web applications, REST APIs, or other scenarios where images need to be included in JSON responses. Note that the same approach can be used for embedding images in HTML or XML documents, all using T-SQL.

DECLARE @ProductId int = 6
DECLARE @ProductImage varbinary(max) = 0xCAFECAFE89504E470D0A1A0A0000000D49484452
SELECT
ProductJson = JSON_OBJECT(
'productId' : @ProductId,
'thumbnail' : 'data:image/png;base64,' || BASE64_ENCODE(@ProductImage)
)

Result:

{"productId":6,"thumbnail":"data:image/png;base64,yv7K/olQTkcNChoKAAAADUlIRFI="}

Here is another example, where we construct a binary token for use in a URL. The token is first created as a binary value, then encoded using URL-safe Base64 encoding, and finally embedded into a URL string. This approach is useful for securely transmitting tokens in web applications via a URL, such as for authentication or authorization purposes.

DECLARE @Token varbinary(max) = CAST('user1|expiry=2025-12-31' AS varbinary)
SELECT @Token
DECLARE @UrlSafeEncodedToken varchar(max) = BASE64_ENCODE(@Token, 1)
SELECT @UrlSafeEncodedToken
DECLARE @Url varchar(max) = 'https://api.myapp.com/download?token=' || @UrlSafeEncodedToken
SELECT @Url

First, the string token is represented as binary data:

0x75736572317C6578706972793D323032352D31322D3331

Then it is encoded using URL-safe Base64:

dXNlcjF8ZXhwaXJ5PTIwMjUtMTItMzE

Finally, that encoded token is placed directly in the URL:

https://api.myapp.com/download?token=dXNlcjF8ZXhwaXJ5PTIwMjUtMTItMzE

Observe that the resulting URL contains a token that is safe for inclusion in URLs, thanks to the URL-safe Base64 encoding. Also notice the use of the ANSI SQL standard concatenation operator (||) to build the URL string, as you learned about in the previous lab.

Summary

The new BASE64_ENCODE and BASE64_DECODE functions are small additions that solve a very practical problem. They make it easy to convert binary values into portable text and back again, directly in T-SQL.

Use standard Base64 when you need conventional encoded output, and use URL-safe Base64 when the encoded value will appear in a URL, token, route segment, or query string. Just remember that Base64 is not encryption, and that encoding increases the size of the data.

Regular Expressions Have Finally Arrived in SQL Server 2025 and Azure SQL Database

Regular expression support has been one of those “how do we still not have this?” features in T-SQL for a very long time. SQL Server developers have always been able to do basic pattern matching with LIKE, and slightly more advanced wildcard searches with PATINDEX, but neither of those features provides true regex capabilities.

That limitation has historically forced developers into awkward workarounds: chains of CHARINDEX and SUBSTRING, complex LIKE predicates, CLR functions, application-side validation, ETL cleanup steps, or other approaches that were harder to read, harder to maintain, and often less expressive than a regular expression.

SQL Server 2025 and Azure SQL Database now include native regular expression functions for matching, counting, extracting, locating, replacing, returning matches as rows, and splitting text into rows. Microsoft’s documentation lists these functions as applying to SQL Server 2025, Azure SQL Database, Azure SQL Managed Instance, and SQL database in Microsoft Fabric.

The examples below use a simple Review table to demonstrate the new regex support. I’ll keep the regex explanations inside the SQL comments, so the walkthrough can focus on what each query is doing and what output to expect.

Create the sample table and data

CREATE TABLE Review(
ReviewId int IDENTITY PRIMARY KEY,
Name varchar(50) NOT NULL,
Email varchar(150),
Phone varchar(20),
ReviewText varchar(1000)
)
GO
INSERT INTO Review
(Name, Email, Phone, ReviewText) VALUES
('John Doe', 'john@contoso.com', '123-4567890', 'This product is excellent! I really like the build quality and design. #excellent #quality'),
('Alice Smith', 'alice@fabrikam@com', '234-567-81', 'Good value for money, but the software is terrible.'),
('Mary Jo Anne Erickson', 'mary.jo.anne@acme.co.uk', '456-789-1234', 'Poor battery life, bad camera performance, and poor build quality. #poor'),
('Max Wong', 'max@fabrikam.com', NULL, 'Excellent service from the support team, highly recommended!' || char(9) || '#goodservice #recommended'),
('Bob Johnson', 'bob.fabrikam.net', '345-678-9012', 'The product is good, but delivery was delayed.' || char(13) || char(10) || 'Overall, decent experience.'),
('Terri S Duffy', 'terri.duffy@acme.com', '678-901-2345', 'Battery life is weak, camera quality is poor. #aweful'),
('Eve Jones', NULL, '456-789-0123', 'I love this product, it''s great! #fantastic #amazing'),
('Charlie Brown', 'charlie@contoso.co.in', '587-890-1234', 'I hate this product, it''s terrible!')

This establishes the sample data used throughout the rest of the walkthrough.

REGEXP_LIKE

REGEXP_LIKE is used to test whether a value matches a regular expression pattern. It is especially useful in WHERE clauses.

Get rows with valid email addresses

SELECT
ReviewId,
Name,
Email
FROM
Review
WHERE
REGEXP_LIKE(
Email,
'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
/*
^ Start of string
[a-zA-Z0-9._%+-]+ One or more letters, digits, dot, underscore, percent, plus, or hyphen
@ Literal @ before the domain
[a-zA-Z0-9.-]+ One or more letters, digits, dot, or hyphen for the domain name
\. Literal . before the top-level domain
[a-zA-Z]{2,} At least two letters for the top-level domain (e.g., com, org, co, uk)
$ End of string
*/
)
ReviewIdNameEmail
1John Doejohn@contoso.com
3Mary Jo Anne Ericksonmary.jo.anne@acme.co.uk
4Max Wongmax@fabrikam.com
6Terri S Duffyterri.duffy@acme.com
8Charlie Browncharlie@contoso.co.in

Get rows with valid email addresses that end with .com

SELECT
ReviewId,
Name,
Email
FROM
Review
WHERE
REGEXP_LIKE(
Email,
'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.com$'
/*
^ Start of string
[a-zA-Z0-9._%+-]+ One or more letters, digits, dot, underscore, percent, plus, or hyphen
@ Literal @ before the domain
[a-zA-Z0-9.-]+ One or more letters, digits, dot, or hyphen for the domain name
\.com Literal . before the top-level domain which must be "com"
$ End of string
*/
)
ReviewIdNameEmail
1John Doejohn@contoso.com
4Max Wongmax@fabrikam.com
6Terri S Duffyterri.duffy@acme.com

Get rows with valid phone numbers

SELECT
ReviewId,
Name,
Phone
FROM
Review
WHERE
REGEXP_LIKE(
Phone,
'^(\d{3})-(\d{3})-(\d{4})$'
/*
^ Start of string
(\d{3}) Match exactly 3 digits (area code)
- Literal hyphen
(\d{3}) Match exactly 3 digits (first part of the phone number)
- Literal hyphen
(\d{4}) Match exactly 4 digits (second part of the phone number)
$ End of string
*/
)
ReviewIdNamePhone
3Mary Jo Anne Erickson456-789-1234
5Bob Johnson345-678-9012
6Terri S Duffy678-901-2345
7Eve Jones456-789-0123
8Charlie Brown587-890-1234

REGEXP_COUNT

REGEXP_COUNT counts the number of times a regex pattern occurs in a string. It can also be used to return validation flags in the SELECT list.

Indicate valid and invalid email addresses and phone numbers

SELECT
ReviewId,
Name,
Email,
IsEmailValid = CASE WHEN REGEXP_COUNT(Email, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$') = 1 THEN 1 ELSE 0 END,
Phone,
IsPhoneValid = CASE WHEN REGEXP_COUNT(Phone, '^(\d{3})-(\d{3})-(\d{4})$') = 1 THEN 1 ELSE 0 END
FROM
Review
ReviewIdNameEmailIsEmailValidPhoneIsPhoneValid
1John Doejohn@contoso.com1123-45678900
2Alice Smithalice@fabrikam@com0234-567-810
3Mary Jo Anne Ericksonmary.jo.anne@acme.co.uk1456-789-12341
4Max Wongmax@fabrikam.com10
5Bob Johnsonbob.fabrikam.net0345-678-90121
6Terri S Duffyterri.duffy@acme.com1678-901-23451
7Eve Jones0456-789-01231
8Charlie Browncharlie@contoso.co.in1587-890-12341

Count the number of vowels in each name

SELECT
Name,
VowelCount = REGEXP_COUNT(
Name,
'[AEIOU]', -- match any single vowel
1, -- start position
'i' -- case insensitive flag (match uppercase or lowercase vowels)
)
FROM
Review
NameVowelCount
John Doe3
Alice Smith4
Mary Jo Anne Erickson7
Max Wong2
Bob Johnson3
Terri S Duffy3
Eve Jones4
Charlie Brown4

Count specific “sentiment” words from each review

SELECT
Name,
ReviewText,
GoodSentimentWordCount = REGEXP_COUNT(
ReviewText,
'\b(excellent|great|good|love|like)\b',
/*
\b start of word boundary
(excellent|great|good|love|like) match any of the words in the parentheses
\b end of word boundary
*/
1, -- start position
'i' -- case insensitive flag (match uppercase or lowercase words)
),
BadSentimentWordCount = REGEXP_COUNT(
ReviewText,
'\b(bad|poor|terrible|hate)\b',
/*
\b start of word boundary
(bad|poor|terrible|hate) match any of the words in the parentheses
\b end of word boundary
*/
1, -- start position
'i' -- case insensitive flag (match uppercase or lowercase words)
)
FROM
Review
NameGoodSentimentWordCountBadSentimentWordCount
John Doe30
Alice Smith11
Mary Jo Anne Erickson04
Max Wong10
Bob Johnson10
Terri S Duffy01
Eve Jones20
Charlie Brown02

CHECK constraints

Regex support is also useful for enforcing data quality rules with CHECK constraints. Before those constraints can be added, existing invalid data has to be removed or corrected.

Attempt to add constraints before cleaning the data

-- Cannot add check constraints to ensure valid email addresses and phone numbers in the Review table
ALTER TABLE Review
ADD CONSTRAINT CK_ValidEmail CHECK (Email IS NULL OR REGEXP_LIKE(Email, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'))
ALTER TABLE Review
ADD CONSTRAINT CK_ValidPhone CHECK (Phone IS NULL OR REGEXP_LIKE(Phone, '^(\d{3})-(\d{3})-(\d{4})$'))

If executed, these constraints would fail against the existing data, because the current table still contains invalid email addresses and phone numbers.

Delete rows with invalid email addresses and phone numbers

Let’s use the REGEXP_LIKE function in the WHERE clause of a DELETE statement to remove all rows with invalid email addresses or phone numbers:

DELETE FROM Review
WHERE
(Email IS NOT NULL AND NOT REGEXP_LIKE(Email, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')) OR
(Phone IS NOT NULL AND NOT REGEXP_LIKE(Phone, '^(\d{3})-(\d{3})-(\d{4})$'))
SELECT * FROM Review
ReviewIdNameEmailPhone
3Mary Jo Anne Ericksonmary.jo.anne@acme.co.uk456-789-1234
4Max Wongmax@fabrikam.com
6Terri S Duffyterri.duffy@acme.com678-901-2345
7Eve Jones456-789-0123
8Charlie Browncharlie@contoso.co.in587-890-1234

Add CHECK constraints

With only valid data remaining in the table, the two check constraints can now be created:

-- Add check constraints to ensure valid email addresses and phone numbers in the Review table
ALTER TABLE Review
ADD CONSTRAINT CK_ValidEmail CHECK (Email IS NULL OR REGEXP_LIKE(Email, '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'))
ALTER TABLE Review
ADD CONSTRAINT CK_ValidPhone CHECK (Phone IS NULL OR REGEXP_LIKE(Phone, '^(\d{3})-(\d{3})-(\d{4})$'))

Now test the constraints by trying to insert invalid values:

-- Try and fail to insert an invalid email address or phone number
INSERT INTO Review VALUES
('Invalid Email', 'invalid-email@com', '123-456-7890', 'Review')
INSERT INTO Review VALUES
('Invalid Phone', 'valid-email@gmail.com', '234-342-INVALID', 'Review')

Both of these statements fail, because the inserted values violate the regex-based CHECK constraints.

However, data that satisfies the check constraints is valid:

INSERT INTO Review
(Name, Email, Phone, ReviewText) VALUES
('John Doe', 'john@fabrikam.com', '123-456-7890', 'This product is excellent! I really like the build quality and design. #excellent #quality'),
('Alice Smith', 'alice.smith@fabrikam.co.uk', '234-567-8195', 'Good value for money, but the software is terrible.'),
('Bob Johnson', 'bob@fabrikam.com', '345-678-9012', 'The product is good, but delivery was delayed. Overall, decent experience.'),
('Stuart Green', 'stuart.green@acme.com', '456-789-0123', 'Pretty good product, I am enjoying it!')

These rows satisfy the regex-based constraints, so the insert succeeds. The following SELECT * is included in the code, but the output is not repeated here because it is just the table contents after the insert.

REGEXP_SUBSTR

REGEXP_SUBSTR extracts a substring that matches a regex pattern.

Extract the domain name of each valid email address

-- Extract the domain name of each valid email address
SELECT
ReviewId,
Name,
Email,
DomainName = REGEXP_SUBSTR(
Email,
'@(.+)$', -- @ = literal at-symbol; (.+)$ = capture everything after the at-symbol
1, -- start position
1, -- return the first occurrence
'c', -- enable capture group indexing
1) -- return the first capture group, which is the domain name
FROM
Review
ReviewIdNameEmailDomainName
3Mary Jo Anne Ericksonmary.jo.anne@acme.co.ukacme.co.uk
4Max Wongmax@fabrikam.comfabrikam.com
6Terri S Duffyterri.duffy@acme.comacme.com
7Eve Jones
8Charlie Browncharlie@contoso.co.incontoso.co.in
9John Doejohn@fabrikam.comfabrikam.com
10Alice Smithalice.smith@fabrikam.co.ukfabrikam.co.uk
11Bob Johnsonbob@fabrikam.comfabrikam.com
12Stuart Greenstuart.green@acme.comacme.com

Show how many email addresses there are for each domain

;WITH DomainNameCte AS (
SELECT
DomainName = REGEXP_SUBSTR(
Email,
'@(.+)$', -- @ = literal at-symbol; (.+)$ = capture everything after the at-symbol
1, -- start position
1, -- return the first occurrence
'c', -- enable capture group indexing
1) -- return the first capture group, which is the domain name
FROM
Review
)
SELECT
DomainName,
DomainCount = COUNT(*)
FROM
DomainNameCte
GROUP BY
DomainName
ORDER BY
DomainName
DomainNameDomainCount
1
acme.co.uk1
acme.com2
contoso.co.in1
fabrikam.co.uk1
fabrikam.com3

REGEXP_INSTR

REGEXP_INSTR returns the position of a regex match.

Find the position of the @ character and the first . after it

SELECT
ReviewId,
Name,
Email,
At = REGEXP_INSTR(Email, '@'), -- match the at-symbol; simple scenario (could also achieve with CHARINDEX)
DotAfterAt = REGEXP_INSTR(Email,
'@[^@]*?(\.)', -- @ = match the at-symbol; [^@]*? = match everything after the at-symbol until the first dot; (\.) = capture the first dot after the at-symbol
1, -- start position
1, -- return the first occurrence
0, -- return the position of the match (not the psition after it)
'c', -- enable capture group indexing
1 -- return the position of the first capture group, which is the dot
)
FROM
Review
/*
1 2
12345678901234567890123456
Example: alice.smith@fabrikam.co.uk
^ ^
| |
| +-- match first dot after @ = 21
+-- match starts at @
*/
ReviewIdEmailAtDotAfterAt
3mary.jo.anne@acme.co.uk1318
4max@fabrikam.com413
6terri.duffy@acme.com1217
7
8charlie@contoso.co.in816
9john@fabrikam.com514
10alice.smith@fabrikam.co.uk1221
11bob@fabrikam.com413
12stuart.green@acme.com1318

REGEXP_REPLACE

REGEXP_REPLACE replaces text matched by a regex pattern.

Strip middle names

SELECT
ReviewId,
Name,
ShortName = REGEXP_REPLACE(
Name, -- scan the Name column
'^(\S+)\s+.*\s+(\S+)$', -- ^(\S+) = capture the first word; .* = match everything in the middle; (\S+)$ = capture the last word
'\1 \2', -- replace the whole name with first + last
1, -- start position
1, -- replace only the first occurrence
'i' -- case insensitive flag (optional, but safe to include)
)
FROM
Review
ReviewIdNameShortName
3Mary Jo Anne EricksonMary Erickson
4Max WongMax Wong
6Terri S DuffyTerri Duffy
7Eve JonesEve Jones
8Charlie BrownCharlie Brown
9John DoeJohn Doe
10Alice SmithAlice Smith
11Bob JohnsonBob Johnson
12Stuart GreenStuart Green

REGEXP_MATCHES

REGEXP_MATCHES returns regex matches as rows, including match position and captured substrings.

Extract words starting with A followed by any two characters

SELECT *
FROM REGEXP_MATCHES(
'ATE ABOVE ACT',
'\b(A)(..)\b' -- \b = word boundary; (A) = the letter A; (..) = any two characters after A
)
match_idstart_positionend_positionmatch_valuesubstring_matches
113ATE[{“value”:”A”,”start”:1,”length”:1},{“value”:”TE”,”start”:2,”length”:2}]
21113ACT[{“value”:”A”,”start”:11,”length”:1},{“value”:”CT”,”start”:12,”length”:2}]

Extract hashtags from text

SELECT *
FROM REGEXP_MATCHES(
'Learning #AzureSQL #AzureSQLDB',
'#([A-Za-z0-9_]+)' -- # = hash symbol; ([A-Za-z0-9_]+) = followed by 1 or more characters or underscores
)
match_idstart_positionend_positionmatch_valuesubstring_matches
11018#AzureSQL[{“value”:”AzureSQL”,”start”:11,”length”:8}]
22030#AzureSQLDB[{“value”:”AzureSQLDB”,”start”:21,”length”:10}]

Extract hashtags from the review text in the database

SELECT
r.ReviewId,
r.ReviewText,
m.*
FROM
Review AS r
CROSS APPLY REGEXP_MATCHES(r.ReviewText, '#([A-Za-z0-9_]+)') AS m
ReviewIdmatch_value
3#poor
4#goodservice
4#recommended
6#aweful
7#fantastic
7#amazing
9#excellent
9#quality

REGEXP_SPLIT_TO_TABLE

REGEXP_SPLIT_TO_TABLE splits a string into rows using a regex delimiter.

Extract individual words from text using whitespace as the delimiter

SELECT *
FROM
REGEXP_SPLIT_TO_TABLE(
'the quick brown fox jumped' || char(9) || 'over the lazy' || char(13) || char(10) || 'dog',
'\s+' -- \s+ = match one or more whitespace characters (spaces, tabs, newlines)
)
ordinalvalue
1the
2quick
3brown
4fox
5jumped
6over
7the
8lazy
9dog

Extract individual words from review text

SELECT
r.ReviewId,
r.ReviewText,
WordText = s.value,
WordPosition = s.ordinal
FROM
Review AS r
CROSS APPLY REGEXP_SPLIT_TO_TABLE(r.ReviewText, '\s+') AS s

This returns one row per token from each review. Each row includes the original review text, the token value, and the token position within that review.

Clean punctuation from the split words

SELECT
r.ReviewId,
r.ReviewText,
WordText = REGEXP_REPLACE(s.value, '[^\w]', '', 1, 0, 'i'),
WordPosition = s.ordinal
FROM
Review AS r
CROSS APPLY REGEXP_SPLIT_TO_TABLE(r.ReviewText, '\s+') AS s

This version is similar, but removes punctuation from each token.

Final thoughts

Native regex support dramatically improves the T-SQL developer experience. Instead of relying on fragile combinations of LIKE, PATINDEX, CHARINDEX, and procedural cleanup code, you can now express many validation, extraction, replacement, and tokenization tasks directly and clearly in SQL.

For text-heavy workloads, data quality checks, ETL processing, review analysis, and general string manipulation, these functions are a very welcome addition to SQL Server 2025 and Azure SQL Database.

Fuzzy String Matching in SQL Server 2025

Fuzzy string matching is the process of finding strings that are approximately equal, rather than exact matches. This is a critical capability for data cleansing, deduplication, primitive natural language search, and matching user input against known values.

Until now, SQL Server’s fuzzy options have been limited to phonetic comparisons. But now, SQL Server 2025 (and Azure SQL Database) introduces a set of modern string similarity functions you can run directly in T-SQL with far more precision.

Note: This post is based on SQL Server 2025 CTP 2.1. Syntax and behavior are subject to subtle changes by the time the product is released. Fuzzy string matching will ultimately be supported across all SKUs of SQL Server, including SQL Server 2025 for Windows, SQL Server 2025 for Linux, Azure SQL Database, and Managed Instance.

Legacy Options: SOUNDEX and DIFFERENCE

These two functions have been around for decades:

  • SOUNDEX: Produces a code representing the phonetic sound of a word (e.g., “Green” → G650, “Greener” → G656).
  • DIFFERENCE: Compares SOUNDEX codes on a 1–4 scale (4 ≈ exact).

While these work marginally well for quick phonetic checks, they are not suited for long strings or nuanced comparisons.

New in SQL Server 2025: Four Modern Fuzzy Matching Functions

SQL Server 2025 now has these well-known fuzzy matching algorithms built into the engine:

Function NameReturnsUse Cases
EDIT_DISTANCENumber of edits (insert/delete/substitute) between two strings (aka Levenshtein distance)You want a raw “how many changes?” counter or to sort by degree of change
EDIT_DISTANCE_SIMILARITYNormalized similarity percentage (0–100) (aka Levenshtein similarity)You want a rankable score or thresholds like >= 80
JARO_WINKLER_DISTANCEA distance score optimized for short strings (lower is better)You prefer inverse scoring (distance)
JARO_WINKLER_SIMILARITYA similarity score emphasizing common prefixes (higher is better)Names and short, typo-prone values

Exploring with Word Pairs

Let’s create a table of word pairs and score them with both the new and legacy functions.

CREATE TABLE WordPair (
  WordPairId    int IDENTITY PRIMARY KEY,
  Word1         varchar(50),
  Word2         varchar(50)
)

Now populate the table with some sample data:

INSERT INTO WordPair VALUES
 ('Colour',     'Color'),
 ('Flavour',    'Flavor'),
 ('Centre',     'Center'),
 ('Theatre',    'Theater'),
 ('Theatre',    'Theatrics'),
 ('Theatre',    'Theatrical'),
 ('Organise',   'Organize'),
 ('Analyse',    'Analyze'),
 ('Catalogue',  'Catalog'),
 ('Programme',  'Program'),
 ('Metre',      'Meter'),
 ('Honour',     'Honor'),
 ('Neighbour',  'Neighbor'),
 ('Travelling', 'Traveling'),
 ('Grey',       'Gray'),
 ('Green',      'Greene'),
 ('Green',      'Greener'),
 ('Green',      'Greenery'),
 ('Green',      'Greenest'),
 ('Orange',     'Purple'),      -- very different
 ('Defence',    'Defense'),
 ('Practise',   'Practice'),
 ('Practice',   'Practice'),    -- identical
 ('Aluminium',  'Aluminum'),
 ('Cheque',     'Check')

This sample include a mix of near-matches (British vs. American spellings), slight variations (“Green” and “Greener”), one intentionally exact pair (“Practice” and “Practice”), and one intentionally bad pair (“Orange” vs. “Purple”).

Now let’s score each pair using the new and legacy functions:

SELECT
  *,
  -- New SQL Server 2025 fuzzy matching functions
  LevenshteinDistance     = EDIT_DISTANCE(Word1, Word2),
  LevenshteinSimilarity   = EDIT_DISTANCE_SIMILARITY(Word1, Word2),
  JaroWrinklerDistance    = JARO_WINKLER_DISTANCE(Word1, Word2),
  JaroWrinklerSimilarity  = JARO_WINKLER_SIMILARITY(Word1, Word2),
  -- Legacy SQL Server fuzzy matching functions
  Soundex1                = SOUNDEX(Word1),
  Soundex2                = SOUNDEX(Word2),
  Difference              = DIFFERENCE(Word1, Word2)
FROM
  WordPair
ORDER BY 
  LevenshteinSimilarity DESC

This query exercises the four new functions alongside the two legacy functions for every row. The results are sorted by EDIT_DISTANCE_SIMILARITY descending (0–100; 100 = exact), which yields a quick and easy comparison.

Run the query, and observe:

  • The exact match on “Practice/Practice” floats to the very top (similarity 100, distance 0).
  • Close pairs like “Grey/Gray” and “Colour/Color” cluster near the top with high similarity and small edit distances.
  • The outlier (“Orange/Purple”) drops to the bottom with low similarity and large distance.
  • You’ll see that legacy SOUNDEX/DIFFERENCE sometimes overrate or underrate certain pairs, while the newer metrics capture nuance (insertions, substitutions, transpositions, prefix weight).

Clean up the example table

DROP TABLE WordPair

Real-World Deduping (Customer Records)

Now let’s detect potential duplicates in a customer table by scoring first names, last names, and addresses, then combining them.

CREATE TABLE Customer (
  CustomerId int IDENTITY PRIMARY KEY,
  FirstName varchar(50),
  LastName varchar(50),
  Address varchar(100)
)

While minimal, this structure will suffice to keep focus on fuzzy matching. Now populate the table with sample data, including a mix of exact duplicate and near duplicate values across the first name, last name, and address fields.

INSERT INTO Customer VALUES
 ('Johnathan',  'Smith',    '123 North Main Street'),
 ('Jonathan',   'Smith',    '123 N Main St.'),
 ('Johnathan',  'Smith',    '456 Ocean View Blvd'),
 ('Johnathan',  'Smith',    '123 North Main Street'),
 ('Daniel',     'Smith',    '123 N. Main St.'),
 ('Danny',      'Smith',    '123 N. Main St.'),
 ('John',       'Smith',    '123 Main Street'),
 ('Jonathon',   'Smyth',    '123 N Main St'),
 ('Jon',        'Smith',    '123 N Main St.'),
 ('Johnny',     'Smith',    '124 N Main St'),
 ('Ethan',      'Goldberg', '742 Evergreen Terrace'),
 ('Carlos',     'Rivera',   '456 Ocean View Boulevard'),
 ('Carlos',     'Rivera',   '456 Ocean View Blvd'),
 ('Carl',       'Rivera',   '456 Ocean View Boulevard'),
 ('Carlos',     'Rivera',   '456 Ocean View Boulevard')

SELECT * FROM Customer

Score First Name Similarity

We’ll compute pairwise first-name similarity across all row combinations using JARO_WINKLER_SIMILARITY (great for short strings and prefix sensitivity).

;WITH PairwiseSimilarityCte AS (
  SELECT
    CustomerId1         = c1.CustomerId,
    CustomerId2         = c2.CustomerId,
    FirstName1          = c1.FirstName,
    FirstName2          = c2.FirstName,
    FirstNameSimilarity = JARO_WINKLER_SIMILARITY(c1.FirstName, c2.FirstName)
  FROM
    Customer AS c1
    INNER JOIN Customer AS c2 ON c2.CustomerId < c1.CustomerId
)
SELECT
  *,
  FirstNameQuality = CASE
    WHEN FirstNameSimilarity = 1    THEN 'Exact'
    WHEN FirstNameSimilarity >= .85 THEN 'Very Strong'
    WHEN FirstNameSimilarity >= .75 THEN 'Strong'
    WHEN FirstNameSimilarity >= .4  THEN 'Weak'
                                    ELSE 'Very Weak'
    END
FROM
  PairwiseSimilarityCte
ORDER BY
  FirstNameSimilarity DESC

How it works:

  • The self-join c2.CustomerId < c1.CustomerId generates unique unordered pairs (no duplicates, no self-pairs).
  • The CTE computes similarity for each pair; the outer query labels it with human-friendly buckets.

What to expect:

  • Exact repeats (e.g., identical first names) show FirstNameSimilarity = 1 and label Exact.
  • Variants like Johnathan/ Jonathan and Jon/ John cluster as Very Strong or Strong.
  • Unrelated names (e.g., Daniel / Johnathan) drop into Weak/Very Weak.

Score Last Name Similarity

Same pattern, now focused on last names. This will show phonetic look-alikes like Smith/Smyth scoring high.

;WITH PairwiseSimilarityCte AS (
  SELECT
    CustomerId1         = c1.CustomerId,
    CustomerId2         = c2.CustomerId,
    LastName1           = c1.LastName,
    LastName2           = c2.LastName,
    LastNameSimilarity  = JARO_WINKLER_SIMILARITY(c1.LastName, c2.LastName)
  FROM
    Customer AS c1
    INNER JOIN Customer AS c2 ON c2.CustomerId < c1.CustomerId
)
SELECT
  *,
  LastNameQuality = CASE
    WHEN LastNameSimilarity = 1     THEN 'Exact'
    WHEN LastNameSimilarity >= .85  THEN 'Very Strong'
    WHEN LastNameSimilarity >= .75  THEN 'Strong'
    WHEN LastNameSimilarity >= .4   THEN 'Weak'
                                    ELSE 'Very Weak'
    END
FROM
    PairwiseSimilarityCte
ORDER BY
    LastNameSimilarity DESC

What to expect:

  • All Smith/Smith pairs rank Exact.
  • Smith/Smyth should score Very Strong (tiny spelling change).
  • Completely different surnames (e.g., Goldberg / Smith) fall low.

Score Address Similarity

Addresses are noisy (abbreviations, punctuation, ordering). JARO_WINKLER_SIMILARITY handles short transpositions and prefix overlaps well.

;WITH PairwiseSimilarityCte AS (
  SELECT
    CustomerId1         = c1.CustomerId,
    CustomerId2         = c2.CustomerId,
    Address1            = c1.Address,
    Address2            = c2.Address,
    AddressSimilarity   = JARO_WINKLER_SIMILARITY(c1.Address, c2.Address)
  FROM
    Customer AS c1
    INNER JOIN Customer AS c2 ON c2.CustomerId < c1.CustomerId
)
SELECT
  *,
  AddressQuality = CASE
    WHEN AddressSimilarity = 1      THEN 'Exact'
    WHEN AddressSimilarity >= .85   THEN 'Very Strong'
    WHEN AddressSimilarity >= .75   THEN 'Strong'
    WHEN AddressSimilarity >= .4    THEN 'Weak'
                                    ELSE 'Very Weak'
  END
FROM
  PairwiseSimilarityCte
ORDER BY
  AddressSimilarity DESC

What to expect:

  • Exact duplicates top the list.
  • Variants like “123 North Main Street” vs “123 N Main St.” score Very Strong/Strong.
  • Unrelated addresses (e.g., “742 Evergreen Terrace” vs “123 N. Main St.”) land low.

Combine All Three: A Composite Match Score

A high first-name score plus a low address score probably isn’t a true duplicate. Let’s average the three similarities into a FinalCombinedScore for better overall ranking.

;WITH PairwiseSimilarityCte AS (
  SELECT
    CustomerId1         = c1.CustomerId,
    CustomerId2         = c2.CustomerId,
    FirstName1          = c1.FirstName,
    FirstName2          = c2.FirstName,
    FirstNameSimilarity = JARO_WINKLER_SIMILARITY(c1.FirstName, c2.FirstName),
    LastName1           = c1.LastName,
    LastName2           = c2.LastName,
    LastNameSimilarity  = JARO_WINKLER_SIMILARITY(c1.LastName, c2.LastName),
    Address1            = c1.Address,
    Address2            = c2.Address,
    AddressSimilarity   = JARO_WINKLER_SIMILARITY(c1.Address, c2.Address)
  FROM
    Customer AS c1
    INNER JOIN Customer AS c2 ON c2.CustomerId < c1.CustomerId
),
FinalCombinedScoreCte AS (
  SELECT
    *,
    FinalCombinedScore = (FirstNameSimilarity + LastNameSimilarity + AddressSimilarity) / 3.0
  FROM
    PairwiseSimilarityCte
)
SELECT
  *,
  FinalQuality = CASE
    WHEN FinalCombinedScore = 1     THEN 'Exact'
    WHEN FinalCombinedScore >= .85  THEN 'Very Strong'
    WHEN FinalCombinedScore >= .75  THEN 'Strong'
    WHEN FinalCombinedScore >= .4   THEN 'Weak'
                                    ELSE 'Very Weak'
  END
FROM
  FinalCombinedScoreCte
ORDER BY
  FinalCombinedScore DESC

How it works:

  • The first CTE computes three similarities for each pair.
  • The second CTE averages them into FinalCombinedScore.
  • The outer query labels and sorts by that combined metric.

What to expect:

  • True duplicates (identical first, last, address) appear right at the top (Exact).
  • Pairs like “Jonathon Smyth, 123 N Main St” and “Johnathan Smith, 123 North Main Street” rank high because names and addresses are all very similar.
  • Pairs that share a name but differ in address (e.g., Johnathan Smith at 456 Ocean View Blvd vs 123 North Main) rank lower—exactly what you want when deduping.

    Final Thoughts

    The new fuzzy string functions in SQL Server 2025 finally give T-SQL first-class similarity scoring. Whether you’re cleaning legacy data, merging customer lists, or building typo-tolerant features, you can now do it natively, efficiently, and with the granularity you always wanted.

    Getting Started with Change Event Streaming in SQL Server 2025 (Part 2: Consuming Events)

    Welcome back! In this second part of my two-part series covering the new Change Event Streaming (CES) feature in SQL Server 2025, I’ll show you how to consume events generated by CES. In Part 1, we provisioned an Azure event hub, generated a SAS token to access the event hub, created the CesDemo sample database, and enabled Change Event Streaming (CES) on the database. We then added tables to an event stream group with deliberate choices for @include_old_values and @include_all_columns. So at this point, CES is now emitting DML changes (inserts, updates, and deletes) from those tables into the event hub.

    Note: This post is based on SQL Server 2025 CTP 2.1. Syntax and behavior are subject to subtle changes by the time the product is released. Change Event Streaming (CES) will ultimately be supported across all SKUs of SQL Server, including SQL Server 2025 for Windows, SQL Server 2025 for Linux, Azure SQL Database, and Managed Instance.

    Now we’re ready to build a client application to consume generated events. But before we start coding, let’s establish some context so the steps make sense.

    First, CES merely writes into Event Hubs. It doesn’t know (or care) who’s listening. It’s up to your client application(s) to subsequently consume those events. Our sample C# application will use the Event Hubs client SDK (specifically, EventProcessorClient) to listen for events.

    Every CES client needs somewhere to record progress, as it processes events. This is called a checkpoint, which works like a “bookmark”. Using checkpoints, client applications can stop and later resume where they left off, and not reprocess events that have already been processed. The SDK uses Azure Blob Storage for this purpose.

    You’ll also encounter the term consumer group. Think of a consumer group as a “view” of the stream with its own checkpoint. By utilizing multiple consumer groups (one per client application), each application can maintain its own checkpoint for bookmarking its place in the event stream. The Basic tier allows for only one consumer group. Moving to (and paying for) a higher tier than Basic will allow you to manage multiple client applications that consume events simultaneously from the same event hub, each at their own pace, without stepping on each other.

    Create a Blob Storage Container

    You’ll need a blob container in Azure Storage so that the Event Hubs client SDK can manage checkpoints for your consumer groups.

    Create a Storage Account

    A blob container lives within a storage account. To create a new storage account:

    1. In the Azure portal, create a new resource.
    2. From the Marketplace, create a new Storage Account resource.
    3. Provide a name for a new storage account in either a new or existing resource group (dashes not permitted).
    4. For the Primary service, choose Azure Blob Storage or Azure Data Lake Storage Gen 2.
    5. For Redundancy, choose Locally-redundant storage (LRS) (sufficient for development and testing).
    6. Click Review + create, and then Create.

    Create a Blob Container

    Now you can create a new blob container within the new storage account:

    1. Under Data Storage on the left, click Containers.
    2. Click + Add container.
    3. Provide a name for the new container.
    4. Click Create.

    Now get the connection string for the storage account:

    1. Under Security + Networking on the left, click Access Keys.
    2. Click Show under the Connection String for key1.
    3. Click the Copy icon to copy the connection string to the clipboard.
    4. Paste the connection string into Notepad; it will be needed for the client application configuration.

    Create the Visual Studio Project

    Alright, we’re ready to roll. We’ll build our consumer client as a simple console app, keeping the the focus on wiring up the stream, deserializing events, and showing what’s happening.

    Note: CES consumers can also be built with Azure Functions (I’ll cover that in a later post). Azure Functions hide much of the boilerplate with an Event Hubs trigger, run serverlessly, and scale out automatically. In contrast, building a client “manually” as we’re doing here, gives you maximum control over connection behavior, batching, retry policies, and diagnostics.

    Let’s get started!

    Launch Visual Studio 2022. Then select Create a new project and choose Console App (C#). Name the project CESClient, click Next, and then click Create.

    Install NuGet Packages

    First, we’ll need three NuGet packages to support our application. Right-click the CESClient project and choose Manage NuGet Packages. Click the Browse tab, and then locate and install the following packages:

    • Azure.Messaging.EventHubs.Processor
      • Includes the Event Hubs client and processor, as well as Azure Blob Storage for checkpoint support.
    • Microsoft.Extensions.Configuration.Json
      • Supports external configuration in appsettings.json rather than using hard-coded configuration.
    • Newtonsoft.Json
      • Allows us to deserialize the CloudEvent payload received from the event hub, which is supplied as JSON.

    Add a Configuration File

    Now create the appsettings.json file where we’ll keep our configuration. This includes connection details and secrets (SAS token, Blob connection string).

    1. Right-click the project and choose Add > New Item
    2. Name the file appsettings.json.
    3. Replace its content with:
    {
      "EventHub": {
        "HostName": "ces-namespace.servicebus.windows.net",
        "Name": "ces-hub",
        "SasToken": "paste-your-sas-token-here"
      },
      "BlobStorage": {
        "ConnectionString": "paste-your-blob-connection-string-here",
        "ContainerName": "ces-checkpoint"
      }
    }
    
    1. For the EventHub property, note the HostName property specifies our event hub namespace name ces-namespace as the host name prefix, the Name property specifies our event hub name ces-hub, and the SasToken property holds the SAS token generated for accessing the event hub. All three of these values were established during setup and configuration in Part 1.
    2. For the BlobStorage property, paste in values for the ConnectionString and ContainerName for the Azure Storage blob container that you just created.
    3. To ensure this file gets copied to the output directory when we build the project, click appsettings.json in the Solution Explorer panel. Then, in the Properties panel set Copy to Output Directory to Copy if newer.

    Add the Code

    Now supply the following code in Program.cs:

    using Azure;
    using Azure.Messaging.EventHubs;
    using Azure.Messaging.EventHubs.Processor;
    using Azure.Messaging.EventHubs.Consumer;
    using Azure.Storage.Blobs;
    using Microsoft.Extensions.Configuration;
    using System;
    using System.Collections.Generic;
    using System.IO;
    using System.Text.Json;
    using System.Threading.Tasks;
    
    namespace CESClient
    {
      public class Program
      {
        private static int _eventCount;
    
        // Add methods here
    
      }
    }
    

    This imports all the namespaces we’ll be referencing and defines a private field as a simple event counter that we’ll increment with each received event.

    Next plug in the Main method:

    public static async Task Main(string[] args)
    {
      // Say hello
      Console.WriteLine("SQL Server 2025 Change Event Streaming Client");
      Console.WriteLine();
      Console.Write("Initializing... ");
    
      // Load configuration from appsettings.json
      var config = new ConfigurationBuilder()
          .SetBasePath(Directory.GetCurrentDirectory())
          .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
          .Build();
    
      // Create a blob container client that the event processor will use for checkpointing
      var blobStorageConnectionString = config["BlobStorage:ConnectionString"];
      var blobStorageContainerName = config["BlobStorage:ContainerName"];
    
      var storageClient = new BlobContainerClient(blobStorageConnectionString, blobStorageContainerName);
    
      // Create an event processor client to process events in the event hub
      var eventHubHostName = config["EventHub:HostName"];
      var eventHubName = config["EventHub:Name"];
      var sasToken = config["EventHub:SasToken"];
    
      var processor = new EventProcessorClient(
          storageClient,                                        // checkpoint store
          EventHubConsumerClient.DefaultConsumerGroupName,      // Basic tier: one consumer group (e.g., $Default)
          eventHubHostName,
          eventHubName,
          new AzureSasCredential(sasToken)
      );
    
      // Register handlers for processing events and errors
      processor.ProcessEventAsync += ProcessEventHandler;
      processor.ProcessErrorAsync += ProcessErrorHandler;
    
      // Start listening for events
      Console.Write("starting... ");
      _eventCount = 0;
    
      await processor.StartProcessingAsync();
    
      Console.WriteLine("waiting... press any key to stop.");
      Console.ReadKey(intercept: true);
    
      // Stop listening for events
      await processor.StopProcessingAsync();
    
      Console.WriteLine("Stopped");
    }
    

    This code loads the configuration from appsettings.json, creates the blob container client (for saving checkpoints), spins up the Event Hubs processor with the default consumer group, and attaches handlers for processing events and errors. Finally, it starts and stops cleanly when the user presses any key.

    Process Events

    Now add the ProcessEventHandler method. This method first parses the outer CloudEvent envelope, then the inner payload, prints helpful metadata, and routes to the insert/update/delete handlers. Finally (and critically), it updates the checkpoint so restarts will resume from the next event. (I explain the CloudEvent payload structure in Part 1.)

    private static async Task ProcessEventHandler(ProcessEventArgs eventArgs)
    {
      try
      {
        // Deserialize the event data
        using var doc = JsonDocument.Parse(eventArgs.Data.Body.ToArray());
        var root = doc.RootElement;
        var dataJson = root.GetProperty("data");
    
        using var innerDoc = JsonDocument.Parse(dataJson.GetString());
        var data = innerDoc.RootElement;
    
        Console.WriteLine($"Processing event... #{++_eventCount}");
    
        // Deserialize the "current" and "old" fields in the eventrow property of the event data to dictionaries
        var operation = root.GetProperty("operation").GetString();
        var cols = data.GetProperty("eventsource").GetProperty("cols").EnumerateArray();
        var current = JsonSerializer.Deserialize<Dictionary<string, string>>(data.GetProperty("eventrow").GetProperty("current").GetString());
        var old = JsonSerializer.Deserialize<Dictionary<string, string>>(data.GetProperty("eventrow").GetProperty("old").GetString());
    
        DisplayEventMetadata(eventArgs, root, data);
    
        switch (operation)
        {
          case "INS":
            ProcessInsert(cols, current);
            break;
          case "UPD":
            ProcessUpdate(cols, current, old);
            break;
          case "DEL":
            ProcessDelete(cols, old);
            break;
        }
    
        Console.WriteLine();
        Console.WriteLine(new string('-', 80));
        Console.WriteLine();
    
        // Persist progress so we don't reprocess this event on restart
        await eventArgs.UpdateCheckpointAsync();
      }
      catch (Exception ex)
      {
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine(ex.Message);
        Console.ResetColor();
      }
    }
    

    Display Event Metadata

    This method renders a quick “context dump” for each event: it first prints the sequence number and offset from ProcessEventArgs so you can pinpoint the event’s exact position within the event hub (useful for ordering and replay). It then surfaces key CloudEvent fields from the outer envelope; spec/version, the event type, the DML operation (INS, UPD, DEL), timestamp, unique ID, logical ID, and the data content type. Finally, it drills into the inner CES payload to show the database, schema, and table that produced the event. Together, these details make it easy to correlate what you’re seeing in the console with the emitting source and to troubleshoot issues like unexpected operations or schema mismatches.

    private static void DisplayEventMetadata(ProcessEventArgs eventArgs, JsonElement root, JsonElement data)
    {
      Console.WriteLine("Event Args");
      Console.WriteLine($"  Sequence:Offset => {eventArgs.Data.SequenceNumber}:{eventArgs.Data.Offset}");
      Console.WriteLine();
      Console.WriteLine("Event Data");
      Console.WriteLine($"  Spec version:       {root.GetProperty("specversion").GetString()}");
      Console.WriteLine($"  Operation:          {root.GetProperty("type").GetString()}");
      Console.WriteLine($"  Time:               {root.GetProperty("time").GetString()}");
      Console.WriteLine($"  Event ID:           {root.GetProperty("id").GetString()}");
      Console.WriteLine($"  Logical ID:         {root.GetProperty("logicalid").GetString()}");
      Console.WriteLine($"  Operation:          {root.GetProperty("operation").GetString()}");
      Console.WriteLine($"  Data content type:  {root.GetProperty("datacontenttype").GetString()}");
      Console.WriteLine();
      Console.WriteLine("Data");
      Console.WriteLine($"  Database:           {data.GetProperty("eventsource").GetProperty("db").GetString()}");
      Console.WriteLine($"  Schema:             {data.GetProperty("eventsource").GetProperty("schema").GetString()}");
      Console.WriteLine($"  Table:              {data.GetProperty("eventsource").GetProperty("tbl").GetString()}");
      Console.WriteLine();
    }
    
    

    Handle Inserts

    For inserts, a full “after” image is easiest to read and is a quick way to validate the @include_all_columns setting we established in Part 1.

    private static void ProcessInsert(JsonElement.ArrayEnumerator cols, Dictionary<string, string> current)
    {
      Console.WriteLine("Operation: Insert");
      Console.ForegroundColor = ConsoleColor.Green;
    
      foreach (var col in cols)
      {
        var name = col.GetProperty("name").GetString();
        Console.WriteLine($"\t{name}: {current[name]}");
      }
    
      Console.ResetColor();
    }
    
    

    Handle Updates

    For tables where we’ve enabled @include_old_values in Part 1, you’ll get a great side-by-side view; otherwise you’ll just see the “after” image.

    private static void ProcessUpdate(JsonElement.ArrayEnumerator cols, Dictionary<string, string> current, Dictionary<string, string> old)
    {
      Console.WriteLine("Operation: Update");
    
      foreach (var col in cols)
      {
        var name = col.GetProperty("name").GetString();
    
        if (old.Count > 0 && current[name] != old[name])
        {
          Console.ForegroundColor = ConsoleColor.Yellow;
          Console.WriteLine($"\t{name}: {current[name]} (old: {old[name]})");
          Console.ResetColor();
        }
        else
        {
          Console.WriteLine($"\t{name}: {current[name]}");
        }
      }
    }
    

    Handle Deletes

    Deletes only have the “before” image, which are useful for auditing and reconciliation purposes.

    private static void ProcessDelete(JsonElement.ArrayEnumerator cols, Dictionary<string, string> old)
    {
      Console.WriteLine("Operation: Delete");
      Console.ForegroundColor = ConsoleColor.Red;
    
      foreach (var col in cols)
      {
        var name = col.GetProperty("name").GetString();
        Console.WriteLine($"\t{name}: {old[name]}");
      }
    
      Console.ResetColor();
    }
    

    Process Errors

    Finally, we need to tolerate errors without crashing the application. Should an error occur, this method displays the exception details. Of course, a real-world scenario would require proper error handling; for example, saving the event details to a queue for automatic retry or manual intervention.

    private static Task ProcessErrorHandler(ProcessErrorEventArgs e)
    {
      Console.ForegroundColor = ConsoleColor.Red;
      Console.WriteLine(e.Exception.Message);
      Console.ResetColor();
      return Task.CompletedTask;
    }
    

    Run the Application

    The moment of truth is here! Go ahead and run the application. The client console window should open, and you should see:

    SQL Server 2025 Change Event Streaming Client
    Initializing... starting... waiting... press any key to stop.

    If errors appear, double-check configuration values and NuGet package installs.

    Generate and Monitor Change Events

    Let’s exercise inserts, updates, deletes, as well as trigger-driven changes, based on the database schema we setup in Part 1. This will allow us to observe the events being captured in real-time, and examine the CloudEvent payloads as we receive them.

    Start SSMS and open a query window to the CesDemo database. Then tile the SSMS and client console windows side-by-side. This way, you can examine the events in the client console window as you generate them from the SSMS window.

    Create an Order

    In SSMS, run the stored procedure to create a new order:

    EXEC CreateOrder @CustomerId = 1
    

    In the client console window, you should observe an INS event for the Order table.

    Create Order Details

    Now add two details to the order:

    EXEC CreateOrderDetail @OrderId = 1, @ProductId = 1, @Quantity = 2
    EXEC CreateOrderDetail @OrderId = 1, @ProductId = 2, @Quantity = 1
    

    Expect two corresponding INS events for OrderDetail. And because the OrderDetail trigger adjusts Product.ItemsInStock, also expect UPD events for Product reflecting stock decrements (2 for product 1; 1 for product 2).

    Delete an Order

    Now call the stored procedure that deletes an entire order, along with the order details.

    EXEC DeleteOrder @OrderId = 1
    

    Expect DEL events for the order and its details, and UPD events for Product as stock is restored.

    Update a Customer

    Now change a customer’s city to Chicago:

    UPDATE Customer SET City = 'Chicago' WHERE CustomerId = 1
    

    Expect a UPD event for Customer. Recall that in Part 1, we chose not to include old values for this table, so you’ll see only the “after” values.

    Bulk Update Products

    Let’s apply a 20% discount on the price of all cameras:

    UPDATE Product SET UnitPrice = UnitPrice * 0.8 WHERE Category = 'Camera'
    

    Expect UPD events for each matching row. In Part 1, we included old values and only changed columns for Product, so you’ll see the old and new UnitPrice (and the primary key, which is always included).

    Update a Table Without a Primary Key

    This last example demonstrates the need for always having a primary key defined on a table:

    UPDATE TableWithNoPK SET ItemName = 'Stove' WHERE Id = 3
    

    Expect a UPD event without key columns. So you’ll see that an item name was changed to Stove in some row, but you won’t know which row, rendering this event data as useless information.

    Conclusion

    That’s a wrap! In this two-part blog post series, you have successfully built out an end-to-end CES pipeline. First you configured SQL Server 2025 to stream changes into Azure Event Hubs with the appropriate SAS credential, event stream group, and table settings. Then you built a C# consumer that reads the CloudEvent-wrapped payloads and uses Azure Blob Storage for checkpoints. Our demo ran with the default consumer group (Basic tier) for a single application, but higher tiers support multiple groups for multiple independent clients. You now have a clean, real-time path from database changes to actionable events!

    Getting Started with Change Event Streaming in SQL Server 2025 (Part 1: Setup and Configuration)

    Change Event Streaming (CES) is one of the most exciting new features coming in SQL Server 2025. It allows you to continuously stream row-level changes from your tables directly into Azure Event Hubs, where multiple consumer applications can subscribe to the event data in real time.

    Note: This post is based on SQL Server 2025 CTP 2.1. Syntax and behavior are subject to subtle changes by the time the product is released. Change Event Streaming (CES) will ultimately be supported across all SKUs of SQL Server, including SQL Server 2025 for Windows, SQL Server 2025 for Linux, Azure SQL Database, and Managed Instance.

    In this two-part series, I’ll show you how to set up and configure CES (Part 1), and then how to build a consumer application to process the streamed changes (Part 2).

    Let’s dive in!

    Step 1: Create an Event Hub

    Before SQL Server can stream changes, you need a target destination. CES is designed to stream directly into Azure Event Hubs.

    Create an Event Hub Namespace

    An event hub lives within an event namespace. To create a new event hub namespace:

    1. In the Azure portal, create a new resource.
    2. From the Marketplace, create a new Event Hubs resource.
    3. Provide a name for a new Event Hubs namespace in either a new or existing resource group.
    4. Choose the Basic pricing tier with 1 throughput unit (sufficient for development and testing).
    5. Click Review + create, and then Create.

    Create an Event Hub

    Now you can create a new event hub within the new event hub namespace:

    1. On the namespace Overview page, click + Event Hub.
    2. Provide a name for the new event hub, and leave all other options at their default settings.
    3. Click Review + create, and then Create.

    Create an Event Hub Policy

    Now create a policy that allows managing the event hub:

    1. Under Settings on the left, click Shared Access Policies.
    2. Click + Add to create a new policy.
    3. Provide a name for the policy.
    4. Check Manage (which automatically includes Send and Listen).
    5. Click Create.

    Generate a SAS Token

    Finally, you’ll need a Shared Access Signature (SAS) token for SQL Server and other clients to authenticate against the Event Hub. Unfortunately, the Azure portal does not provide a GUI for generating SAS tokens for Event Hub, so you must generate one programmatically using PowerShell, Azure CLI, or the Azure SDK. In this walkthrough, we’ll use PowerShell.

    Install PowerShell Modules

    Run PowerShell as an administrator and install the necessary modules.

    Note: You only need to install these modules once on a machine. If you’ve already installed them previously, you can skip this step.

    # Install the general Azure cmdlets (this can take up to 20 minutes)
    Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force
    
    # Install the Event Hub module (runs quickly)
    Install-Module -Name Az.EventHub -Scope CurrentUser -Force
    
    

    Create the SAS Token Script

    Copy the following code into a new file named Generate-SasToken.ps1. This script was adapted from Microsoft’s documentation at https://learn.microsoft.com/en-us/sql/relational-databases/track-changes/change-event-streaming/configure.

    function Generate-SasToken {
    
        # Provide values for the following resources:
        $resourceGroupName  = "ces-demo-rg"
        $namespaceName      = "ces-namespace"
        $eventHubName       = "ces-hub"
        $policyName         = "ces-policy"
    
        # Login to Azure and select the Azure Subscription
        Connect-AzAccount -InformationAction SilentlyContinue | Out-Null
    
        # Validate the existence of the specified resource group, event hub namespace, and event hub
        Get-AzResourceGroup -Name $resourceGroupName -ErrorAction Stop | Out-Null
        Get-AzEventHubNamespace -ResourceGroupName $resourceGroupName -Name $namespaceName -ErrorAction Stop | Out-Null
        Get-AzEventHub -ResourceGroupName $resourceGroupName -NamespaceName $namespaceName -Name $eventHubName -ErrorAction Stop | Out-Null
    
        # Get the event hub authorization policy (it must have Manage rights)
        $policy = Get-AzEventHubAuthorizationRule -ResourceGroupName $resourceGroupName -NamespaceName $namespaceName -EventHubName $eventHubName -AuthorizationRuleName $policyName -ErrorAction SilentlyContinue
    
        if (-not ("Manage" -in $policy.Rights)) {
            throw "Authorization rule '$policyName' does not exist, or is missing the required 'Manage' right"
        }
    
        # Get the Primary Key of the Shared Access Policy
        $keys = Get-AzEventHubKey -ResourceGroupName $resourceGroupName -NamespaceName $namespaceName -EventHubName $eventHubName -AuthorizationRuleName $policyName
    
        if (-not $keys) {
            throw "Could not obtain Azure Event Hub Key"
        }
    
        if (-not $keys.PrimaryKey) {
            throw "Could not obtain Primary Key"
        }
    
        $primaryKey = ($keys.PrimaryKey) 
    
        # Define a function to create the SAS token
        function Create-SasToken {
            param ([string]$resourceUri, [string]$keyName, [string]$key)
    
            $sinceEpoch = [datetime]::UtcNow - [datetime]"1970-01-01"
            $expiry = [int]$sinceEpoch.TotalSeconds + (60 * 60 * 24 * 31 * 6)  # 6 months
            $stringToSign = [System.Web.HttpUtility]::UrlEncode($resourceUri) + "`n" + $expiry
            $hmac = New-Object System.Security.Cryptography.HMACSHA256
            $hmac.Key = [Text.Encoding]::UTF8.GetBytes($key)
            $signature = [Convert]::ToBase64String($hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($stringToSign)))
            $sasToken = "SharedAccessSignature sr=$([System.Web.HttpUtility]::UrlEncode($resourceUri))&sig=$([System.Web.HttpUtility]::UrlEncode($signature))&se=$expiry&skn=$keyName"
    
            return $sasToken
        }
    
        # Construct the resource URI for the SAS token
        $resourceUri = "https://$namespaceName.servicebus.windows.net/$eventHubName"
    
        # Generate the SAS token using the primary key from the new policy
        $sasToken = Create-SasToken -resourceUri $resourceUri -keyName $policyName -key $primaryKey
    
        # Output the SAS token
        Write-Host "`n-- Generated SAS Token --" -ForegroundColor Gray
        Write-Host $sasToken -ForegroundColor White
        Write-Host "-- End of generated SAS Token --`n" -ForegroundColor Gray
    
        # Copy the SAS token to the clipboard
        $sasToken | Set-Clipboard
        Write-Host "The generated SAS token has been copied to the clipboard." -ForegroundColor Green
    }
    
    Generate-SasToken
    
    

    At the top of the script (lines 4 through 7), fill in the values for your resource group, namespace, event hub, and policy. Also, the script generates a token that expires after 6 months. To adjust the expiration, edit the $expiry assignment on line 42.

    Run the Script

    Before you can execute the script, you must allow PowerShell to run local scripts:

    Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
    
    

    When prompted for confirmation, type A (for “Yes to All”) and press Enter.

    Now run the script:

    .\Generate-SasToken.ps1
    
    

    You’ll be prompted to log in to your Microsoft account and select your Azure subscription. The script will then generate the SAS token and display it between the lines:

    -- Generated SAS Token --
    <your SAS token>
    -- End of generated SAS Token --
    
    

    The script also copies the generated SAS token to the clipboard so that you can paste it into Notepad. You’ll need it later when configuring SQL Server to stream events to Event Hubs, and then again (Part 2) when configuring your consumer applications that subsequently read those events from Event Hubs.

    Step 2: Create the Demo Database

    We’ll use a small sample database to demonstrate CES. Run this script in SSMS to create the database:

    -- Create the demo database
    USE master
    GO
    
    CREATE DATABASE CesDemo
    GO
    
    USE CesDemo
    GO
    
    -- Create some demo tables
    CREATE TABLE Customer (
      CustomerId    int IDENTITY PRIMARY KEY,
      CustomerName  varchar(50),
      City          varchar(20)
    )
    GO
    
    SET IDENTITY_INSERT Customer ON
    INSERT INTO Customer
      (CustomerId,  CustomerName,               City) VALUES
      (1,           'Shutter Bros Wholesale',   'New York'),
      (2,           'Aperture Supply Co.',      'Los Angeles')
    SET IDENTITY_INSERT Customer OFF
    
    CREATE TABLE Product (
      ProductId     int IDENTITY PRIMARY KEY,
      Name          varchar(80),
      Color         varchar(15),
      Category      varchar(20),
      UnitPrice     decimal(8, 2),
      ItemsInStock  smallint
    )
    GO
    
    SET IDENTITY_INSERT Product ON
    INSERT INTO Product
      (ProductId, Name,                                  Color,     Category,       UnitPrice,  ItemsInStock) VALUES 
      (1,         'Canon EOS R5 Mirrorless Camera',      'Black',   'Camera',       3899.99,    10),
      (2,         'Nikon Z6 II Mirrorless Camera',       'Silver',  'Camera',       1996.95,    8),
      (3,         'Sony NP-FZ100 Rechargeable Battery',  'Black',   'Accessory',    78.00,      25)
    SET IDENTITY_INSERT Product OFF
    
    CREATE TABLE [Order] (
      OrderId       int IDENTITY PRIMARY KEY,
      CustomerId    int REFERENCES Customer(CustomerId),
      OrderDate     datetime2
    )
    GO
    
    CREATE TABLE OrderDetail (
      OrderDetailId int IDENTITY PRIMARY KEY,
      OrderId       int REFERENCES [Order](OrderId),
      ProductId     int REFERENCES Product(ProductId),
      Quantity      smallint
    )
    GO
    
    -- This table lacks Primary Key. Combining that with IncludeAllColumns = 0 results in events that
    -- have no primary key, which is essentially useless
    CREATE TABLE TableWithNoPK (
      Id        int IDENTITY,
      ItemName  varchar(50)
    )
    GO
    
    INSERT INTO TableWithNoPK (ItemName) VALUES
      ('Camera'),
      ('Automobile'),
      ('Oven'),
      ('Couch')
    GO
    
    -- Create a DML trigger on OrderDetail that updates the ItemsInStock column in the Product table
    -- based on the Quantity column in the OrderDetail table
    CREATE TRIGGER trgUpdateItemsInStock ON OrderDetail AFTER INSERT, UPDATE, DELETE
    AS
    BEGIN
      -- Handle insert
      IF EXISTS (SELECT * FROM inserted) AND NOT EXISTS (SELECT * FROM deleted)
        UPDATE Product
        SET ItemsInStock = p.ItemsInStock - i.Quantity
        FROM
          Product AS p
          INNER JOIN inserted AS i ON p.ProductId = i.ProductId
    
      -- Handle update
      ELSE IF EXISTS (SELECT * FROM inserted) AND EXISTS (SELECT * FROM deleted) AND UPDATE(Quantity)
        UPDATE Product
        SET ItemsInStock = p.ItemsInStock + d.Quantity - i.Quantity
        FROM
          Product AS p
          INNER JOIN inserted AS i ON p.ProductId = i.ProductId
          INNER JOIN deleted AS d ON p.ProductId = d.ProductId
    
      -- Handle delete
      ELSE IF EXISTS (SELECT * FROM deleted) AND NOT EXISTS (SELECT * FROM inserted)
        UPDATE Product
        SET ItemsInStock = p.ItemsInStock + d.Quantity
        FROM
          Product AS p
          INNER JOIN deleted AS d ON p.ProductId = d.ProductId
    END
    GO
    
    -- Add some procs to handle orders
    CREATE OR ALTER PROC CreateOrder
      @CustomerId int
    AS
    BEGIN
      INSERT INTO [Order](CustomerId, OrderDate)
      VALUES (@CustomerId, SYSDATETIME())
    
      SELECT OrderId = SCOPE_IDENTITY()
    END
    GO
    
    CREATE OR ALTER PROC CreateOrderDetail
      @OrderId int,
      @ProductId int,
      @Quantity smallint
    AS
    BEGIN
      INSERT INTO OrderDetail (OrderId, ProductId, Quantity)
      VALUES (@OrderId, @ProductId, @Quantity)
    
      SELECT OrderDetailId = SCOPE_IDENTITY()
    END
    GO
    
    CREATE OR ALTER PROC DeleteOrder
      @OrderId int
    AS
    BEGIN
      BEGIN TRANSACTION
        DELETE FROM OrderDetail WHERE OrderId = @OrderId
        DELETE FROM [Order] WHERE OrderId = @OrderId
      COMMIT TRANSACTION
    END
    GO
    
    

    This database includes:

    • Customer, Order, OrderDetail, and Product tables
    • Stored procedures for inserting/deleting rows in the Order and OrderDetail tables.
    • A trigger on OrderDetail that updates inventory in Product. This is to demonstrate that CES also streams changes to tables that are updated by triggers, not just changes to tables that you issue direct DML statements on.
    • A TableWithNoPK to illustrate why tables without primary keys can be problematic when used with CES.

    Step 3: Configure CES

    With the Event Hub and database ready, let’s enable CES.

    Create a Database Master Key

    You need to store the SAS token in SQL Server as a database scoped credential, and that requires creating a password-protected database master key first so that SQL Server encrypt that SAS token credential.

    CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'H@rd2Gue$$P@$$w0rd'
    
    

    Create a Database Scoped Credential

    To store the SAS token securely in SQL Server, run the following statement (paste the SAS token you copied to Notepad into the SECRET parameter—keep the entire string intact).

    CREATE DATABASE SCOPED CREDENTIAL SqlCesCredential
    WITH
      IDENTITY = 'SHARED ACCESS SIGNATURE',
      SECRET = '<your SAS token>'
    
    

    Enable CES for the Database

    Execute this T-SQL to enable CES for the current database:

    EXEC sys.sp_enable_event_stream
    
    

    Now verify that CES is enabled:

    SELECT * FROM sys.databases WHERE is_event_stream_enabled = 1
    
    

    Create an Event Stream Group

    An event stream group defines the Event Hub target for your events. Be sure to provide the correct values for your event hub namespace and event hub names in the @destination_location parameter:

    EXEC sys.sp_create_event_stream_group
      @stream_group_name      = 'SqlCesGroup',
      @destination_location   = 'ces-namespace.servicebus.windows.net/ces-hub',
      @destination_credential = SqlCesCredential,
      @destination_type       = 'AzureEventHubsAmqp'
    
    

    Add Tables to the Event Stream Group

    Decide whether to include old values and whether to include all columns. Each table in our demo uses different settings for different reasons; old values and all values are included when we need that extra context, and they are excluded in favor of reduced bandwidth for smaller event payloads when we don’t.

    -- Customer: full row in each event, no old values
    EXEC sys.sp_add_object_to_event_stream_group
      @stream_group_name = 'SqlCesGroup',
      @object_name = 'dbo.Customer',
      @include_old_values = 0,      -- do not include old values on updates/deletes
      @include_all_columns = 1      -- include all columns even if unchanged
    
    -- Product: only changed columns, include old values (important for inventory diffs)
    EXEC sys.sp_add_object_to_event_stream_group
      @stream_group_name = 'SqlCesGroup',
      @object_name = 'dbo.Product',
      @include_old_values = 1,      -- include old values for changed columns
      @include_all_columns = 0      -- only include changed columns
    
    -- Order: only changed columns, include old values (for auditing changes)
    EXEC sys.sp_add_object_to_event_stream_group
      @stream_group_name = 'SqlCesGroup',
      @object_name = 'dbo.Order',
      @include_old_values = 1,      -- include old values for changed columns
      @include_all_columns = 0      -- only include changed columns
    
    -- OrderDetail: only changed columns, include old values (quantity updates matter)
    EXEC sys.sp_add_object_to_event_stream_group
      @stream_group_name = 'SqlCesGroup',
      @object_name = 'dbo.OrderDetail',
      @include_old_values = 1,      -- include old values for changed columns
      @include_all_columns = 0      -- only include changed columns
    
    -- TableWithNoPK: demonstrates CES limitations without a primary key
    EXEC sys.sp_add_object_to_event_stream_group
      @stream_group_name = 'SqlCesGroup',
      @object_name = 'dbo.TableWithNoPK',
      @include_old_values = 0,      -- no old values
      @include_all_columns = 0      -- changed columns only (essentially useless without a PK)
    
    
    • Customer: All columns included for upsert scenarios. Old values aren’t important here.
    • Product: Old values are essential to calculate stock and pricing diffs.
    • Order: Old values matter for audit.
    • OrderDetail: Old and new quantities are needed for downstream stock adjustments.
    • TableWithNoPK: Included as a demo; in real-world scenarios, CES requires a primary key to make the events useful.

    Verify CES on Each Table

    Run the following statements to confirm that CES is enabled on all the tables you just added to the event stream group, and to view the associated CES metadata associated with each table:

    EXEC sp_help_change_feed_table @source_schema = 'dbo', @source_name = 'Customer'
    EXEC sp_help_change_feed_table @source_schema = 'dbo', @source_name = 'Product'
    EXEC sp_help_change_feed_table @source_schema = 'dbo', @source_name = 'Order'
    EXEC sp_help_change_feed_table @source_schema = 'dbo', @source_name = 'OrderDetail'
    EXEC sp_help_change_feed_table @source_schema = 'dbo', @source_name = 'TableWithNoPK'
    
    

    The CloudEvent Payload

    At this point, CES is fully configured! From here on out, all changes to these tables will be streamed to your Event Hub, where they can be consumed by multiple clients in real-time. Specifically, each event is generated and streamed as a CloudEvent with the following JSON structure:

    {
    	"specversion": "1.0",
    	"type": "com.microsoft.SQL.CES.DML.V1",
    	"source": "\/",
    	"id": "cc3fcdca-09c0-4f46-a8d3-5d0c3c1eb85a",
    	"logicalid": "8376457a-17af-49f4-b9ea-0d5071f515f4:0000002C000007300011:00000000000000000002",
    	"time": "2025-06-30T12:29:46.290Z",
    	"datacontenttype": "application\/avro-json",
    	"operation": "UPD",
    	"segmentindex": 1,
    	"finalsegment": true,
    	"data": "{\n  \"eventsource\": {\n    \"db\": \"CesDemo\",\n    \"schema\": \"dbo\",\n    \"tbl\": \"Product\",\n    \"cols\": [\n      {\n        \"name\": \"ProductId\",\n        \"type\": \"int\",\n        \"index\": 0\n      },\n      {\n        \"name\": \"ItemsInStock\",\n        \"type\": \"smallint\",\n        \"index\": 5\n      }\n    ],\n    \"pkkey\": [\n      {\n        \"columnname\": \"ProductId\",\n        \"value\": \"2\"\n      }\n    ],\n    \"transaction\": {\n      \"commitlsn\": \"0000002C:00000730:0011\",\n      \"beginlsn\": \"0000002C:00000730:000C\",\n      \"sequencenumber\": 2,\n      \"committime\": \"2025-06-30T12:29:46.290Z\"\n    }\n  },\n  \"eventrow\": {\n    \"old\": \"{\\\"ProductId\\\": \\\"2\\\", \\\"ItemsInStock\\\": \\\"8\\\"}\",\n    \"current\": \"{\\\"ProductId\\\": \\\"2\\\", \\\"ItemsInStock\\\": \\\"7\\\"}\"\n  }\n}"
    }
    

    In this sample, the operation property is UPD, indicating an UPDATE operation on a table. Also notice that there is nested JSON contained in the data property, which you can unpack to get the necessary details of each DML operation:

    {
    	"eventsource": {
    		"db": "CesDemo",
    		"schema": "dbo",
    		"tbl": "Product",
    		"cols": [
    			{
    				"name": "ProductId",
    				"type": "int",
    				"index": 0
    			},
    			{
    				"name": "ItemsInStock",
    				"type": "smallint",
    				"index": 5
    			}
    		],
    		"pkkey": [
    			{
    				"columnname": "ProductId",
    				"value": "2"
    			}
    		],
    		"transaction": {
    			"commitlsn": "0000002C:00000730:0011",
    			"beginlsn": "0000002C:00000730:000C",
    			"sequencenumber": 2,
    			"committime": "2025-06-30T12:29:46.290Z"
    		}
    	},
    	"eventrow": {
    		"old": "{\"ProductId\": \"2\", \"ItemsInStock\": \"8\"}",
    		"current": "{\"ProductId\": \"2\", \"ItemsInStock\": \"7\"}"
    	}
    }
    

    Here, the eventsource property describes the database, schema, table, columns, primary key, and transaction for the event. And the eventrow property is yet another nested level of JSON within the CloudEvent which provides the actual column values affected by the event as a collection of key/value pairs.

    In Part 2, we’ll build a consumer application that listens for events, unpackages the CloudEvent payload, and processes them in real time.