IntermediateSQL · 30 min · 18 challenges

String Functions: Intermediate

In the week before Spotify Wrapped, the job that ranks each listener's top songs reads play logs where every item is a URI such as spotify:track:6rqhFgbbKwnb9MLmUQDhG6. Podcast plays arrive as spotify:episode: URIs, a few rows carry a stray leading space, and one wrong character position would crown the wrong song on someone's year-end card.

SUBSTRING()

Daily Life
Interviews

Every character in a string sits at a numbered position, and the numbering starts at 1. SUBSTR(text, start, length) returns length characters beginning at position start. SUBSTRING is also written SUBSTR, the spelling this lesson uses throughout. It hands back text, a VARCHAR, even when the characters it cuts out are digits.

Write the positions over a value once and every slice turns into a pair of numbers you can read off. Here is a release date that was loaded as text:
position12345678910
character2019-11-29

The year is 4 characters from position 1, the month is 2 characters from position 6, and the day is 2 characters from position 9. The third argument is a count of characters, so SUBSTR('2019-11-29', 6, 2) returns 11 while SUBSTR('2019-11-29', 6, 7) returns 11-29: positions 6 through 10 are all the value has, and the slice stops at the last of them.

Run those slices on a table of releases. Every row gets the same cut, and that is safe here because every date in the column was written in the same layout.
SELECT
track_name,
SUBSTR(release_date, 1, 4) AS release_year,
SUBSTR(release_date, 6, 2) AS release_month
FROM releases
releases4 rows
track_name
release_date
Night Drive
2019-11-29
Paper Moons
2020-10-01
Static Bloom
2022-04-01
Low Tide
2023-01-13
Preparing the animation

Both output columns hold text: 2019 and 11 are strings of digits. Comparing release_month to '11' works, but sorting months or adding to a year needs a number, so wrap the slice: CAST(SUBSTR(release_date, 6, 2) AS INTEGER) returns 11. Use TRY_CAST when some rows might hold something other than digits at those positions.

Reading to the end of the value

Leave out the length and SUBSTR keeps everything from start to the last character, which is what play logs need. In spotify:track:6rqhFgbbKwnb9MLmUQDhG6 the prefix spotify:track: fills positions 1 to 14, so SUBSTR(item_uri, 15) returns the 22-character ID after it, and the query never needs to know how long the ID is.

SELECT
play_id,
SUBSTR(item_uri, 15) AS item_id
FROM play_events
Result
play_iditem_id
16rqhFgbbKwnb9MLmUQDhG6
23n3Ppam7vgaVa1iaRUc9Lp
37ouMYWpwJ422jRcDASZB7P
3 rows

Positions outside the value

SUBSTR raises no error when a position falls outside the value. It returns whatever characters the requested slice overlaps, and an empty string when it overlaps none. Here is how that plays out on the 8-character value playlist:

callresultwhy
SUBSTR('playlist', 5, 4)listpositions 5 to 8
SUBSTR('playlist', 5, 100)listthe slice stops at the last character
SUBSTR('playlist', 9)'' (empty)position 9 is past the end
SUBSTR('playlist', 0, 3)'' (empty)positions start at 1, so 0 points at nothing
SUBSTR('playlist', 3, 0)'' (empty)a length of 0 asks for no characters
Run 3 of those calls yourself. Wrapping a slice in LENGTH turns an empty result into a 0 you can see in the grid.
SELECT
SUBSTR(word, 5, 100) AS past_end,
LENGTH(SUBSTR(word, 9)) AS start_past_end,
LENGTH(SUBSTR(word, 0, 3)) AS start_zero
FROM words
Result
past_endstart_past_endstart_zero
list02
1 row

An empty string is a value of its own, distinct from NULL. Blank slices pass an IS NOT NULL check and gather into a group of their own under GROUP BY, so a column full of blanks is a sign that the layout you assumed doesn't match the data.

Positions count characters, matching LENGTH: the é in café fills 1 position, so SUBSTR('café', 4, 1) returns é.

