This lesson continues the shop example from lessons P07–P11. The five customers now have email addresses. A new signups table contains newsletter registrations submitted through a web form.
The examples run in DuckDB. Where PostgreSQL behaves the same way, the explanation names both databases. Where it differs, a note explains what to change.
The sample data
The shop stores customer email addresses in lowercase, without surrounding spaces.
The newsletter form contains the values people entered.
Throughout the lesson, quotes make otherwise hard-to-see differences visible. We show surrounding spaces, repeated spaces, tabs and empty strings inside quotes. The quotes are display markers, not part of the stored value. \t represents an actual tab.
A few details matter:
- Asha, Ben, Carla, Esha and Dev are existing customers. Reuben and Sam are not.
- Asha’s missing middle name is stored as an empty string,
''. Several other missing middle names are stored asNULL. - Carla’s surname looks like “Muñoz,” but its
ñis stored as two Unicode code points:nfollowed by a combining tilde. §07 explains why this matters. - Dev entered
dev.example.com, with a full stop where the@should be.
Text values
Names, email addresses, phone numbers, addresses and product codes are commonly stored as text.
DuckDB uses the type VARCHAR; TEXT and STRING are aliases for the same type. PostgreSQL also provides text and varchar.
A few basic rules for writing text in SQL queries:
- Write text values inside single quotes.
- Double quotes identify table or column names.
- To include an apostrophe inside a text value, write it twice.
As shown in the below example:
SELECT 'O''Brien' AS with_apostrophe, '' AS empty_string, length('') AS empty_length, '' IS NULL AS empty_is_null;An empty string contains zero characters. It is still a value. NULL represents a missing or unknown value, as discussed in lesson P02.
Matching sign-ups to customers
Marketing wants to identify newsletter sign-ups from existing customers so those people do not receive an offer intended for new customers.
Five sign-ups came from existing customers. The analyst tries to find them by joining the two tables on email.
How many rows does this query return?
SELECT s.signup_id, s.email, c.customer_idFROM signups AS sJOIN customers AS c ON c.email = s.emailORDER BY s.signup_id;Checking the lengths helps reveal some of the differences:
SELECT signup_id, email, length(email) AS charsFROM signupsORDER BY signup_id;Each missed customer has a different problem:
- Asha:
asha@example.comhas 16 characters. Her submitted value has 18 because it includes a space at each end. Its capitalisation also differs. - Carla: Her address is in upper case.
- Esha: Her address ends with a tab, adding one character.
- Dev: His address contains a full stop instead of
@.
Changing case can fix Carla’s address. Trimming can fix Asha’s spaces and, with the right settings, Esha’s. Dev’s address needs correction rather than formatting.
Text comparisons depend on the data type and collation, the rules used to compare and sort text. PostgreSQL’s usual text comparisons with a deterministic collation also distinguish these values. MySQL and SQL Server commonly use case-insensitive collations, which can make Carla match without cleaning. SQL Server also generally ignores trailing ordinary spaces in equality comparisons.
Upper and lower case
lower(): converts letters to lower case.upper(): converts them to upper case.
These functions change capitalisation without removing surrounding spaces or fixing spelling. The shop stores customer emails in lower case, so the analyst applies lower() to the submitted addresses:
SELECT s.signup_id, c.customer_idFROM signups AS sJOIN customers AS c ON c.email = lower(s.email)ORDER BY s.signup_id;Carla now matches. Asha’s address is also converted to lower case, but the spaces remain, so it still does not match.
Only the signup side needs conversion here because the customer table already follows the shop’s rule. If either side may contain different capitalisation, apply the same rule to both:
lower(first_value) = lower(second_value)Another approach is to use a case-insensitive collation. This changes how the comparison works without changing the displayed values:
SELECT signup_id, emailFROM signupsWHERE email = 'carla@example.com' COLLATE NOCASE;DuckDB and SQLite provide a collation named NOCASE. PostgreSQL does not have a built-in collation with that exact name; use lower() or an appropriate configured collation.
lower() to a column can prevent an ordinary index on the original column from being used efficiently. For frequently searched data, consider storing a consistently cleaned value or using an expression index where supported.Trimming the ends of text
trim(): removes specified characters from both ends of a string.ltrim(): works on the left end.rtrim(): works on the right.
These functions leave the middle of the value unchanged.
How many rows does this query return?
SELECT s.signup_id, c.customer_idFROM signups AS sJOIN customers AS c ON c.email = lower(trim(s.email))ORDER BY s.signup_id;To remove a specific set of characters, provide them as the second argument:
SELECT s.signup_id, c.customer_idFROM signups AS sJOIN customers AS c ON c.email = lower(trim(s.email, E' \t\r\n'))ORDER BY s.signup_id;trim() removes any of those characters from either end. Four customers now match. Dev’s address still contains the wrong character, which trimming cannot correct.
Pattern matching with LIKE
Use = when you want to compare a complete value. Use LIKE when you want to match a pattern. In a LIKE pattern, % represents any sequence of characters, including an empty sequence.
Now, marketing wants sign-ups whose addresses end with @example.com:
SELECT signup_id, emailFROM signupsWHERE email LIKE '%@example.com'ORDER BY signup_id;The query finds three addresses. The pattern also requires the value to end immediately after .com. Asha’s trailing space and Esha’s tab prevent their addresses from matching.
ILIKE performs case-insensitive pattern matching in both DuckDB and PostgreSQL:
SELECT signup_id, emailFROM signupsWHERE email ILIKE '%@example.com'ORDER BY signup_id;Carla now matches. Asha and Esha still have extra characters after the domain. Cleaning first solves both problems:
lower(trim(email, E' \t\r\n')) LIKE '%@example.com'That condition finds all six addresses with the intended domain.
Using = would change the meaning. The condition email = '%@example.com' searches for that exact text, including the percent sign. % acts as a wildcard only in a pattern operation such as LIKE.
An older import also used underscores in some addresses. The analyst tries to find those addresses with the following pattern.
Which sign-ups does this query return?
SELECT signup_id, emailFROM signupsWHERE email LIKE '%_%'ORDER BY signup_id;To search for an actual underscore, escape it:
SELECT signup_id, emailFROM signupsWHERE email LIKE '%\_%' ESCAPE '\'ORDER BY signup_id;ESCAPE '\' identifies the backslash as the escape character. The sequence \_ then means a literal underscore. The same approach works for a literal percent sign.
For a fixed piece of text, DuckDB’s contains(email, '_') avoids wildcard syntax altogether. In PostgreSQL, an equivalent check is strpos(email, '_') > 0.
'ben%' can often use a suitable ordinary index. A pattern beginning with % generally cannot use the same prefix search. Specialised indexes can support other patterns; PostgreSQL’s trigram indexes are one example.Taking text apart
Marketing wants to count sign-ups by email domain.
In DuckDB and PostgreSQL, split_part(text, delimiter, part_number) splits text at the delimiter and returns the requested piece. Part numbers start at 1:
SELECT signup_id, email, split_part(email, '@', 1) AS local_part, split_part(email, '@', 2) AS domainFROM signupsWHERE signup_id IN (102, 107)ORDER BY signup_id;You can also extract text by position:
| Function | What it does |
|---|---|
position(part IN text) | Finds the starting position of the first occurrence |
strpos(text, part) | Another way to find that position |
substring(text, start, length) | Returns the requested number of characters |
substring(text, start) | Returns everything from that position onward |
left(text, count) | Takes characters from the beginning |
right(text, count) | Takes characters from the end |
The analyst uses position() to find @, then extracts everything after it.
What does domain show for signup 106, whose address has no @?
SELECT signup_id, email, substring(email, position('@' IN email) + 1) AS domainFROM signupsWHERE signup_id IN (102, 106)ORDER BY signup_id;split_part() handles the missing piece differently:
SELECT signup_id, position('@' IN email) AS at_position, split_part(email, '@', 2) AS split_domainFROM signupsWHERE signup_id IN (102, 106)ORDER BY signup_id;Because there is no second piece, split_part() returns an empty string. Neither result automatically becomes NULL. A report could therefore contain an incorrect domain or an empty group label.
Use NULLIF() to make an empty result explicit:
SELECT signup_id, NULLIF(split_part(email, '@', 2), '') AS domainFROM signupsWHERE signup_id IN (102, 106)ORDER BY signup_id;NULLIF(first_value, second_value) returns NULL when the values are equal. Otherwise, it returns the first value.
0 does not raise an error. For a substring with a specified length, that position is before the first character and consumes one position from the requested range:SELECT substring('ben@example.com', 0, 4) AS from_0, substring('ben@example.com', 1, 4) AS from_1;Starting at 0 with a length of 4 reaches positions 0–3, so only the first three actual characters are returned.
Length, characters and bytes
In DuckDB, length() counts Unicode code points, while strlen() counts storage bytes. These counts can differ because UTF-8 characters may use more than one byte:
SELECT signup_id, middle_name, length(middle_name) AS chars, strlen(middle_name) AS bytesFROM signupsWHERE signup_id = 103;“Inés” has four characters but five bytes because é uses two bytes.
A visible character can also contain multiple code points. Carla’s ñ is stored as n plus a combining tilde. Her surname therefore has five visible characters, six code points and seven bytes. DuckDB’s length_grapheme() counts the visible character groups.
This difference also affects exact comparisons. Searching for 'Muñoz' with a single-code-point ñ finds no row. NFC normalisation converts equivalent Unicode representations into a consistent form:
SELECT signup_id, last_nameFROM signupsWHERE nfc_normalize(last_name) = 'Muñoz';Normalise both sides if either may contain different representations. NFC preserves accents; it does not make Muñoz equal to Munoz.
PostgreSQL uses octet_length() for bytes and normalize(text, NFC) for normalisation with UTF-8 encoding. In MySQL, LENGTH() counts bytes and CHAR_LENGTH() counts characters.
Putting text together
The welcome email needs a full name built from the first, middle and last name columns.
In DuckDB and PostgreSQL, || joins text values. concat() also combines values, but the two handle NULL differently:
SELECT signup_id, first_name || ' ' || middle_name || ' ' || last_name AS with_pipes, concat(first_name, ' ', middle_name, ' ', last_name) AS with_concatFROM signupsWHERE signup_id IN (102, 103)ORDER BY signup_id;Ben’s middle name is NULL.
With ||, that missing value makes the entire result NULL.
concat() skips the NULL, but it still includes both separately supplied spaces. Ben’s full name therefore contains two spaces between the first and last names.
concat_ws(separator, ...) means “concatenate with separator.” It places the separator between the values and skips NULL arguments.
What does full_name show for signup 101?
SELECT signup_id, concat_ws(' ', first_name, middle_name, last_name) AS full_nameFROM signupsWHERE signup_id IN (101, 102)ORDER BY signup_id;Ben’s NULL middle name is skipped, so his result has just one separator. When an empty string means “no value,” convert it to NULL before combining the parts:
SELECT signup_id, concat_ws(' ', first_name, NULLIF(middle_name, ''), last_name) AS full_nameFROM signupsORDER BY signup_id;The names keep the capitalisation people entered. Sorting or changing that capitalisation is a separate operation. To combine values from multiple rows into one string, use the aggregate string_agg():
SELECT string_agg(first_name, ', ' ORDER BY signup_id) AS first_namesFROM signups;lpad('7', 3, '0') returns 007, but lpad('101', 2, '0') returns 10. When the original value is longer than the target length, DuckDB and PostgreSQL truncate it. Check lengths before using padding to create identifiers.Replacing text and regular expressions
replace(text, old_text, new_text) replaces every occurrence of a fixed piece of text.
In DuckDB and PostgreSQL, the replacement is literal and case-sensitive. Replacing 'a' does not also replace 'A' or 'á'.
Below is an example with replace:
SELECT signup_id, phone, replace(phone, ' ', '') AS no_spacesFROM signupsWHERE signup_id = 101;When you need to describe a set of characters or a more complicated pattern, use a regular expression. Below is a list of patterns and their meanings:
| Pattern | Meaning |
|---|---|
[0-9] | One digit from 0 to 9 |
[^0-9] | One character that is not a digit from 0 to 9 |
+ | One or more repetitions of the preceding item |
. | Any character except a newline under the default settings |
^ | Start of the value |
$ | End of the value |
(…) | A group that can be matched and captured |
DuckDB’s regexp_matches(text, pattern) returns true when it finds a match. The following query cleans the email addresses, then finds those that fail a simple format check:
SELECT signup_id, emailFROM signupsWHERE NOT regexp_matches(lower(trim(email, E' \t\r\n')), '^[a-z0-9._]+@[a-z0-9-]+(\.[a-z0-9-]+)+$')ORDER BY signup_id;The pattern requires text before @, a domain after it, and at least one full stop within the domain. The ^ and $ require the pattern to cover the whole value.
regexp_matches() returns matching text in arrays, rather than a true/false result. For this test, use regexp_like(text, pattern) in PostgreSQL 15 onward, or the operator text ~ pattern.DuckDB also provides regexp_extract(text, pattern, group_number). It returns the text captured by a parenthesised group. If there is no match, it returns an empty string, so handle missing results just as you did with split_part().
The system now asks for digits only, including removal of the leading +. The analyst uses [^0-9] to identify characters to remove.
What does digits show?
SELECT signup_id, phone, regexp_replace(phone, '[^0-9]', '') AS digitsFROM signupsWHERE signup_id = 101;Add the option 'g', meaning global, to replace every match:
SELECT signup_id, phone, regexp_replace(phone, '[^0-9]', '', 'g') AS digitsFROM signupsORDER BY signup_id;The same approach can clean spaces inside text:
regexp_replace(name, ' {2,}', ' ', 'g')The pattern means “two or more consecutive ordinary spaces.” Each run becomes one space. Unlike trim(), this can change the middle of a value. Replacement defaults differ across databases. MySQL and Oracle replace all matches by default. SQL Server’s built-in regular-expression functions, available from SQL Server 2025, also use all occurrences as the default for replacement.
Sorting text
The events team wants a printed list of first names in alphabetical order. Two people entered their names in lower case: ben and reuben.
Where does ben appear in this result?
SELECT first_nameFROM signupsORDER BY first_name;To ignore capitalisation when sorting these names, sort by their lower-case forms:
SELECT first_nameFROM signupsORDER BY lower(first_name), first_name;The first sort expression ignores capitalisation. The second uses the original name to resolve ties between values that differ only in case.
ORDER BY first_name COLLATE NOCASE also produces this order in DuckDB.
ORDER BY lower(first_name), first_name expression. However, its default ORDER BY first_name result depends on the active collation. An English-language collation may already place ben after Asha. MySQL and SQL Server also use collation rules. So assuming every database puts capitals first would be wrong.Joining on messy text keys
The earlier sections handled one problem at a time. We can now bring the cleaning steps together. Define the cleaned email in one place, using a CTE as in lesson P10. Then join on that value.
A LEFT JOIN keeps every sign-up, including those with no matching customer:
WITH clean_signups AS ( SELECT signup_id, lower(trim(email, E' \t\r\n')) AS clean_email FROM signups)SELECT s.signup_id, s.clean_email, c.customer_idFROM clean_signups AS sLEFT JOIN customers AS c ON c.email = s.clean_emailORDER BY s.signup_id;Four sign-ups match existing customers. Reuben and Sam correctly remain unmatched. Dev also remains unmatched because his submitted address is malformed. That value needs review.
The CTE keeps the cleaning rule together and gives the result a clear name. It does not, by itself, guarantee that the database physically calculates the expression only once.
A colleague suggests a looser join: accept a sign-up whenever its email contains a customer’s email anywhere inside it. This would allow extra characters at either end without explicitly removing them.
Which sign-ups does this query match to a customer?
SELECT s.signup_id, c.customer_idFROM signups AS sJOIN customers AS c ON lower(s.email) LIKE '%' || c.email || '%'ORDER BY s.signup_id;Keep the join based on equality after applying a defined cleaning rule. Leave values that still fail to match for review rather than accepting increasingly loose matches.
Define the cleaning rule once and join on the cleaned value with =. Check key uniqueness and review unmatched values instead of hiding them with a loose “contains” match.
How to spot string problems
Text problems often return plausible results instead of errors. A query may miss a customer, split one group into two, or join two unrelated records.
Use the following checks to investigate unexpected results:
| What you notice | What to check | Section |
|---|---|---|
| A join finds fewer matches than expected | Capitalisation, surrounding spaces, tabs or other characters differ | §02 |
| The same comparison returns different matches in another database | The collation, data type or trailing-space rules differ | §02, §13 |
Values still do not match after trim() | A tab, line break or another character remains | §04 |
| The length is greater than the text appears to have | Hidden characters or combining Unicode marks are present | §02, §07 |
| A pattern misses upper-case values | The comparison is case-sensitive | §05 |
Searching for _ or % matches too many rows | Those characters are being interpreted as wildcards | §05 |
| An extracted value contains the whole input or empty text | The delimiter was missing | §06 |
| Two identical-looking names do not match | Their Unicode representations differ | §07 |
| A short-looking value exceeds another system’s limit | The limit counts bytes rather than characters | §07 |
A combined name becomes NULL or contains double spaces | The concatenation function handles NULL and empty strings differently | §08 |
| Some unwanted characters remain after replacement | Only the first regular-expression match was replaced | §09 |
| A format check accepts unexpected values | A special character was not escaped, or the pattern matches only part of the value | §09 |
| Lower-case names appear after all capitalised names | The active sort rules distinguish case that way | §10 |
| A join returns a believable count but pairs the wrong people | A substring match accepted part of another key | §11 |
Before writing the query, answer four questions:
- Where did the text come from? Check what the application enforces and what users can enter.
- Which differences should matter? Decide how to handle capitalisation, spaces, accents and punctuation.
- What happens when a piece is missing? Check whether each function returns
0, an empty string orNULL. - Where will the cleaning rule be defined? Keep it consistent, whether it runs during loading or in a shared query expression.
String functions across engines
The examples use DuckDB. Other databases provide similar tools, but their comparison rules, function names and handling of missing values can differ.
PostgreSQL supports many of the same functions used here, including lower, trim, ILIKE, split_part, strpos, concat_ws and string_agg. These differences need particular attention:
| DuckDB expression or feature | PostgreSQL alternative or distinction |
|---|---|
strlen(text) | Use octet_length(text) to count bytes |
nfc_normalize(text) | Use normalize(text, NFC) with UTF-8 server encoding |
contains(text, part) | Use strpos(text, part) > 0 |
Boolean regexp_matches(text, pattern) | Use regexp_like from PostgreSQL 15, or text ~ pattern; PostgreSQL’s regexp_matches returns matches in arrays |
regexp_extract and length_grapheme | No direct built-in functions with those names |
COLLATE NOCASE | Choose an appropriate configured collation or use a suitable expression such as lower() |
Default trim() | PostgreSQL removes ordinary spaces; DuckDB also removes some other space characters |
See PostgreSQL’s string functions, pattern matching and collation rules for details.
For the other engines, keep these differences in mind:
| Database | Main differences to remember | Details |
|---|---|---|
| MySQL | Comparison behaviour depends on collation. CONCAT returns NULL if any argument is NULL. LENGTH counts bytes; CHAR_LENGTH counts characters. | String functions |
| SQL Server | Comparison behaviour depends on collation. LEN ignores trailing spaces; DATALENGTH counts bytes. Built-in regular-expression replacement is available from SQL Server 2025 and replaces all occurrences by default. | LEN · REGEXP_REPLACE |
| Oracle | Empty character strings are treated as NULL. Concatenating nonempty text with NULL preserves that text. REGEXP_REPLACE replaces all matches by default. | NULL rules · REGEXP_REPLACE |
| SQLite | Built-in lower() normally handles ASCII letters only. Default LIKE ignores case for those letters. Declared VARCHAR(n) lengths are not enforced, and regular-expression support requires an added implementation. | Built-in functions |
Summary
Start by deciding which differences should matter when comparing text. Apply consistent rules for capitalisation, surrounding characters and equivalent Unicode representations. Check what each function returns when it cannot find the requested text.
Use = for complete-value comparisons and LIKE for patterns. Escape wildcard characters when you mean them literally. When combining values, handle NULL and empty strings deliberately. When replacing patterns, check whether the function changes one match or all matches.
For reliable reports and joins, keep the cleaning rule in one place, check that cleaned keys remain unique, and review values that still do not match. A believable row count does not guarantee correct matches.
Next, lesson P13 covers data types and CAST: how a value’s type affects its behaviour, and how to convert text into numbers and dates safely.