Day 13: String functions & pattern matching
On this page
By the end of this lesson you can
- Explain the difference between
LENandDATALENGTH, and betweenvarcharandnvarchar. - Locate text with
CHARINDEXandPATINDEX, and extract parts withSUBSTRING,LEFTandRIGHT— guarding against "not found". - Clean text with
TRIM,REPLACE,TRANSLATEandSTUFF, including collapsing repeated spaces. - Write
LIKEpatterns with%,_,[ ],[^ ]andESCAPE, and validate formats withREGEXP_LIKE. - Split delimited text into rows with
STRING_SPLIT, using the ordinal column when position matters. - Concatenate rows into a list with
STRING_AGG … WITHIN GROUP (ORDER BY …), including a distinct list. - Combine these functions to standardise messy imported data.
A. Lesson overview
| Week | 2 — Joins, set operations and advanced retrieval |
| Level | Intermediate |
| Estimated time | 2 h 30 min essential + 30 min optional stretch |
| Prerequisites | Days 1–12, especially LIKE (Day 1), CROSS APPLY (Day 10) and STRING_AGG in dynamic SQL (Day 11) |
| Syllabus topic | STRING Functions & Pattern Matching — LIKE, PATINDEX, STRING_SPLIT, STRING_AGG, CONCAT |
| Version note | REGEXP_LIKE is new in SQL Server 2025 and needs compatibility level 170 — which RetailLab uses |
Activity breakdown
| Activity | Time |
|---|---|
| Part 1 — Strings, lengths, Unicode and collation | 15 min |
Part 2 — Finding and extracting: CHARINDEX, PATINDEX, SUBSTRING, LEFT, RIGHT |
20 min |
Part 3 — Changing text: REPLACE, TRANSLATE, STUFF, TRIM, case |
15 min |
Part 4 — Pattern matching: LIKE and regular expressions |
20 min |
Part 5 — Splitting and aggregating: STRING_SPLIT, STRING_AGG |
15 min |
| Worked examples | 20 min |
| Guided lab | 25 min |
| Independent practice | 15 min |
| Knowledge check and wrap-up | 5 min |
| Stretch challenge (optional) | 30 min |
Business scenario. The customer import in Staging.CustomerImport is messy: names with extra spaces and lower-case letters, emails with capitals and leading spaces, phone numbers in different formats, countries written as “UK” or “united kingdom”, and two email addresses that cannot be valid. Before Day 15’s load, the data team needs a clean, standardised version and a report of what could not be fixed.
B. Theory explained clearly
Part 1 — Strings, lengths, Unicode and collation
| Type | Stores | Bytes per character (typical) |
|---|---|---|
varchar(n) / char(n) |
Characters from the column’s code page | 1 for Western European text |
nvarchar(n) / nchar(n) |
Unicode (UTF-16) | 2 (4 for some rarer characters) |
char/nchar are fixed length and padded with spaces; varchar/nvarchar store only what you give them. RetailLab stores names as nvarchar (so Zoë, Søren and François are safe) and codes as varchar.
| Function | Returns | Example | Result |
|---|---|---|---|
LEN(s) |
Number of characters, excluding trailing spaces | LEN(N'Zoë ') |
3 |
DATALENGTH(s) |
Number of bytes, including trailing spaces | DATALENGTH(N'Zoë ') |
10 |
Collation decides how text is compared and sorted. RetailLab uses Latin1_General_100_CI_AS: case-insensitive ('A' = 'a'), accent-sensitive ('e' <> 'é'). Comparisons with = and LIKE follow it. To force a case-sensitive comparison in one expression: WHERE Email COLLATE Latin1_General_100_CS_AS = 'Alice…'.
Trailing spaces are ignored by = ('abc' = 'abc ' is TRUE) but leading spaces are not. LIKE does not ignore trailing spaces in the pattern.
Part 2 — Finding and extracting
| Function | Returns | Not found |
|---|---|---|
CHARINDEX(find, s [, start]) |
Position (1-based) of the first occurrence of a literal string | 0 |
PATINDEX('%pattern%', s) |
Position of the first match of a LIKE-style pattern |
0 |
SUBSTRING(s, start, length) |
Part of s |
Empty string if start is beyond the end |
LEFT(s, n), RIGHT(s, n) |
First / last n characters |
Error if n is negative |
DECLARE @e nvarchar(255) = N'alice.morgan@example.com';
SELECT CHARINDEX(N'@', @e) AS AtPos, -- 13
LEFT(@e, CHARINDEX(N'@', @e) - 1) AS LocalPart, -- alice.morgan
SUBSTRING(@e, CHARINDEX(N'@', @e) + 1, LEN(@e)) AS Domain; -- example.com
Guard against “not found”. If there is no @, CHARINDEX returns 0 and LEFT(@e, -1) raises Invalid length parameter passed to the LEFT or SUBSTRING function. Protect with NULLIF(CHARINDEX(…), 0) (making the position NULL, so the result is NULL), or with a CASE. Passing a length longer than the string to SUBSTRING is harmless, which is why LEN(@e) is a convenient “rest of the string”.
Part 3 — Changing text
| Function | Purpose | Example | Result |
|---|---|---|---|
UPPER, LOWER |
Change case | LOWER(N'BEN') |
ben |
TRIM(s), LTRIM, RTRIM |
Remove leading/trailing spaces (SQL Server 2022+ can also remove other characters: TRIM('.' FROM s)) |
TRIM(N' x ') |
x |
REPLACE(s, find, with) |
Replace every occurrence | REPLACE('07700 900301', ' ', '') |
07700900301 |
TRANSLATE(s, from, to) |
Replace characters one-for-one | TRANSLATE('(01) 2-3', '()-', ' ') |
01 2 3 |
STUFF(s, start, length, insert) |
Delete part of a string and insert another | STUFF('alice', 2, 3, '***') |
a***e |
REPLICATE(s, n) |
Repeat | REPLICATE('*', 3) |
*** |
REVERSE(s) |
Reverse | REVERSE('abc') |
cba |
Collapsing repeated spaces. REPLACE(s, ' ', ' ') only halves runs of spaces. A reliable one-pass trick uses two marker characters that never occur in the data:
REPLACE(REPLACE(REPLACE(TRIM(s), N' ', N'<>'), N'><', N''), N'<>', N' ')
-- 'Daisy Holt' → 'Daisy<><><>Holt' → 'Daisy<>Holt' → 'Daisy Holt'
Part 4 — Pattern matching
LIKE
| Wildcard | Matches | Example |
|---|---|---|
% |
Any string of zero or more characters | '%@%' contains @ |
_ |
Exactly one character | 'TNT-00_' |
[abc], [a-z] |
One character from a set or range | '[A-Z][A-Z][A-Z]-[0-9][0-9][0-9]' |
[^…] |
One character not in the set | '%[^0-9 +]%' — contains something other than digits, spaces, + |
To match a literal %, _ or [, either wrap it in brackets ('%[%]%') or use ESCAPE: LIKE '%50!%%' ESCAPE '!'.
LIKE is good for simple shapes. It cannot express repetition (“one or more letters”) or alternatives, so format validation with LIKE is approximate: '%_@_%._%' accepts a@b.c but also a@b..c.
Regular expressions (SQL Server 2025)
SQL Server 2025 adds regular-expression functions: REGEXP_LIKE, REGEXP_COUNT, REGEXP_INSTR, REGEXP_REPLACE, REGEXP_SUBSTR, and the table-valued REGEXP_MATCHES and REGEXP_SPLIT_TO_TABLE. REGEXP_LIKE, REGEXP_MATCHES and REGEXP_SPLIT_TO_TABLE require database compatibility level 170.
WHERE REGEXP_LIKE(Email, '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$', 'i')
| Flag | Meaning |
|---|---|
c |
Case-sensitive (the default, regardless of collation) |
i |
Case-insensitive |
m |
Multi-line: ^ and $ match at line breaks |
s |
. also matches a newline |
REGEXP_LIKE returns a Boolean, so it is used as a predicate (WHERE, CASE WHEN, CHECK), not as a column value. Regular expressions are powerful but expensive on large tables, and no email pattern is perfect — validate the format, then confirm by sending an email. On older servers or other platforms, fall back to LIKE/PATINDEX patterns.
Part 5 — Splitting and aggregating
STRING_SPLIT
SELECT value, ordinal
FROM STRING_SPLIT(N'tent,2-person,lightweight', N',', 1); -- third argument 1 = return ordinal
| Column | Meaning |
|---|---|
value |
Each piece |
ordinal |
Position of the piece (only when the third argument is 1; SQL Server 2022 and later) |
- The separator must be a single character.
- Without
ordinal, the output order is not guaranteed. Use the ordinal whenever position matters (“the first tag”). - Empty pieces are returned as empty strings:
'a,,b'gives three rows. - It is a table-valued function, so use it with
CROSS APPLYto split a column (Day 10).
STRING_AGG
STRING_AGG(expression, separator) [WITHIN GROUP (ORDER BY …)]
- Concatenates the values of a group, ignoring NULLs.
WITHIN GROUP (ORDER BY …)makes the order deterministic.- There is no
DISTINCToption: de-duplicate in a derived table or CTE first. - If the inputs are
varchar(n)/nvarchar(n), the result is limited to 8,000 bytes and exceeding it raises an error; cast the input tonvarchar(max)for long lists.
STRING_SPLIT and STRING_AGG are inverses in spirit: one turns a list into rows, the other rows into a list. Storing comma-separated lists in a column — as Inventory.Product.Tags does, for teaching — makes searching and validation harder; a separate ProductTag table is the normalised design.
C. Dataset and starting state
| Dataset | Shared synthetic dataset (Ridgeway Outdoor), core data only |
| Database | RetailLab (compatibility level 170 — required for REGEXP_LIKE) |
| Tables used | Sales.Customer, Sales.SalesOrder, Sales.SalesOrderLine, Inventory.Product, Staging.CustomerImport |
| Teaching cases used | Accented names; NULL emails; staging names with extra spaces and lower case, emails with capitals and leading spaces, two invalid emails, a NULL and a blank name, UK phone numbers in local format, inconsistent country names |
| Permissions | SELECT on these tables |
| Objects created today | None — Day 13 is read-only |
Preparation. None if RetailLab is unchanged. Otherwise run scripts/setup/04_reset_training_data.sql in SQLCMD Mode (destructive) or scripts 00 → 03.
Validation. Expected: 170, 15, 30.
USE RetailLab;
GO
SELECT (SELECT compatibility_level FROM sys.databases WHERE name = N'RetailLab') AS CompatLevel,
(SELECT COUNT(*) FROM Staging.CustomerImport) AS ImportRows,
(SELECT COUNT(*) FROM Inventory.Product WHERE Tags IS NOT NULL) AS TaggedProducts;
D. Worked examples
Worked example 1 — Lengths, bytes and Unicode
SELECT LEN(N'Zoë') AS LenUnicode, -- 3
DATALENGTH(N'Zoë') AS BytesUnicode, -- 6
LEN('abc ') AS LenTrailing, -- 3
DATALENGTH('abc ') AS BytesTrailing, -- 6
CASE WHEN 'abc' = 'abc ' THEN 'equal' ELSE 'different' END AS TrailingCompare, -- equal
CASE WHEN 'abc' = ' abc' THEN 'equal' ELSE 'different' END AS LeadingCompare; -- different
Expected result: 3, 6, 3, 6, equal, different.
Worked example 2 — Customers by email domain
SELECT COALESCE(SUBSTRING(Email, NULLIF(CHARINDEX(N'@', Email), 0) + 1, LEN(Email)), N'(none)') AS Domain,
COUNT(*) AS Customers
FROM Sales.Customer
GROUP BY COALESCE(SUBSTRING(Email, NULLIF(CHARINDEX(N'@', Email), 0) + 1, LEN(Email)), N'(none)')
ORDER BY Customers DESC, Domain;
Expected result: 11 rows. example.com 22, then (none) 4 and example.fr 4, then example.de and example.ie 2 each, then six domains with 1 (example.dk, example.es, example.it, example.net, example.org, example.se).
What to notice: NULLIF(CHARINDEX(…), 0) makes the start position NULL when there is no @, so SUBSTRING returns NULL instead of the whole string — and a NULL email also produces NULL. COALESCE then labels both. Repeating the expression in GROUP BY is clumsy; Task 1 tidies it with CROSS APPLY.
Worked example 3 — Product codes that follow the house format
SELECT ProductCode,
CASE WHEN ProductCode LIKE '[A-Z][A-Z][A-Z]-[0-9][0-9][0-9]' THEN 'Valid' ELSE 'Invalid' END AS CodeFormat
FROM (VALUES ('TNT-001'), ('tnt-001'), ('TNT-01'), ('TN1-001'), ('TNT-0011')) AS v (ProductCode)
ORDER BY ProductCode;
Expected result: TN1-001 Invalid, TNT-001 Valid, tnt-001 Valid, TNT-01 Invalid, TNT-0011 Invalid. The lower-case code passes because [A-Z] follows the case-insensitive collation. To insist on capitals, add COLLATE Latin1_General_100_CS_AS after ProductCode — or use REGEXP_LIKE(ProductCode, '^[A-Z]{3}-[0-9]{3}$'), which is case-sensitive by default.
Run the same LIKE against Inventory.Product: all 30 codes are valid.
Worked example 4 — The first number in a product name (PATINDEX)
SELECT ProductCode,
ProductName,
p.FirstDigitPos,
LEFT(SUBSTRING(ProductName, p.FirstDigitPos, 20),
PATINDEX('%[^0-9]%', SUBSTRING(ProductName, p.FirstDigitPos, 20) + 'x') - 1) AS LeadingNumber
FROM Inventory.Product
CROSS APPLY (VALUES (PATINDEX('%[0-9]%', ProductName))) AS p (FirstDigitPos)
WHERE p.FirstDigitPos > 0
ORDER BY ProductCode;
Expected result: 9 rows. For example: BPK-001 Daypack 25L → position 9, 25; COK-003 Gas Canister 230g → 14, 230; TNT-002 Summit 4-Person Tent → 8, 4.
How it works: from the first digit, take the following characters and find the first non-digit with PATINDEX('%[^0-9]%', …). Appending 'x' guarantees a non-digit exists even when the number ends the string. REGEXP_SUBSTR(ProductName, '[0-9]+') does the same in one call on SQL Server 2025.
Worked example 5 — Validating staging emails: LIKE vs REGEXP_LIKE
SELECT ImportRowID,
Email,
CASE WHEN TRIM(Email) LIKE '%_@_%._%' THEN 'Pass' ELSE 'Fail' END AS LikeCheck,
CASE WHEN REGEXP_LIKE(TRIM(Email), '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$', 'i')
THEN 'Pass' ELSE 'Fail' END AS RegexCheck
FROM Staging.CustomerImport
ORDER BY ImportRowID;
Expected result: 15 rows. Row 7 (sam.jones@) and row 15 (tariq.aziz@example) fail both checks; row 12 (NULL) shows Fail in both because a NULL comparison is not TRUE. Every other row passes. Try 'a@b..c' in a VALUES row: LIKE passes it, the regular expression does not.
Worked example 6 — Tags into rows, with position (STRING_SPLIT + ordinal)
SELECT p.ProductCode, t.ordinal, t.value AS Tag
FROM Inventory.Product AS p
CROSS APPLY STRING_SPLIT(p.Tags, N',', 1) AS t
WHERE p.ProductCode IN ('TNT-001', 'NAV-004')
ORDER BY p.ProductCode, t.ordinal;
Expected result: 6 rows — NAV-004: 1 satellite, 2 electronics, 3 safety; TNT-001: 1 tent, 2 2-person, 3 lightweight.
Worked example 7 — A list per group (STRING_AGG)
SELECT p.CategoryID,
COUNT(*) AS Products,
STRING_AGG(p.ProductCode, N', ') WITHIN GROUP (ORDER BY p.ProductCode) AS ProductCodes
FROM Inventory.Product AS p
GROUP BY p.CategoryID
ORDER BY p.CategoryID;
Expected result: 12 rows (categories that have products). Category 2: TNT-001, TNT-002, TNT-003, TNT-004; category 14: JKT-001, JKT-002.
E. Guided practical lab — Standardise the customer import
Produce one cleaned row per import row with: CleanFullName, FirstName, LastName, CleanEmail, EmailStatus, CleanPhone (international format) and CleanCountry, then summarise what needs attention.
Rules agreed with the data team
| Column | Rule |
|---|---|
| Name | Trim, collapse multiple spaces; blank or NULL → NULL. First name = text before the first space; last name = the rest. Each part: first letter upper case, the rest lower case |
| Trim and lower-case. Valid if it matches the regular expression; Invalid if not; Missing if NULL | |
| Phone | Remove spaces. Numbers starting 07 are UK mobiles: replace the leading 0 with +44. Others are kept as given (they already start with +) |
| Country | UK or any capitalisation of United Kingdom → United Kingdom; otherwise the trimmed value |
Step 1 — Clean the name in one place
- Do: Use
CROSS APPLY (VALUES …)to compute the cleaned name once. - Run:
USE RetailLab; GO SELECT i.ImportRowID, i.FullName, n.CleanFullName FROM Staging.CustomerImport AS i CROSS APPLY (VALUES (NULLIF(REPLACE(REPLACE(REPLACE(TRIM(i.FullName), N' ', N'<>'), N'><', N''), N'<>', N' '), N''))) AS n (CleanFullName) ORDER BY i.ImportRowID; - Observe: row 2
' ben carter '→ben carter; row 5'Daisy Holt'→Daisy Holt; rows 8 (NULL) and 9 ('') → NULL. - Why it matters:
NULLIF(…, N'')turns a blank name into NULL, so “missing” has one representation. - Confirm: exactly 2 NULL clean names.
Step 2 — Split and capitalise
- Do: Add a second
APPLYthat finds the first space, then buildFirstNameandLastName. - Run:
SELECT i.ImportRowID, n.CleanFullName, s.FirstName, s.LastName FROM Staging.CustomerImport AS i CROSS APPLY (VALUES (NULLIF(REPLACE(REPLACE(REPLACE(TRIM(i.FullName), N' ', N'<>'), N'><', N''), N'<>', N' '), N''))) AS n (CleanFullName) CROSS APPLY (VALUES (CHARINDEX(N' ', n.CleanFullName))) AS sp (SpacePos) CROSS APPLY (VALUES ( UPPER(LEFT(LEFT(n.CleanFullName, NULLIF(sp.SpacePos, 0) - 1), 1)) + LOWER(SUBSTRING(LEFT(n.CleanFullName, NULLIF(sp.SpacePos, 0) - 1), 2, 100)), UPPER(LEFT(SUBSTRING(n.CleanFullName, NULLIF(sp.SpacePos, 0) + 1, 100), 1)) + LOWER(SUBSTRING(SUBSTRING(n.CleanFullName, NULLIF(sp.SpacePos, 0) + 1, 100), 2, 100)) )) AS s (FirstName, LastName) ORDER BY i.ImportRowID; - Observe: row 2 →
Ben,Carter; row 10 →Ines,Moreau; rows 8 and 9 → NULL, NULL. - Why it matters:
NULLIF(sp.SpacePos, 0)protectsLEFTfrom a negative length if a name had no space. Simple capitalisation is wrong for names like McDonald or van der Berg — real systems keep the customer’s own spelling where possible. - Confirm: no row raises Invalid length parameter.
Step 3 — Email, phone and country
- Do: Add the remaining cleaned columns.
- Run: add to the
SELECTlist of step 2 (and keep itsAPPLYs):
and add these, e.CleanEmail , CASE WHEN e.CleanEmail IS NULL THEN 'Missing' WHEN REGEXP_LIKE(e.CleanEmail, '^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$') THEN 'Valid' ELSE 'Invalid' END AS EmailStatus , CASE WHEN ph.Compact LIKE '07%' THEN N'+44' + SUBSTRING(ph.Compact, 2, 30) ELSE ph.Compact END AS CleanPhone , CASE WHEN UPPER(TRIM(i.Country)) IN (N'UK', N'UNITED KINGDOM') THEN N'United Kingdom' ELSE TRIM(i.Country) END AS CleanCountryAPPLYs beforeORDER BY:CROSS APPLY (VALUES (LOWER(TRIM(i.Email)))) AS e (CleanEmail) CROSS APPLY (VALUES (REPLACE(i.Phone, ' ', ''))) AS ph (Compact) - Observe: row 3 phone
07700 900301→+447700900301; row 13 countryunited kingdom→United Kingdom; row 2UK→United Kingdom; row 15 emailtariq.aziz@example→ Invalid. - Why it matters: Each rule is visible and testable in one place. Because the email has been lower-cased, the regular expression needs no
'i'flag. - Confirm: EmailStatus counts: 12 Valid, 2 Invalid, 1 Missing.
Step 4 — Summarise the issues
- Do: Wrap the step-3 query in a CTE
Cleaned(remove itsORDER BY) and summarise. - Run:
SELECT COUNT(*) AS ImportRows, SUM(CASE WHEN CleanFullName IS NULL THEN 1 ELSE 0 END) AS MissingName, SUM(CASE WHEN EmailStatus = 'Invalid' THEN 1 ELSE 0 END) AS InvalidEmail, SUM(CASE WHEN EmailStatus = 'Missing' THEN 1 ELSE 0 END) AS MissingEmail, SUM(CASE WHEN CleanPhone IS NULL THEN 1 ELSE 0 END) AS MissingPhone, COUNT(DISTINCT CleanEmail) AS DistinctEmails FROM Cleaned; - Observe:
15,2,2,1,4,11. - Why it matters: After cleaning, row 5 (
' Daisy.Holt@Example.com') is recognisably the same person as rows 3 and 4. 11 distinct emails among 14 non-NULL values means 3 duplicate rows remain — Day 15 removes them during the load. - Confirm: the six numbers match.
F. Independent practice
Task 1 — Email domains, tidied
Rewrite worked example 2 so that the domain expression is written once, using CROSS APPLY (VALUES …). Same output.
Success criteria: 11 rows, example.com 22 first.
Task 2 — Numbers in product names
Return products whose name contains at least one digit: ProductCode, ProductName and LeadingNumber as an int. Sort by LeadingNumber descending, then code.
Success criteria: 9 rows: LGT-001 Headtorch 400 Lumen (400) first, then COK-003 (230), BPK-002 (65), BPK-001 (25), BPK-003 (12), TNT-002 (4), BSL-003 and SLP-001 (3), and TNT-001 (2).
Hint
Reuse worked example 4 and wrap the extracted text in TRY_CAST(… AS int).
Task 3 — What did each customer buy in December? (STRING_AGG)
For December 2025 (non-cancelled), return one row per customer with Products (number of distinct products) and ProductCodes — a comma-separated list of distinct product codes in alphabetical order.
Success criteria: 20 rows. Customer 1: 10 products, BSL-001, COK-003, FTW-001, JKT-003, JKT-005, LGT-001, NAV-001, SLP-003, TNT-001, TNT-003. Customer 13: BPK-002, NAV-002.
Hint
Produce distinct (customer, product code) pairs in a CTE first; STRING_AGG has no DISTINCT option.
Task 4 — Products by first tag (ordinal)
Using STRING_SPLIT(…, ',', 1), count products by their first tag. Return FirstTag and Products, most common first, then alphabetically.
Success criteria: 19 rows; jacket 4, tent 4, backpack 3, then base layer, boots and sleeping bag with 2 each, then 13 tags with 1.
Stretch challenge — Masked contact details
For a customer-services screen, show each customer’s email masked as first character + *** + last character of the local part + @domain, e.g. a***n@example.com, and the phone with all but the last four digits replaced by * (spaces removed). Return CustomerID, MaskedEmail, MaskedPhone for customers 1–5.
Success criteria: customer 1: a***n@example.com, *********0101; customer 3: c***s@example.net, NULL phone; customer 5: NULL email, *********0105.
Hint
STUFF(local, 2, LEN(local) - 2, '***') masks the middle of the local part. For the phone, REPLICATE('*', LEN(p) - 4) + RIGHT(p, 4) on the compacted number.
G. Solutions and explanations
Show solution 1
SELECT d.Domain, COUNT(*) AS Customers
FROM Sales.Customer AS c
CROSS APPLY (VALUES (COALESCE(SUBSTRING(c.Email, NULLIF(CHARINDEX(N'@', c.Email), 0) + 1, LEN(c.Email)),
N'(none)'))) AS d (Domain)
GROUP BY d.Domain
ORDER BY Customers DESC, d.Domain;
Reasoning. The APPLY column can be grouped and sorted by name. Logic written once cannot drift between SELECT and GROUP BY.
Show solution 2
SELECT ProductCode,
ProductName,
TRY_CAST(LEFT(SUBSTRING(ProductName, p.Pos, 20),
PATINDEX('%[^0-9]%', SUBSTRING(ProductName, p.Pos, 20) + 'x') - 1) AS int) AS LeadingNumber
FROM Inventory.Product
CROSS APPLY (VALUES (PATINDEX('%[0-9]%', ProductName))) AS p (Pos)
WHERE p.Pos > 0
ORDER BY LeadingNumber DESC, ProductCode;
Result: LGT-001 400; COK-003 230; BPK-002 65; BPK-001 25; BPK-003 12; TNT-002 4; BSL-003 3; SLP-001 3; TNT-001 2.
Reasoning. Converting to int makes the sort numeric — as text, '4' would sort above '230' and '400'.
Show solution 3
WITH DecProducts AS
(
SELECT DISTINCT o.CustomerID, p.ProductCode
FROM Sales.SalesOrder AS o
JOIN Sales.SalesOrderLine AS l ON l.OrderID = o.OrderID
JOIN Inventory.Product AS p ON p.ProductID = l.ProductID
WHERE o.OrderStatus <> 'Cancelled'
AND o.OrderDate >= '2025-12-01' AND o.OrderDate < '2026-01-01'
)
SELECT CustomerID,
COUNT(*) AS Products,
STRING_AGG(ProductCode, ', ') WITHIN GROUP (ORDER BY ProductCode) AS ProductCodes
FROM DecProducts
GROUP BY CustomerID
ORDER BY CustomerID;
Result: 20 rows, e.g. customer 2: BPK-001, BPK-002, BSL-001, FTW-003, LGT-001, TNT-003; customer 35: BPK-003, COK-002, FTW-002, SLP-001.
Reasoning. In this data no customer bought the same product twice in December, so the DISTINCT changes nothing — but without it, a repeat purchase would appear twice in the list. Always de-duplicate on purpose.
Show solution 4
SELECT t.value AS FirstTag,
COUNT(*) AS Products
FROM Inventory.Product AS p
CROSS APPLY STRING_SPLIT(p.Tags, N',', 1) AS t
WHERE t.ordinal = 1
GROUP BY t.value
ORDER BY Products DESC, FirstTag;
Reasoning. Without the ordinal, “first” is undefined: STRING_SPLIT’s output order is not guaranteed. With it, the filter is exact.
Show the stretch solution
SELECT c.CustomerID,
CASE WHEN e.AtPos > 0
THEN STUFF(LEFT(c.Email, e.AtPos - 1), 2, e.AtPos - 3, N'***') + SUBSTRING(c.Email, e.AtPos, LEN(c.Email))
END AS MaskedEmail,
CASE WHEN ph.Compact IS NOT NULL
THEN REPLICATE('*', LEN(ph.Compact) - 4) + RIGHT(ph.Compact, 4)
END AS MaskedPhone
FROM Sales.Customer AS c
CROSS APPLY (VALUES (CHARINDEX(N'@', c.Email))) AS e (AtPos)
CROSS APPLY (VALUES (REPLACE(c.Phone, ' ', ''))) AS ph (Compact)
WHERE c.CustomerID BETWEEN 1 AND 5
ORDER BY c.CustomerID;
| CustomerID | MaskedEmail | MaskedPhone |
|---|---|---|
| 1 | a***n@example.com | *********0101 |
| 2 | b***r@example.com | *********0102 |
| 3 | c***s@example.net | NULL |
| 4 | d***n@example.com | *********0104 |
| 5 | NULL | *********0105 |
Reasoning. For a NULL email, CHARINDEX returns NULL, e.AtPos > 0 is UNKNOWN, and the CASE returns NULL. STUFF(local, 2, len - 2, '***') keeps the first and last characters of the part before @. Each compacted phone number (for example +442079460101) is 13 characters long, so 9 asterisks precede the last four digits. Masking in a query is a display technique only; SQL Server’s Dynamic Data Masking (Day 26) applies masks at the database level.
H. Knowledge check
-
Understanding. What do
LEN(N'Søren ')andDATALENGTH(N'Søren ')return? -
Debugging. On a supplier table where some email values have no
@,SELECT LEFT(Email, CHARINDEX('@', Email) - 1) FROM …fails with Invalid length parameter. Why, and how do you fix it? -
Query interpretation. Which of these match
LIKE 'J[A-K]%-00[^1]'inRetailLab:JKT-002,JKT-001,jkt-005,JLT-002? -
Understanding. Why should you pass
1as the third argument ofSTRING_SPLITwhen you need “the first tag”? -
Debugging.
STRING_AGG(ProductCode, ', ')returns codes in a different order each time and repeats some codes. Fix both problems.
Show the answers
-
LEN= 5 (trailing spaces ignored);DATALENGTH= 16 (8 characters × 2 bytes). -
For a value without
@,CHARINDEXreturns 0, so the length becomes −1 andLEFTrejects it. UseLEFT(Email, NULLIF(CHARINDEX('@', Email), 0) - 1)— the length becomes NULL and so does the result — or aCASE. NULL emails simply return NULL. -
JKT-002andjkt-005(case-insensitive collation).JKT-001fails[^1];JLT-002fails[A-K]because L is outside the range. -
Without the ordinal argument there is no position column and the row order of
STRING_SPLITis not guaranteed, so “first” cannot be identified reliably. -
Add
WITHIN GROUP (ORDER BY ProductCode)for a deterministic order, and de-duplicate the input in a derived table or CTE (SELECT DISTINCT …) before aggregating.
I. Troubleshooting and cleanup
| Error or symptom | Cause | Fix |
|---|---|---|
| Invalid length parameter passed to the LEFT or SUBSTRING function. | A computed length is negative (search text not found) | NULLIF(CHARINDEX(…), 0) or a CASE guard |
| ‘REGEXP_LIKE’ is not a recognized built-in function name. | Compatibility level below 170, or an older SQL Server | Check sys.databases; on older systems use LIKE/PATINDEX |
| An expression of non-boolean type specified in a context where a condition is expected | REGEXP_LIKE used as a value in SELECT |
Use it inside CASE WHEN … THEN … END or WHERE |
| Procedure expects parameter ‘separator’ of type ‘nchar(1)/nvarchar(1)’ | Multi-character separator for STRING_SPLIT |
REPLACE the separator with a single character first |
| STRING_AGG aggregation result exceeded the limit of 8000 bytes. | Long list built from varchar(n) inputs |
STRING_AGG(CAST(x AS nvarchar(max)), …) |
Accented characters become ? |
varchar literal or column for Unicode text |
Use nvarchar and N'…' |
| Case-sensitive comparison needed in a CI database | Collation | COLLATE Latin1_General_100_CS_AS on the expression |
LIKE '%100%%' matches too much |
% is a wildcard |
'%100[%]%' or ESCAPE |
Cleanup. Day 13 is read-only; nothing to clean up. Keep RetailLab (and the Day 12 objects) for Day 14.
J. Completion checklist, recap and references
Completion checklist
- Worked example 1 returned 3, 6, 3, 6, equal, different.
- Worked example 5 failed rows 7, 12 and 15 with both checks.
- Your lab summary shows 15, 2, 2, 1, 4, 11.
- Tasks 1–4 match the success criteria (11, 9, 20 and 19 rows).
- You used
STRING_SPLITwith the ordinal argument when position mattered.
Recap
LENcounts characters without trailing spaces;DATALENGTHcounts bytes. Usenvarchar/N'…'for Unicode.- Collation controls case and accent sensitivity for
=andLIKE; override withCOLLATE. CHARINDEX/PATINDEXreturn 0 when not found — guard before using the position inLEFT/SUBSTRING.TRIM,REPLACE,TRANSLATE,STUFFclean text; collapse repeated spaces with the marker trick.LIKEhandles simple shapes;REGEXP_LIKE(SQL Server 2025, compatibility 170) handles real patterns and is case-sensitive by default.STRING_SPLITturns lists into rows (use the ordinal for position);STRING_AGG … WITHIN GROUPturns rows into ordered lists (de-duplicate first).
Next: Day 14 — Week 2 review: a complex report combining joins, set operations, APPLY, PIVOT, dates and strings.
References (Microsoft Learn)
- String functions (Transact-SQL)
- LIKE (Transact-SQL)
- PATINDEX (Transact-SQL)
- REGEXP_LIKE (Transact-SQL)
- Regular expressions functions (Transact-SQL)
- STRING_SPLIT (Transact-SQL)
- STRING_AGG (Transact-SQL)
- TRANSLATE (Transact-SQL)
- Collation and Unicode support
- Regex support in SQL Server 2025 — general availability (Azure SQL Dev Corner)