Every slice in this section relied on positions that hold still. Before hard-coding a number, decide which kind of column you are cutting.
✓Positions you can hard-code
  • Dates loaded in one layout, such as 2019-11-29
  • Codes whose width is fixed by a published spec
  • A prefix that every row in the column shares
✗Positions that move
  • Names and addresses typed by people
  • IDs whose prefix depends on the kind of item
  • Columns merged from sources with different layouts
Multiple Choice

A podcast play is logged in the same table as spotify:episode:512ojhOuo1ktJprKbVcKyQ. What does SUBSTR(item_uri, 15) return for it?

The same counting finds a piece in the middle of a code with a published layout. An ISRC, the 12-character code that identifies a recording, is 2 letters of country, then 3 characters of registrant, then 2 digits of year, then 5 digits of designation. A piece starts 1 position after the total width of the pieces before it, and its length is its own width. In USRC17607839 the year follows 2 + 3 characters, so it starts at position 6 and runs for 2: SUBSTR(isrc, 6, 2) returns 76.

Edge Extractions

Daily Life
Interviews

Plenty of the slices you need sit against one edge of the value: a country code at the end of a locale, a file extension, the last 4 digits of an account number. The start edge is position 1 for every value, so SUBSTR(locale, 1, 2) reads the first 2 characters whatever comes after them.

For the end edge, give SUBSTR a negative start. Position -1 is the last character and -2 the one before it, so SUBSTR(locale, -2) returns the final 2 characters of a value of any length. Leaving out the length still means read to the end.

characterfil_PH
from the start123456
from the end-6-5-4-3-2-1

Both rows of numbers point at the same characters, so SUBSTR('fil_PH', 5) and SUBSTR('fil_PH', -2) both return PH. The negative form keeps pointing at the last 2 characters when the next value is 1 character longer or shorter, which the positive form can't do.

Counts back from the last character with a negative start.
SELECT
locale,
SUBSTR(locale, 1, 2) AS language_code,
SUBSTR(locale, - 2) AS country_code
FROM user_settings
Result
localelanguage_codecountry_code
en_USenUS
pt_BRptBR
es_MXesMX
fil_PHfiPH
en_GBenGB
5 rows

Look at the fil_PH row in either tab. Its country code is right, because the country always fills the last 2 positions. Its language code comes back as fi: Filipino has no 2-letter language code, so this locale opens with fil, and a 2-character cut from the start splits it. An edge slice is trustworthy only on the side where the fixed-width piece sits. The other side needs the position of the separator, which STRPOS supplies 2 sections from now.

The end edge of a play URI

The same reasoning rescues the play log. A track prefix is 14 characters and an episode prefix is 16, but in this log every ID is 22 characters at the very end, so SUBSTR(item_uri, -22) returns the ID for both kinds of item.

SELECT
item_uri,
SUBSTR(item_uri, - 22) AS item_id
FROM play_events
Result
item_uriitem_id
spotify:track:6rqhFgbbKwnb9MLmUQDhG66rqhFgbbKwnb9MLmUQDhG6
spotify:track:3n3Ppam7vgaVa1iaRUc9Lp3n3Ppam7vgaVa1iaRUc9Lp
spotify:track:7ouMYWpwJ422jRcDASZB7P7ouMYWpwJ422jRcDASZB7P
spotify:episode:512ojhOuo1ktJprKbVcKyQ512ojhOuo1ktJprKbVcKyQ
4 rows

The end edge holds only while every ID keeps that width. A 21-character ID would come back with the colon in front of it, and no error would flag the row.

Values shorter than the slice

The 2 edges disagree when a value is shorter than the slice. From the start, SUBSTR('42', 1, 4) returns 42, the characters that exist. From the end, SUBSTR('42', -4) returns an empty string: position -4 lies before the first character, and a start outside the value matches nothing.

