Why string handling matters for analysts

Real-world data is messy. The Customers table arrives with names in all caps, phone numbers with country codes, emails with typos, and product codes glued together. You cannot group or join on dirty text, so string functions are the first line of cleaning. I reach for them before any dashboard because a wrong join on 'NYC' versus 'nyc' silently drops half the rows.

The three functions I use most are SUBSTRING to slice, REGEXP to match, and CONCAT to assemble. Learn these and most cleaning tasks stop being scary.

SUBSTRING: slice a piece of text

SELECT SUBSTRING(phone, 1, 3) AS area_code
FROM Customers

In Postgres and SQL Server the start index is 1;

1
Extract a fixed position

MySQL also supports SUBSTRING(phone FROM 1 FOR 3). This pulls the first three characters of a phone number.

SELECT SUBSTRING(sku, 4, 5) AS product_code
FROM Products
WHERE sku LIKE 'US-%'
2
Pull a code from a SKU

If sku is 'US-48213-BLUE', this returns '48213', the part you actually group by.

SELECT SUBSTRING(email, 1, LENGTH(email) - 4) AS local_part
FROM Customers
3
Trim with LENGTH when the end varies

strips the '.com' by computing the length. Use this when the tail you want to drop has a fixed size but the head does not.

Query editor showing SUBSTRING pulling an area code and a product code from sample text columns.

REGEXP: match patterns, not just substrings

SELECT email
FROM Customers
WHERE email REGEXP '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'

This keeps only rows that look like real addresses and drops 'john@' or 'john@x'. MySQL and Postgres both support REGEXP;

4
Find valid emails

SQL Server uses the LIKE pattern or a CLR function.

SELECT *
FROM Products
WHERE name REGEXP 'pro|max|plus'
5
Keep rows that contain a word

returns products whose name contains pro, max, or plus. The pipe means OR inside the pattern.

6
Use LIKE for simple prefixes

When you only need a start or end match, SELECT * FROM Orders WHERE order_id LIKE '2025%' is faster and clearer than a full regex. Reach for REGEXP only when the pattern is genuinely complex.

Pro Tip

I recommend REGEXP only when LIKE cannot express the rule. A complex pattern is harder to read and usually slower. In my experience a simple LIKE with % beats a clever regex on a 2-million-row table every time.

CONCAT: build and join text

SELECT CONCAT(first_name, ' ', last_name) AS full_name
FROM Customers
7
Build a full name

The space between quotes is required or the names glue together as 'JohnSmith'.

SELECT CONCAT(region, '-', order_id) AS join_key
FROM Orders
8
Build a key for joining

A composite key like 'West-48213' lets you join two systems that store the same order under different schemes.

9
Handle NULLs safely

CONCAT ignores NULL in MySQL but returns NULL in Postgres and SQL Server if any argument is NULL. Use COALESCE(first_name, '') to turn NULL into an empty string before concatenating.

Cleaning a messy import with string functions

SELECT UPPER(region)
FROM Orders
10
Normalize case

turns 'nyc' and 'NYC' into one value so your GROUP BY does not split the same region. Use LOWER(email) the same way for email joins.

SELECT TRIM(customer_name)
FROM Customers
11
Strip whitespace

removes leading and trailing spaces that hide in CSV imports and break exact matches. Combine with LOWER for a clean join key.

SELECT REPLACE(phone, '-', '')
FROM Customers
12
Replace bad characters

removes dashes so '212-555-0199' becomes '2125550199' and matches a system that stores digits only.

Pro Tip

Avoid assuming your source is clean. A common mistake is joining on customer_name straight from a form, where 'Smith' and ' Smith ' never match. TRIM and LOWER before any join and you skip a whole class of missing-row bugs.

The collation trap: why 'Smith' != 'smith'

Collation decides how text is compared. A case-insensitive collation treats 'Smith' and 'smith' as equal; a case-sensitive one does not, and your JOIN or GROUP BY will split them. The default collation is set per database and per column, so two tables can disagree.

SELECT *
FROM Customers
WHERE last_name = 'smith' COLLATE SQL_Latin1_General_CP1_CI_AS
13
Force a collation in the query

makes the comparison case-insensitive for that one query, overriding the column setting.

SELECT LOWER(last_name) COLLATE SQL_Latin1_General_CP1_CI_AS AS name, COUNT(*)
FROM Customers
GROUP BY LOWER(last_name) COLLATE SQL_Latin1_General_CP1_CI_AS
ORDER BY name
14
Normalize before grouping

