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))GOINSERT 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, EmailFROM ReviewWHERE 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*/ )
| ReviewId | Name | |
|---|---|---|
| 1 | John Doe | john@contoso.com |
| 3 | Mary Jo Anne Erickson | mary.jo.anne@acme.co.uk |
| 4 | Max Wong | max@fabrikam.com |
| 6 | Terri S Duffy | terri.duffy@acme.com |
| 8 | Charlie Brown | charlie@contoso.co.in |
Get rows with valid email addresses that end with .com
SELECT ReviewId, Name, EmailFROM ReviewWHERE 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*/ )
| ReviewId | Name | |
|---|---|---|
| 1 | John Doe | john@contoso.com |
| 4 | Max Wong | max@fabrikam.com |
| 6 | Terri S Duffy | terri.duffy@acme.com |
Get rows with valid phone numbers
SELECT ReviewId, Name, PhoneFROM ReviewWHERE 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*/ )
| ReviewId | Name | Phone |
|---|---|---|
| 3 | Mary Jo Anne Erickson | 456-789-1234 |
| 5 | Bob Johnson | 345-678-9012 |
| 6 | Terri S Duffy | 678-901-2345 |
| 7 | Eve Jones | 456-789-0123 |
| 8 | Charlie Brown | 587-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 ENDFROM Review
| ReviewId | Name | IsEmailValid | Phone | IsPhoneValid | |
|---|---|---|---|---|---|
| 1 | John Doe | john@contoso.com | 1 | 123-4567890 | 0 |
| 2 | Alice Smith | alice@fabrikam@com | 0 | 234-567-81 | 0 |
| 3 | Mary Jo Anne Erickson | mary.jo.anne@acme.co.uk | 1 | 456-789-1234 | 1 |
| 4 | Max Wong | max@fabrikam.com | 1 | 0 | |
| 5 | Bob Johnson | bob.fabrikam.net | 0 | 345-678-9012 | 1 |
| 6 | Terri S Duffy | terri.duffy@acme.com | 1 | 678-901-2345 | 1 |
| 7 | Eve Jones | 0 | 456-789-0123 | 1 | |
| 8 | Charlie Brown | charlie@contoso.co.in | 1 | 587-890-1234 | 1 |
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
| Name | VowelCount |
|---|---|
| John Doe | 3 |
| Alice Smith | 4 |
| Mary Jo Anne Erickson | 7 |
| Max Wong | 2 |
| Bob Johnson | 3 |
| Terri S Duffy | 3 |
| Eve Jones | 4 |
| Charlie Brown | 4 |
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
| Name | GoodSentimentWordCount | BadSentimentWordCount |
|---|---|---|
| John Doe | 3 | 0 |
| Alice Smith | 1 | 1 |
| Mary Jo Anne Erickson | 0 | 4 |
| Max Wong | 1 | 0 |
| Bob Johnson | 1 | 0 |
| Terri S Duffy | 0 | 1 |
| Eve Jones | 2 | 0 |
| Charlie Brown | 0 | 2 |
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 tableALTER 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 ReviewWHERE (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
| ReviewId | Name | Phone | |
|---|---|---|---|
| 3 | Mary Jo Anne Erickson | mary.jo.anne@acme.co.uk | 456-789-1234 |
| 4 | Max Wong | max@fabrikam.com | |
| 6 | Terri S Duffy | terri.duffy@acme.com | 678-901-2345 |
| 7 | Eve Jones | 456-789-0123 | |
| 8 | Charlie Brown | charlie@contoso.co.in | 587-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 tableALTER 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 numberINSERT 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 addressSELECT 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 nameFROM Review
| ReviewId | Name | DomainName | |
|---|---|---|---|
| 3 | Mary Jo Anne Erickson | mary.jo.anne@acme.co.uk | acme.co.uk |
| 4 | Max Wong | max@fabrikam.com | fabrikam.com |
| 6 | Terri S Duffy | terri.duffy@acme.com | acme.com |
| 7 | Eve Jones | ||
| 8 | Charlie Brown | charlie@contoso.co.in | contoso.co.in |
| 9 | John Doe | john@fabrikam.com | fabrikam.com |
| 10 | Alice Smith | alice.smith@fabrikam.co.uk | fabrikam.co.uk |
| 11 | Bob Johnson | bob@fabrikam.com | fabrikam.com |
| 12 | Stuart Green | stuart.green@acme.com | acme.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 DomainNameCteGROUP BY DomainNameORDER BY DomainName
| DomainName | DomainCount |
|---|---|
| 1 | |
| acme.co.uk | 1 |
| acme.com | 2 |
| contoso.co.in | 1 |
| fabrikam.co.uk | 1 |
| fabrikam.com | 3 |
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 @*/
| ReviewId | At | DotAfterAt | |
|---|---|---|---|
| 3 | mary.jo.anne@acme.co.uk | 13 | 18 |
| 4 | max@fabrikam.com | 4 | 13 |
| 6 | terri.duffy@acme.com | 12 | 17 |
| 7 | |||
| 8 | charlie@contoso.co.in | 8 | 16 |
| 9 | john@fabrikam.com | 5 | 14 |
| 10 | alice.smith@fabrikam.co.uk | 12 | 21 |
| 11 | bob@fabrikam.com | 4 | 13 |
| 12 | stuart.green@acme.com | 13 | 18 |
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
| ReviewId | Name | ShortName |
|---|---|---|
| 3 | Mary Jo Anne Erickson | Mary Erickson |
| 4 | Max Wong | Max Wong |
| 6 | Terri S Duffy | Terri Duffy |
| 7 | Eve Jones | Eve Jones |
| 8 | Charlie Brown | Charlie Brown |
| 9 | John Doe | John Doe |
| 10 | Alice Smith | Alice Smith |
| 11 | Bob Johnson | Bob Johnson |
| 12 | Stuart Green | Stuart 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_id | start_position | end_position | match_value | substring_matches |
|---|---|---|---|---|
| 1 | 1 | 3 | ATE | [{“value”:”A”,”start”:1,”length”:1},{“value”:”TE”,”start”:2,”length”:2}] |
| 2 | 11 | 13 | ACT | [{“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_id | start_position | end_position | match_value | substring_matches |
|---|---|---|---|---|
| 1 | 10 | 18 | #AzureSQL | [{“value”:”AzureSQL”,”start”:11,”length”:8}] |
| 2 | 20 | 30 | #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
| ReviewId | match_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) )
| ordinal | value |
|---|---|
| 1 | the |
| 2 | quick |
| 3 | brown |
| 4 | fox |
| 5 | jumped |
| 6 | over |
| 7 | the |
| 8 | lazy |
| 9 | dog |
Extract individual words from review text
SELECT r.ReviewId, r.ReviewText, WordText = s.value, WordPosition = s.ordinalFROM 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.ordinalFROM 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.

Leave a comment