SELECT
artist_name,
account_ref,
LENGTH(account_ref) AS ref_length,
SUBSTR(account_ref, - 4) AS last_four
FROM payout_accounts
Result
artist_nameaccount_refref_lengthlast_four
Low TideACCT-00491827131827
Paper MoonsACCT-00330415130415
Night Drive27227
3 rows

A blank last_four on a payout statement looks like a display glitch, so make short values visible instead. CASE WHEN LENGTH(account_ref) >= 4 THEN SUBSTR(account_ref, -4) END returns NULL for them, and a NULL shows up in every check that counts missing values.

Multiple Choice

A postal_code column holds 94107, 10001 and 501. What does SUBSTR(postal_code, -4) return for the 3 rows?

An edge slice works in WHERE as well as in SELECT. WHERE SUBSTR(locale, 1, 2) = 'en' keeps rows whose first 2 characters are en, and the SELECT list can then label each kept row by its last 2. A start of 0 is a common slip: it points at no character, so SUBSTR(locale, 0, 2) returns an empty string that never equals 'en'. Dropping the minus sign is the other, and it changes which edge you read from: SUBSTR('en_GB', 2) starts at the second character and returns n_GB.

Complete the SQL

> List the locale and country code of every English-language user in user_settings. Two tiles do not belong.

1
2
3
4
5

REPLACE()

Daily Life
Interviews

REPLACE(text, search, replacement) finds every occurrence of search and puts replacement in its place. With only 2 arguments it deletes each match. Where SUBSTR cuts by position, REPLACE works by content: you name the characters, and it finds them wherever they sit.

Numbers stored as display text

Finance exports format money for people to read. $12,400.75 won't CAST to DECIMAL, because $ and , are not digits, and TRY_CAST quietly turns it into NULL. Delete those 2 symbols and the text that remains is one the cast accepts.

SELECT
artist_name,
amount_text,
CAST(
REPLACE(REPLACE(amount_text, ','), '$')
AS DECIMAL(10, 2)
) AS amount
FROM royalty_exports
royalty_exports3 rows
artist_name
amount_text
Low Tide
$1,250.00
Paper Moons
$980.50
Static Bloom
$12,400.75
Preparing the animation

Read nested calls from the inside out. The inner REPLACE deletes every comma, the outer one deletes the dollar sign from that result, and CAST receives 12400.75. Each call takes the string the previous call produced, so a chain of REPLACE calls is a small pipeline whose first step is the innermost.

The order of swaps changes the answer

Because each step sees the output of the step before it, 2 swaps that touch the same character can collide. Many European exports write 1.250,00 for the amount 1250: dots group the thousands and the comma marks the decimals. Converting it means deleting the dots and turning the comma into a dot, and only 1 order of those 2 steps works.

✓Dots first: 1250.00
  • REPLACE('1.250,00', '.') gives 1250,00
  • REPLACE('1250,00', ',', '.') gives 1250.00
  • CAST to DECIMAL returns 1250.00
✗Comma first: 125000
  • REPLACE('1.250,00', ',', '.') gives 1.250.00
  • REPLACE('1.250.00', '.') deletes both dots: 125000
  • CAST to DECIMAL returns 125000.00
The wrong order raises no error. It produces a number that casts cleanly and is 100 times too large, the kind of bug that survives until someone reconciles the revenue table against the bank.

Every match, in every context

REPLACE can't tell a match you meant from one you did not. REPLACE('carla.combs@mail.com', '.com') deletes both occurrences of .com and returns carlabs@mail. It's case-sensitive as well: REPLACE('Hip-Hop', 'hip-hop', 'hip hop') leaves the value unchanged, because H and h are different characters.

Lowercase first and then replace, and hand-typed tags stop splitting into look-alike groups. The 2 tabs below count plays per genre tag, once as typed and once normalized.
Groups the tags exactly as listeners typed them, so each spelling counts on its own.
SELECT
genre_tag,
COUNT(*) AS plays
FROM genre_plays
GROUP BY genre_tag
ORDER BY plays DESC
Result
genre_tagplays
Hip-Hop2
indie pop1
hip-hop1
hip hop1
Indie Pop1
HIP-HOP1
6 rows