This guarantees one bucket per name regardless of source casing.

Worked example: clean and validate a customer list

Here is a runnable query that trims, lowercases, validates, and builds a clean key from the Customers table in one pass.

SELECT customer_id,
  LOWER(TRIM(email)) AS clean_email,
  CONCAT(TRIM(first_name), ' ', TRIM(last_name)) AS full_name
FROM Customers
WHERE email REGEXP '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$';
15
Count what passed and what failed

Run the same WHERE as a COUNT to see how many emails were rejected. If 312 of 50,000 fail, that is a 0.6 percent bad-data rate worth a quick fix at the signup form.

Side-by-side grid showing raw customer rows next to cleaned email and full name columns.

Extracting parts with position functions

Beyond SUBSTRING, most engines offer LEFT, RIGHT, and POSITION to grab edges or find a delimiter. LEFT(email, 3) returns the first three characters; POSITION('@' IN email) tells you where the domain starts so you can slice the domain with SUBSTRING(email, POSITION('@' IN email) + 1). This is how you split 'john@shop.com' into 'john' and 'shop.com' in one query.

In my experience splitting on POSITION is more reliable than counting characters by hand, because emails vary in length. A common mistake is an off-by-one when adding 1 to the position, which keeps the '@' inside the domain slice. Add 1 so the delimiter itself is dropped, and the same logic applies to splitting a SKU on a dash or a path on a slash. Bad emails alone can cost a business around $1,200 a year in bounced campaigns, so cleaning them pays off.

SELECT SUBSTRING(email,
  1,
  POSITION('@' IN email) - 1) AS local_part,
  SUBSTRING(email, POSITION('@' IN email) + 1) AS domain
FROM Customers
WHERE email LIKE '%@%'
16
Split an email into local and domain

The WHERE drops rows without an '@' so POSITION never returns zero.

Turning text dates into real dates

Messy exports often store dates as text like '01/05/2025'. Cast them with TO_DATE in Postgres or STR_TO_DATE in MySQL so you can use DATE_TRUNC later. Until a text column is a real date type, sorting puts '02/01/2025' before '10/01/2025' as if it were text, which corrupts every time-based report built on top of it.

I recommend validating the format with a regex before casting, because one row like 'NULL' or 'TBD' will throw the whole cast and return nothing. Filter those out first, or use TRY_CAST in SQL Server, which returns NULL instead of erroring on a bad value and lets the rest of the rows through.

SELECT STR_TO_DATE(order_date_text, '%d/%m/%Y') AS order_date
FROM Orders
WHERE order_date_text REGEXP '^[0-9]{2}/[0-9]{2}/[0-9]{4}$'
17
Cast a text date in MySQL

Only rows matching the dd/mm/yyyy shape are cast, so no row breaks the conversion.

Grid showing a text date column cast into a proper date column.

Before you load cleaned text into a report, sample a few rows by eye, because a query that runs clean can still produce wrong values. I once lowercased emails for a join and watched 3 percent of customers vanish, only to find the source stored some addresses with trailing spaces that TRIM fixed but LOWER alone did not. A quick SELECT of ten raw and cleaned pairs would have caught it in seconds. I now keep a validation step that shows raw next to cleaned for a sample of rows, so a silent transformation error is visible before it reaches the dashboard. That check has paid for itself every time a form changes and starts sending data in a new shape.

Pro Tip

I recommend showing ten raw and cleaned rows side by side before you trust a cleaning pass, because a query that runs clean can still return wrong values. On the Customers table, =LOWER(TRIM(email)) fixed trailing spaces that LOWER alone missed, and 3 percent of rows vanished from a join until I added TRIM. A common mistake is cleaning only one side of an XLOOKUP, so 'Smith ' never matches 'Smith'. I keep a validation step that prints raw next to cleaned, and I REGEXP the email column before any CAST to a date.

Next steps and a quick checklist

TRIM and LOWER before any join, use SUBSTRING for fixed slices, REGEXP only for real patterns, and CONCAT with explicit separators. Always check the collation when two tables disagree on casing. Once your text is clean, feed it into GROUP BY and CASE WHEN to label and aggregate. Practice on a messy Customers or Products import until cleaning feels like a reflex rather than a project. Once your text is clean, feed it into SQL GROUP BY and HAVING and SQL CASE WHEN to label and aggregate. The advanced aggregate guide shows how clean strings power accurate segment counts.