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.