The first tab returns 6 groups for what listeners hear as 2 genres. The normalized tab folds them into hip hop with 5 plays and indie pop with 2. GROUP BY repeats the whole expression because the genre alias is assigned in SELECT, which runs after grouping.

Multiple Choice

An export holds 2.499,90, meaning 2499.90. Which expression returns the decimal 2499.90?

Choosing a swap for grouping starts with 2 spellings that ought to land together. Lowercase both and find the characters where they still differ: lo-fi and lo_fi differ only at position 3. Pick one form as the target and replace the other toward it, so REPLACE(LOWER(genre_tag), '_', '-') sends both to lo-fi. Replacing toward a character that already means something else in the column would merge tags you meant to keep apart.

STRPOS()

Daily Life
Interviews

STRPOS(text, search) returns the position where search first appears in text, counting from 1, and 0 when it does not appear. On its own it answers where. Passed into SUBSTR as a start or a length, it lets every slice follow each value's own layout.

Email addresses show the pattern. In ana@beatbox.io the @ is at position 4, so the domain begins 1 position later, at 5. Written with STRPOS, that becomes SUBSTR(email, STRPOS(email, '@') + 1), which works for an address of any length.

SELECT
email,
STRPOS(email, '@') AS at_pos,
SUBSTR(email, STRPOS(email, '@') + 1) AS domain
FROM listeners
listeners3 rows
listener_id
email
1
ana@beatbox.io
2
dev.okafor@soundmail.com
3
support-team
Preparing the animation

Study the last row. support-team has no @, so STRPOS returns 0. The domain slice starts at 0 + 1, which is position 1, and returns the whole value, so a count of listeners by domain would report a domain named support-team. The username slice, SUBSTR(email, 1, STRPOS(email, '@') - 1), fails the other way: its length becomes -1 and it returns an empty string.

Guarding the not-found case

Turn that 0 into NULL with NULLIF and each slice built on it becomes NULL as well, since NULL plus 1 is NULL and SUBSTR given a NULL start returns NULL. Malformed rows then show up as missing values that COUNT(domain) skips, and no fake domain reaches the report.

SELECT
email,
SUBSTR(email, 1, NULLIF(STRPOS(email, '@'), 0) - 1) AS username,
SUBSTR(email, NULLIF(STRPOS(email, '@'), 0) + 1) AS domain
FROM listeners
Result
emailusernamedomain
ana@beatbox.ioanabeatbox.io
dev.okafor@soundmail.comdev.okaforsoundmail.com
support-teamNULLNULL
3 rows
TIP
STRPOS also works as a filter. WHERE STRPOS(email, '@') = 0 returns the malformed rows to fix, and > 0 keeps the rest. Like REPLACE, it matches case exactly, so search LOWER(email) when case can vary.

The second colon in a play URI

A play URI holds 2 colons, and STRPOS stops at the first one, position 8. To reach the second, search only the text after the first: SUBSTR(item_uri, 9) begins right after it, STRPOS finds the colon in that remainder, and adding 8 turns the remainder's position back into a position in the full URI, 14 in a track URI and 16 in an episode URI. STRPOS(item_uri, ':', 2) is a shorthand that asks for the second occurrence directly and returns the same numbers. The ID starts 1 position after that colon, and the item type fills the gap between position 9 and it, so the type's length is the colon's position minus 9. The CTE below finds the colon once and slices with it twice.

WITH located AS (
SELECT
item_uri,
STRPOS(SUBSTR(item_uri, 9), ':') + 8 AS second_colon
FROM play_events
)
SELECT
item_uri,
second_colon,
SUBSTR(item_uri, 9, second_colon - 9) AS item_type,
SUBSTR(item_uri, second_colon + 1) AS item_id
FROM located
Result
item_urisecond_colonitem_typeitem_id
spotify:track:6rqhFgbbKwnb9MLmUQDhG614track6rqhFgbbKwnb9MLmUQDhG6
spotify:track:3n3Ppam7vgaVa1iaRUc9Lp14track3n3Ppam7vgaVa1iaRUc9Lp
spotify:track:7ouMYWpwJ422jRcDASZB7P14track7ouMYWpwJ422jRcDASZB7P
spotify:episode:512ojhOuo1ktJprKbVcKyQ16episode512ojhOuo1ktJprKbVcKyQ
4 rows

