Practice9problems on this pattern, easy to hardStart practising →
Pattern 12SQL course / Working with Real-World Data

SQL string functions: cleaning, combining and matching text correctly

Learn why text that looks the same may not match, how to clean and combine text, and how to search for values without accidentally losing rows or creating incorrect matches.

13 min read14 sections8 predictions9 practice problems

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.

customers5 rows
customer_idcustomer_namecitycountryreferred_by_customer_idemail
1AshaBengaluruIndiaNULLasha@example.com
2BenLondonUK1ben@example.com
3CarlaDubaiUAE1carla@example.com
4DevBengaluruIndia2dev@example.com
5EshaSingaporeSingaporeNULLesha@example.com

The newsletter form contains the values people entered.

signups7 rows
signup_idemailfirst_namemiddle_namelast_namephone
101' Asha@Example.com 'Asha''Rao+91 98450 12345
102ben@example.combenNULLThomas+44 20 7946 0958
103CARLA@EXAMPLE.COMCarlaInésMuñoz+971 4 555 0101
104reuben@example.comreubenNULLMathew+91 80 5550 2020
105'esha@example.com\t'EshaNULLIyer+65 6555 0303
106dev.example.comDevNULLMehtaNULL
107sam_k@example.comSamNULLKhan+1 415 555 0123

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 as NULL.
  • Carla’s surname looks like “Muñoz,” but its ñ is stored as two Unicode code points: n followed by a combining tilde. §07 explains why this matters.
  • Dev entered dev.example.com, with a full stop where the @ should be.
01

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:

Query
SELECT
'O''Brien' AS with_apostrophe,
'' AS empty_string,
length('') AS empty_length,
'' IS NULL AS empty_is_null;
Result1 row
with_apostropheempty_stringempty_lengthempty_is_null
O'Brien''0false

An empty string contains zero characters. It is still a value. NULL represents a missing or unknown value, as discussed in lesson P02.

02

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.

Pause and predictPredict before you continue

How many rows does this query return?

The query
SELECT s.signup_id, s.email, c.customer_id
FROM signups AS s
JOIN customers AS c ON c.email = s.email
ORDER BY s.signup_id;

Checking the lengths helps reveal some of the differences:

Query
SELECT signup_id, email, length(email) AS chars
FROM signups
ORDER BY signup_id;
Result7 rows
signup_idemailchars
101' Asha@Example.com '18
102ben@example.com15
103CARLA@EXAMPLE.COM17
104reuben@example.com18
105'esha@example.com\t'17
106dev.example.com15
107sam_k@example.com17

Each missed customer has a different problem:

  • Asha: asha@example.com has 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.

Database note

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.

03

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:

Query
SELECT s.signup_id, c.customer_id
FROM signups AS s
JOIN customers AS c ON c.email = lower(s.email)
ORDER BY s.signup_id;
Result2 rows
signup_idcustomer_id
1022
1033

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:

Query
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:

Query
SELECT signup_id, email
FROM signups
WHERE email = 'carla@example.com' COLLATE NOCASE;
Result1 row
signup_idemail
103CARLA@EXAMPLE.COM

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.

Watch out
Applying 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.
04

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.

Pause and predictPredict before you continue

How many rows does this query return?

The query
SELECT s.signup_id, c.customer_id
FROM signups AS s
JOIN 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:

Query
SELECT s.signup_id, c.customer_id
FROM signups AS s
JOIN customers AS c ON c.email = lower(trim(s.email, E' \t\r\n'))
ORDER BY s.signup_id;
Result4 rows
signup_idcustomer_id
1011
1022
1033
1055

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.

05

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:

Query
SELECT signup_id, email
FROM signups
WHERE email LIKE '%@example.com'
ORDER BY signup_id;
Result3 rows
signup_idemail
102ben@example.com
104reuben@example.com
107sam_k@example.com

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:

Query
SELECT signup_id, email
FROM signups
WHERE email ILIKE '%@example.com'
ORDER BY signup_id;
Result4 rows
signup_idemail
102ben@example.com
103CARLA@EXAMPLE.COM
104reuben@example.com
107sam_k@example.com

Carla now matches. Asha and Esha still have extra characters after the domain. Cleaning first solves both problems:

Query
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.

Pause and predictPredict before you continue

Which sign-ups does this query return?

The query
SELECT signup_id, email
FROM signups
WHERE email LIKE '%_%'
ORDER BY signup_id;

To search for an actual underscore, escape it:

Query
SELECT signup_id, email
FROM signups
WHERE email LIKE '%\_%' ESCAPE '\'
ORDER BY signup_id;
Result1 row
signup_idemail
107sam_k@example.com

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.

Note
Performance note
A prefix pattern such as '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.
06

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:

Query
SELECT
signup_id,
email,
split_part(email, '@', 1) AS local_part,
split_part(email, '@', 2) AS domain
FROM signups
WHERE signup_id IN (102, 107)
ORDER BY signup_id;
Result2 rows
signup_idemaillocal_partdomain
102ben@example.combenexample.com
107sam_k@example.comsam_kexample.com

You can also extract text by position:

FunctionWhat 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.

Pause and predictPredict before you continue

What does domain show for signup 106, whose address has no @?

The query
SELECT
signup_id,
email,
substring(email, position('@' IN email) + 1) AS domain
FROM signups
WHERE signup_id IN (102, 106)
ORDER BY signup_id;

split_part() handles the missing piece differently:

Query
SELECT
signup_id,
position('@' IN email) AS at_position,
split_part(email, '@', 2) AS split_domain
FROM signups
WHERE signup_id IN (102, 106)
ORDER BY signup_id;
Result2 rows
signup_idat_positionsplit_domain
1024example.com
1060''

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:

Query
SELECT
signup_id,
NULLIF(split_part(email, '@', 2), '') AS domain
FROM signups
WHERE signup_id IN (102, 106)
ORDER BY signup_id;
Result2 rows
signup_iddomain
102example.com
106NULL

NULLIF(first_value, second_value) returns NULL when the values are equal. Otherwise, it returns the first value.

Watch out
In DuckDB and PostgreSQL, a starting position of 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:
Query
SELECT
substring('ben@example.com', 0, 4) AS from_0,
substring('ben@example.com', 1, 4) AS from_1;
Result1 row
from_0from_1
benben@

Starting at 0 with a length of 4 reaches positions 0–3, so only the first three actual characters are returned.

07

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:

Query
SELECT signup_id, middle_name,
length(middle_name) AS chars,
strlen(middle_name) AS bytes
FROM signups
WHERE signup_id = 103;
Result1 row
signup_idmiddle_namecharsbytes
103Inés45

“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:

Query
SELECT signup_id, last_name
FROM signups
WHERE nfc_normalize(last_name) = 'Muñoz';
Result1 row
signup_idlast_name
103Muñoz

Normalise both sides if either may contain different representations. NFC preserves accents; it does not make Muñoz equal to Munoz.

Database note

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.

08

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:

Query
SELECT
signup_id,
first_name || ' ' || middle_name || ' ' || last_name AS with_pipes,
concat(first_name, ' ', middle_name, ' ', last_name) AS with_concat
FROM signups
WHERE signup_id IN (102, 103)
ORDER BY signup_id;
Result2 rows
signup_idwith_pipeswith_concat
102NULL'ben  Thomas'
103Carla Inés MuñozCarla Inés Muñoz

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.

Pause and predictPredict before you continue

What does full_name show for signup 101?

The query
SELECT signup_id, concat_ws(' ', first_name, middle_name, last_name) AS full_name
FROM signups
WHERE 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:

Query
SELECT signup_id, concat_ws(' ', first_name, NULLIF(middle_name, ''), last_name) AS full_name
FROM signups
ORDER BY signup_id;
Result7 rows
signup_idfull_name
101Asha Rao
102ben Thomas
103Carla Inés Muñoz
104reuben Mathew
105Esha Iyer
106Dev Mehta
107Sam Khan

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():

Query
SELECT string_agg(first_name, ', ' ORDER BY signup_id) AS first_names
FROM signups;
Result1 row
first_names
Asha, ben, Carla, reuben, Esha, Dev, Sam
Watch out
Padding functions can also shorten text. 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.
09

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:

Query
SELECT signup_id, phone, replace(phone, ' ', '') AS no_spaces
FROM signups
WHERE signup_id = 101;
Result1 row
signup_idphoneno_spaces
101+91 98450 12345+919845012345

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:

PatternMeaning
[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:

Query
SELECT signup_id, email
FROM signups
WHERE NOT regexp_matches(lower(trim(email, E' \t\r\n')), '^[a-z0-9._]+@[a-z0-9-]+(\.[a-z0-9-]+)+$')
ORDER BY signup_id;
Result1 row
signup_idemail
106dev.example.com

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.

Note
PostgreSQL note
PostgreSQL’s 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.

Pause and predictPredict before you continue

What does digits show?

The query
SELECT signup_id, phone, regexp_replace(phone, '[^0-9]', '') AS digits
FROM signups
WHERE signup_id = 101;

Add the option 'g', meaning global, to replace every match:

Query
SELECT signup_id, phone, regexp_replace(phone, '[^0-9]', '', 'g') AS digits
FROM signups
ORDER BY signup_id;
Result7 rows
signup_idphonedigits
101+91 98450 12345919845012345
102+44 20 7946 0958442079460958
103+971 4 555 010197145550101
104+91 80 5550 2020918055502020
105+65 6555 03036565550303
106NULLNULL
107+1 415 555 012314155550123

The same approach can clean spaces inside text:

Query
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.

10

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.

Pause and predictPredict before you continue

Where does ben appear in this result?

The query
SELECT first_name
FROM signups
ORDER BY first_name;

To ignore capitalisation when sorting these names, sort by their lower-case forms:

Query
SELECT first_name
FROM signups
ORDER BY lower(first_name), first_name;
Result7 rows
first_name
Asha
ben
Carla
Dev
Esha
reuben
Sam

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.

Note
PostgreSQL note
PostgreSQL supports the same 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.
11

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:

Query
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_id
FROM clean_signups AS s
LEFT JOIN customers AS c ON c.email = s.clean_email
ORDER BY s.signup_id;
Result7 rows
signup_idclean_emailcustomer_id
101asha@example.com1
102ben@example.com2
103carla@example.com3
104reuben@example.comNULL
105esha@example.com5
106dev.example.comNULL
107sam_k@example.comNULL

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.

Pause and predictPredict before you continue

Which sign-ups does this query match to a customer?

The query
SELECT s.signup_id, c.customer_id
FROM signups AS s
JOIN 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.

Key idea

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.

12

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 noticeWhat to checkSection
A join finds fewer matches than expectedCapitalisation, surrounding spaces, tabs or other characters differ§02
The same comparison returns different matches in another databaseThe 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 haveHidden characters or combining Unicode marks are present§02, §07
A pattern misses upper-case valuesThe comparison is case-sensitive§05
Searching for _ or % matches too many rowsThose characters are being interpreted as wildcards§05
An extracted value contains the whole input or empty textThe delimiter was missing§06
Two identical-looking names do not matchTheir Unicode representations differ§07
A short-looking value exceeds another system’s limitThe limit counts bytes rather than characters§07
A combined name becomes NULL or contains double spacesThe concatenation function handles NULL and empty strings differently§08
Some unwanted characters remain after replacementOnly the first regular-expression match was replaced§09
A format check accepts unexpected valuesA special character was not escaped, or the pattern matches only part of the value§09
Lower-case names appear after all capitalised namesThe active sort rules distinguish case that way§10
A join returns a believable count but pairs the wrong peopleA substring match accepted part of another key§11

Before writing the query, answer four questions:

  1. Where did the text come from? Check what the application enforces and what users can enter.
  2. Which differences should matter? Decide how to handle capitalisation, spaces, accents and punctuation.
  3. What happens when a piece is missing? Check whether each function returns 0, an empty string or NULL.
  4. Where will the cleaning rule be defined? Keep it consistent, whether it runs during loading or in a shared query expression.
13

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 featurePostgreSQL 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_graphemeNo direct built-in functions with those names
COLLATE NOCASEChoose 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:

DatabaseMain differences to rememberDetails
MySQLComparison behaviour depends on collation. CONCAT returns NULL if any argument is NULL. LENGTH counts bytes; CHAR_LENGTH counts characters.String functions
SQL ServerComparison 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
OracleEmpty 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
SQLiteBuilt-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
14

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.

Go deeper

Ask Colearn about this pattern

Not sure why a join misses a value that looks identical, or when to use LIKE instead of =? Ask about the concept, or paste a simplified version of your query. The examples below use DuckDB, as in P12. PostgreSQL differences are noted where relevant.

Instant answers, grounded in the same verified material the diagnostic grades against.

3 of 3 free questions left
Why did my concatenation produce NULL?

One of the values you joined may be NULL.

In DuckDB and PostgreSQL, combining text with || produces NULL if any part is NULL. For example, Ben’s missing middle name makes this entire expression return NULL:

Query
first_name || ' ' || middle_name || ' ' || last_name

Use concat_ws() when you want to skip missing parts and place a separator between the remaining values:

Query
SELECT concat_ws(' ', first_name, middle_name, last_name) AS full_name
FROM signups
WHERE signup_id = 102;

This returns ben Thomas.

concat() also skips NULL, but separately supplied spaces remain. That can leave double spaces. concat_ws() handles the separators for you.

These rules vary across databases. MySQL’s CONCAT(), for example, returns NULL if any argument is NULL.

Is an empty string the same as NULL?

In DuckDB and PostgreSQL, they are different:

'' is a text value containing zero characters.

NULL represents a missing or unknown value.

This distinction matters when combining text. Asha’s middle name is '', so concat_ws() keeps its place and produces 'Asha  Rao', with two spaces. Ben’s NULL middle name is skipped.

If an empty string means “no value,” convert it to NULL:

Query
SELECT signup_id, concat_ws(' ', first_name, NULLIF(middle_name, ''), last_name) AS full_name
FROM signups
ORDER BY signup_id;

NULLIF(middle_name, '') returns NULL when the middle name is empty. Asha’s result becomes Asha Rao.

Oracle is an important exception: it treats empty character strings as NULL.

Why does WHERE name = 'A%' not match names starting with A?

With =, the percent sign is an ordinary character. This condition searches for the exact value A%:

Query
WHERE name = 'A%'

Use LIKE to interpret % as a wildcard:

Query
WHERE name LIKE 'A%'

Here, % means any number of characters, including none. The pattern can therefore match A, Asha and Arun.

If capitalisation should not matter, DuckDB and PostgreSQL support:

Query
WHERE name ILIKE 'a%'

That can match both Asha and asha.

Why does LIKE '%IND%' match more than I want?

The percent signs allow text both before and after IND. The pattern therefore looks for IND anywhere in the value.

It can match values such as IND, INDIA and WIND.

Choose the condition that describes your requirement:

Result3 rows
RequirementCondition
The complete value must be INDname = 'IND'
The value must start with INDname LIKE 'IND%'
The value may contain IND anywherename LIKE '%IND%'

Case sensitivity depends on the database and its comparison settings.

Be especially careful with “contains” conditions in joins. In P12, reuben@example.com contains ben@example.com, so a loose match incorrectly joins Reuben to Ben. For identifiers, prefer equality after applying a defined cleaning rule.

How do I clean up whitespace in typed values?

First decide which characters to remove and where they may appear.

trim() removes characters from the ends of a value. Its default behaviour does not remove every kind of whitespace.

In DuckDB and PostgreSQL, this expression removes ordinary spaces, tabs, carriage returns and line breaks from both ends:

Query
trim(email, E' \t\r\n')

For the shop’s email-matching rule, combine it with lower-case conversion:

Query
lower(trim(email, E' \t\r\n'))

Trimming does not change the middle of a value. To replace repeated ordinary spaces inside a name with one space, use:

Query
regexp_replace(name, ' {2,}', ' ', 'g')

The 'g' option replaces every matching run.

Avoid removing spaces indiscriminately. Spaces may be meaningful in names, addresses or fixed-width codes. Check the cleaned value and its length before using it.

When should I use LIKE instead of =?

Use = to compare a complete value:

Query
WHERE email = 'ben@example.com'

Use LIKE when part of the value can vary:

Query
WHERE email LIKE '%@example.com'

The second condition finds values ending with @example.com.

LIKE has two main wildcards:

% matches any number of characters.

_ matches exactly one character.

Escape these symbols when you want their literal meaning. For example, this finds an actual underscore:

Query
WHERE email LIKE '%\_%' ESCAPE '\'

Clean surrounding whitespace first when it should not affect the match. Use ILIKE in DuckDB or PostgreSQL when capitalisation should not matter.

For joins between identifiers, use = on consistently cleaned values rather than a broad pattern.

How do I compare names typed in different ways?

Decide which differences you want to ignore.

Different problems need different treatments:

Result4 rows
DifferencePossible treatment
Capitalisationlower()
Unwanted characters at the endstrim() with an explicit character list
Repeated ordinary spaces inside the nameregexp_replace()
Equivalent Unicode representationsNFC normalisation

For example, Carla’s surname contains n plus a combining tilde, while the search text uses a single ñ. NFC normalisation makes those equivalent forms consistent:

Query
SELECT signup_id, last_name
FROM signups
WHERE lower(nfc_normalize(trim(last_name, E' \t\r\n')))
= lower(nfc_normalize('Muñoz'));

This returns Carla’s signup.

In PostgreSQL with UTF-8 encoding, use normalize(text, NFC) instead of nfc_normalize(text).

Normalise both sides when both may contain inconsistent representations. NFC preserves accents and does not correct spelling mistakes: it does not make Muñoz equal to Munoz.

How do I sort names for a printed list?

If capitalisation should not affect the order, sort by the lower-case name while displaying the original:

Query
SELECT first_name
FROM signups
ORDER BY lower(first_name), first_name;

In P12, the result is:

Asha, ben, Carla, Dev, Esha, reuben, Sam

The first expression sorts without capitalisation deciding the order. The second uses the original name to resolve ties between values that differ only in case.

Sorting does not change the displayed spelling. The list still shows ben, as entered.

DuckDB’s default ordering puts these capitalised names before lower-case names. PostgreSQL and other databases may use different collation rules. For names in multiple languages, choose a collation appropriate to the intended readers.

Avoid automatically changing name capitalisation: names such as McDonald and de Souza do not follow a simple “capitalise the first letter” rule.

Don’t paste credentials, personal data, or confidential production records.

Practise this pattern

Now write it yourselfAll 9 String Functions problems →

Graded problems on this pattern, easy to hard. They run in your browser against real tables – no sign-up needed.

Related patterns

Up next · Pattern 13SQL data types and CAST: converting values safely

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.