No slice in that query assumes a width. A playlist URI, or an ID of a different length, would still split cleanly. The fi bug from the locale column has the same cure: SUBSTR(locale, 1, STRPOS(locale, '_') - 1) returns fil for fil_PH and en for en_US.

Multiple Choice

What does STRPOS(spotify:playlist:37i9dQZF1DXcBWIGoYBM5M, ':', 2) return?

Every delimiter slice comes down to 1 rule: the delimiter's own position is the boundary, and neither piece includes it. The piece before it ends 1 position earlier, so its length is the delimiter's position minus 1. The piece after it starts 1 position later, at the position plus 1. In fil_PH the delimiter sits at 4, so the language is 3 characters from position 1 and the country starts at 5.

TRIM()

Daily Life
Interviews

TRIM(text) removes whitespace from both ends of a value. LTRIM removes it from the left end only, RTRIM from the right end only, and none of the 3 touches spaces between words.

Edge whitespace is invisible in a result grid and decisive in a comparison. 'Low Tide ' and 'Low Tide' differ by 1 character, so they fail =, land in separate GROUP BY groups and refuse to pair in a JOIN. LENGTH shows what the grid hides, and wrapping the value in brackets with CONCAT shows where it starts and ends.

SELECT
CONCAT('[', artist_name, ']') AS shown,
LENGTH(artist_name) AS raw_length,
LENGTH(TRIM(artist_name)) AS trimmed_length
FROM artist_signups
Result
shownraw_lengthtrimmed_length
[Low Tide]88
[Low Tide ]98
[ Low Tide]108
[Paper Moons]1212
4 rows

TRIM turns 3 spellings of Low Tide into the same 8 characters. Paper Moons keeps its double space at 12 characters, because the extra space sits between words. REPLACE(artist_name, ' ', ' ') shrinks each pair of spaces to 1 in a single pass, so a run of 3 spaces needs a second pass.

A join that drops rows with no error

A stream count file uploaded from a spreadsheet carries artist names as people typed them. Joined to the artist table on name, every row looks like a match, yet only 1 of them pairs.
Compares the uploaded names exactly as stored.
SELECT
s.artist_name,
s.streams,
a.artist_id
FROM stream_counts AS s
LEFT JOIN artists AS a
ON s.artist_name = a.artist_name
Result
artist_namestreamsartist_id
Low Tide 5200NULL
Paper Moons31002
Night Drive870NULL
3 rows

In the raw tab only Paper Moons gets an artist_id. The other 2 names read identically to the artist table's, but Low Tide ends in a space and Night Drive starts with one. Trimming inside the ON clause matches all 3. With an INNER JOIN the 2 unmatched artists would have dropped out of the total, leaving no NULL behind to hint that anything was missing.

Trim before you slice

A leading space pushes every position 1 to the right. SUBSTR(' en_US', 1, 2) returns a space followed by e, and STRPOS(' en_US', '_') returns 4 where the clean value gives 3. Trim first, then hand the clean value to SUBSTR, STRPOS or REPLACE.

✓Do
  • Trim and lowercase both sides of a text join key: LOWER(TRIM(name))
  • Check LENGTH beside the value when a match fails for no visible reason
  • Trim once in a CTE and slice the trimmed column downstream
✗Don't
  • Trust a result grid to show trailing spaces
  • Run SUBSTR or STRPOS on user-entered text before trimming it
  • Expect TRIM to collapse spaces between words
Multiple Choice

city holds ' New York ', with 2 spaces at each end. What do LENGTH(RTRIM(city)) and LENGTH(TRIM(city)) return?

Capstone: the episodes that vanished

This case puts every function from the lesson into one broken report. Read the evidence, question the people involved and name the cause.
The Episodes That Vanished
Monday morning, the podcast team's dashboard reports 0 episode plays for the past week, while the audio servers show steady traffic. The dashboard reads a report that slices each play URI into a content type and counts plays per type. Find which assumption in that slice broke, and on which rows.
plays_by_type
content_typeplays
track2
episo2
:trac1

The fix in that case follows a fixed order. TRIM goes innermost, wrapped around the column at every place the column appears, because SUBSTR and STRPOS both read positions and each needs the cleaned value. For an email column with stray spaces, SUBSTR(TRIM(email), STRPOS(TRIM(email), '@') + 1) returns the domain whether or not the row was padded.

❯❯❯PUTTING IT ALL TOGETHER

> A streaming service builds each listener's year-end recap from raw play URIs, some pasted from shared links with stray spaces, and joins the extracted IDs to a catalog so every card prints the right track and podcast names.

TRIM runs first, because a single leading space moves every position that SUBSTR and STRPOS compute.
STRPOS(uri, ':', 2) marks where the item type ends, so one SUBSTR returns track or episode and another returns the ID.
NULLIF around STRPOS turns malformed URIs into NULLs a quality check can count, so none of them joins to the wrong catalog row.
REPLACE and LOWER fold hand-typed genre tags together so the recap's genre totals group correctly.
LENGTH beside each extracted ID confirms the slice before the catalog join trusts it.
KEY TAKEAWAYS
SUBSTR(text, start, length) counts positions from 1, and its third argument is a count of characters.
A negative start reads from the end edge; slice from the side where the fixed-width piece sits.
A slice that falls outside the value returns an empty string, a value distinct from NULL.
REPLACE swaps every match by content and exact case, and nested calls run innermost first, so their order decides the result.
STRPOS returns 0 when the search text is absent; NULLIF turns that 0 into NULL before it reaches SUBSTR.
TRIM edge whitespace before you compare, join or slice, and check LENGTH when equal-looking values refuse to match.

Slice, locate, swap and trim text by character position, turning messy IDs and labels into clean join keys.

SUBSTR, TRIM, and string surgery 101

Category
SQL
Difficulty
intermediate
Duration
30 minutes
Challenges
18 hands-on challenges

Topics covered: SUBSTRING(), Edge Extractions, REPLACE(), STRPOS(), TRIM()

Lesson Sections

  1. SUBSTRING() (concepts: Substrings)

    Write the positions over a value once and every slice turns into a pair of numbers you can read off. Here is a release date that was loaded as text: Run those slices on a table of releases. Every row gets the same cut, and that is safe here because every date in the column was written in the same layout. Reading to the end of the value Positions outside the value Run 3 of those calls yourself. Wrapping a slice in LENGTH turns an empty result into a 0 you can see in the grid. Positions count char

  2. Edge Extractions (concepts: Substrings)

    The end edge of a play URI The end edge holds only while every ID keeps that width. A 21-character ID would come back with the colon in front of it, and no error would flag the row. Values shorter than the slice

  3. REPLACE() (concepts: String Replacement)

    Numbers stored as display text The order of swaps changes the answer The wrong order raises no error. It produces a number that casts cleanly and is 100 times too large, the kind of bug that survives until someone reconciles the revenue table against the bank. Every match, in every context Lowercase first and then replace, and hand-typed tags stop splitting into look-alike groups. The 2 tabs below count plays per genre tag, once as typed and once normalized.

  4. STRPOS() (concepts: String Search)

    Guarding the not-found case The second colon in a play URI

  5. TRIM() (concepts: Whitespace Trimming)

    A join that drops rows with no error A stream count file uploaded from a spreadsheet carries artist names as people typed them. Joined to the artist table on name, every row looks like a match, yet only 1 of them pairs. Trim before you slice Capstone: the episodes that vanished This case puts every function from the lesson into one broken report. Read the evidence, question the people involved and name the cause.