SQL · Intermediate
SQL string functions: TRIM, SUBSTRING, REPLACE and regex
Why it matters
SQL string functions are how you turn messy text into something you can group, join and count. Product names with stray spaces, codes typed in two cases, city names written five different ways: every real dataset has them, and if you GROUP BY the raw text, the same thing gets counted as several things. SQL appears in 88% of Data Analyst and 80% of Data Engineer job descriptions we studied, and cleaning text columns is one of the first things a new analyst is asked to do.
This lesson covers the functions you will use every week (TRIM, SUBSTRING, REPLACE, POSITION, SPLIT_PART, STRING_AGG, CONCAT) and regular expressions, with the dialect differences that trip people up when they move between PostgreSQL, MySQL, SQL Server and BigQuery.
The concept
Length: characters, bytes, and trailing spaces
| Database | Characters | Bytes | Watch out |
|---|---|---|---|
| PostgreSQL | length(s), char_length(s) | octet_length(s) | |
| MySQL | CHAR_LENGTH(s) | LENGTH(s) | LENGTH counts bytes, so '£10' is 4 |
| SQL Server | LEN(s) | DATALENGTH(s) | LEN ignores trailing spaces |
| BigQuery | LENGTH(s) | BYTE_LENGTH(s) |
So a MySQL WHERE LENGTH(name) > 50 silently measures bytes, and anything in Hindi, Tamil or with a ₹ sign counts double or triple. In SQL Server, LEN('abc ') is 3, so you cannot use it to find values with trailing spaces; compare DATALENGTH instead.
Trimming and spaces you cannot see
TRIM(s) removes leading and trailing spaces; LTRIM and RTRIM do one side. Two traps:
TRIM only removes the plain space (character 32) by default. A non-breaking space (character 160), which arrives with data copied from web pages, Excel and PDFs, survives TRIM. You need
TRIM(BOTH ' ' || chr(160) FROM s)or a regular expression.TRIM does nothing about double spaces in the middle.
regexp_replace(s, '\s+', ' ', 'g')collapses any run of whitespace to one space.
Cutting strings: SUBSTRING, LEFT, RIGHT, POSITION
Positions in SQL start at 1, not 0. POSITION returns 0 when the text is not found.
SELECT description,
position('HEART' IN description) AS pos,
left(description, 5) AS first5,
right(description, 6) AS last6,
substring(description FROM 7 FOR 7) AS from7
FROM (VALUES ('WHITE HANGING HEART T-LIGHT HOLDER'),
('JUMBO BAG RED RETROSPOT')) AS t(description); description | pos | first5 | last6 | from7
------------------------------------+-----+--------+--------+---------
WHITE HANGING HEART T-LIGHT HOLDER | 15 | WHITE | HOLDER | HANGING
JUMBO BAG RED RETROSPOT | 0 | JUMBO | ROSPOT | BAG RED
(2 rows)The names differ by database:
Tab 1
position('HEART' IN s) -- or strpos(s, 'HEART')
substring(s FROM 7 FOR 7) -- or substr(s, 7, 7)
split_part(s, '_', 3) -- third pieceTab 2
LOCATE('HEART', s) -- or INSTR(s, 'HEART'), note the swapped order
SUBSTRING(s, 7, 7)
SUBSTRING_INDEX(SUBSTRING_INDEX(s, '_', 3), '_', -1) -- third pieceTab 3
CHARINDEX('HEART', s)
SUBSTRING(s, 7, 7) -- length is required
-- STRING_SPLIT returns rows, not one piece; ordinal needs SQL Server 2022
SELECT value FROM STRING_SPLIT(s, '_', 1) WHERE ordinal = 3Tab 4
STRPOS(s, 'HEART')
SUBSTR(s, 7, 7)
SPLIT(s, '_')[SAFE_OFFSET(2)] -- arrays here are 0-basedCase and collation
UPPER, LOWER and (PostgreSQL, Oracle, Snowflake) INITCAP change case. Whether 'Delhi' = 'DELHI' is true depends on the collation, not the function. PostgreSQL compares case-sensitively by default, so you write lower(a) = lower(b) or use ILIKE. MySQL 8's default collation utf8mb4_0900_ai_ci is case-insensitive and accent-insensitive, so 'sao paulo' = 'São Paulo' is true there. SQL Server depends on the column's collation (_CI_ or _CS_ in the name). The same query can return different counts on two databases for this reason alone.
Replacing: REPLACE, TRANSLATE, REGEXP_REPLACE
replace(s, 'from', 'to')swaps every occurrence of a fixed string.translate(s, 'abc', 'xyz')maps single characters one to one: handy for stripping accents or removing a set of characters (translate(phone, ' -()', '')in PostgreSQL).regexp_replace(s, pattern, replacement, 'g')uses a regular expression. In PostgreSQL, the'g'flag means "every match"; without it only the first match is replaced. MySQL 8 and BigQuery replace every match by default. SQL Server has no regex functions before SQL Server 2025, so cleaning there uses nestedREPLACEor happens in Python.
Concatenation and NULL
'a' || NULL is NULL in standard SQL, PostgreSQL, SQLite and Snowflake (Oracle is the odd one out and treats NULL as an empty string). CONCAT('a', NULL) is 'a' in PostgreSQL and SQL Server, but NULL in MySQL and BigQuery. CONCAT_WS(sep, ...) skips NULLs in PostgreSQL, MySQL and SQL Server, so it is the safe choice for building addresses and labels (BigQuery has no CONCAT_WS; use ARRAY_TO_STRING([...], sep), which also skips NULLs). In MySQL, || means logical OR unless PIPES_AS_CONCAT is on.
Rolling rows into one string
| Database | Function | Limit to know |
|---|---|---|
| PostgreSQL, SQL Server 2017+ | STRING_AGG(x, ', ' ORDER BY x) (SQL Server: WITHIN GROUP (ORDER BY x)) | none worth worrying about |
| MySQL | GROUP_CONCAT(x ORDER BY x SEPARATOR ', ') | cut at 1,024 characters by default (group_concat_max_len), with only a warning |
| BigQuery | STRING_AGG(x, ', ' ORDER BY x) | |
| Oracle, Snowflake | LISTAGG(x, ', ') WITHIN GROUP (ORDER BY x) | Oracle errors past 4,000 bytes unless ON OVERFLOW TRUNCATE |
Without an ORDER BY inside the aggregate, the order of items is not guaranteed and can change between runs.
Regular expressions in one minute
| Pattern | Means |
|---|---|
^ / $ | start / end of the string |
\d | a digit; \d{5} is exactly five digits |
[A-Za-z]+ | one or more letters |
\s+ | one or more whitespace characters |
(...) | a capture group: the part you want back |
.* | anything, any length |
Testing and extracting by database:
Tab 1
s ~ '^\d{5}$' -- matches? (~* ignores case)
substring(s FROM '^\d{5}([A-Za-z]+)$') -- first capture group
regexp_replace(s, '\s+', ' ', 'g')Tab 2
s REGEXP '^[0-9]{5}$'
REGEXP_SUBSTR(s, '[A-Za-z]+$') -- whole match; no capture groups
REGEXP_REPLACE(s, '[[:space:]]+', ' ')Tab 3
REGEXP_CONTAINS(s, r'^\d{5}$')
REGEXP_EXTRACT(s, r'^\d{5}([A-Za-z]+)$') -- returns the capture group
REGEXP_REPLACE(s, r'\s+', ' ')Tab 4
REGEXP_LIKE(s, '^\\d{5}$') -- must match the whole string
REGEXP_SUBSTR(s, '^\\d{5}([A-Za-z]+)$', 1, 1, 'e', 1)
REGEXP_REPLACE(s, '\\s+', ' ')Worked example: cleaning product data in Online Retail
We use the UCI Online Retail dataset (541,909 invoice lines from a UK online gift shop, CC BY 4.0) in PostgreSQL 16. Every output below is from a real run.
Load the data
Download online-retail-dataset.csv (link in Further reading), start psql in the same folder, and run:
CREATE TABLE raw_retail (
invoice_no text,
stock_code text,
description text,
quantity int,
invoice_date text,
unit_price numeric(10,3),
customer_id int,
country text
);
\copy raw_retail FROM 'online-retail-dataset.csv' WITH (FORMAT csv, HEADER true)CREATE TABLE
COPY 541909Step 1: measure the mess
SELECT count(DISTINCT description) AS descriptions,
count(DISTINCT description) FILTER (WHERE description <> trim(description)) AS untrimmed,
count(DISTINCT description) FILTER (WHERE description LIKE '% %') AS double_spaces,
count(DISTINCT description) FILTER (WHERE description <> upper(description)) AS not_upper
FROM raw_retail;
SELECT DISTINCT stock_code, '[' || description || ']' AS shown_with_brackets
FROM raw_retail
WHERE stock_code IN ('21243', '22900')
ORDER BY stock_code, shown_with_brackets; descriptions | untrimmed | double_spaces | not_upper
--------------+-----------+---------------+-----------
4223 | 757 | 191 | 201
(1 row)
stock_code | shown_with_brackets
------------+------------------------------------
21243 | [PINK POLKADOT PLATE ]
21243 | [PINK POLKADOT PLATE ]
22900 | [ SET 2 TEA TOWELS I LOVE LONDON ]
22900 | [SET 2 TEA TOWELS I LOVE LONDON ]
(4 rows)757 of the 4,223 distinct descriptions have spaces at an edge and 191 have a double space inside. Wrapping values in brackets is a quick trick to make invisible spaces visible. Product 21243 is listed under two names that differ only by one space, so a report grouped by description would split its sales in two.
Step 2: normalise whitespace and case
Order matters: trim the edges, collapse inner runs of whitespace, then uppercase.
WITH v AS (
SELECT DISTINCT stock_code,
description,
upper(regexp_replace(trim(description), '\s+', ' ', 'g')) AS clean
FROM raw_retail
WHERE description IS NOT NULL
)
SELECT count(DISTINCT description) AS raw_descriptions,
count(DISTINCT clean) AS clean_descriptions
FROM v; raw_descriptions | clean_descriptions
------------------+--------------------
4223 | 4186
(1 row)37 descriptions merged into others. But does that give each product one name? Count products (codes starting with five digits, priced lines only) that still have more than one description:
WITH v AS (
SELECT DISTINCT upper(stock_code) AS code,
description,
upper(regexp_replace(trim(description), '\s+', ' ', 'g')) AS clean
FROM raw_retail
WHERE description IS NOT NULL AND unit_price > 0 AND stock_code ~ '^\d{5}'
),
per_code AS (
SELECT code,
count(DISTINCT description) AS raw_n,
count(DISTINCT clean) AS clean_n
FROM v
GROUP BY code
)
SELECT count(*) FILTER (WHERE raw_n > 1) AS codes_with_several_raw,
count(*) FILTER (WHERE clean_n > 1) AS codes_with_several_clean
FROM per_code; codes_with_several_raw | codes_with_several_clean
------------------------+--------------------------
215 | 207
(1 row)Only 8 of 215 were whitespace problems. The other 207 are genuine renames (product 23236 is sold as both "STORAGE TIN VINTAGE DOILY" and "DOILEY STORAGE TIN"). No string function fixes that. The standard answer is a product table with one name per code, the most common one:
CREATE TABLE products_clean AS
SELECT DISTINCT ON (upper(stock_code))
upper(stock_code) AS stock_code,
upper(regexp_replace(trim(description), '\s+', ' ', 'g')) AS description
FROM raw_retail
WHERE description IS NOT NULL AND unit_price > 0 AND stock_code ~ '^\d{5}'
GROUP BY upper(stock_code), description
ORDER BY upper(stock_code), count(*) DESC, description;
SELECT * FROM products_clean
WHERE stock_code IN ('21243', '22900', '23236');SELECT 3805
stock_code | description
------------+--------------------------------
21243 | PINK POLKADOT PLATE
22900 | SET 2 TEA TOWELS I LOVE LONDON
23236 | STORAGE TIN VINTAGE DOILY
(3 rows)DISTINCT ON keeps the first row per code in the ORDER BY, which is the description with the most lines. On other databases, use ROW_NUMBER() as in Remove duplicate rows in SQL with ROW_NUMBER.
Step 3: characters vs bytes, and pulling a number out of text
SELECT stock_code,
description,
length(description) AS chars,
octet_length(description) AS bytes,
substring(description FROM '£(\d+\.\d{2})')::numeric AS voucher_value,
split_part(stock_code, '_', 3) AS code_part
FROM raw_retail
WHERE description LIKE '%£%'
GROUP BY stock_code, description
ORDER BY voucher_value; stock_code | description | chars | bytes | voucher_value | code_part
--------------+-------------------------------------+-------+-------+---------------+-----------
gift_0001_10 | Dotcomgiftshop Gift Voucher £10.00 | 34 | 35 | 10.00 | 10
gift_0001_20 | Dotcomgiftshop Gift Voucher £20.00 | 34 | 35 | 20.00 | 20
gift_0001_30 | Dotcomgiftshop Gift Voucher £30.00 | 34 | 35 | 30.00 | 30
gift_0001_40 | Dotcomgiftshop Gift Voucher £40.00 | 34 | 35 | 40.00 | 40
gift_0001_50 | Dotcomgiftshop Gift Voucher £50.00 | 34 | 35 | 50.00 | 50
22016 | Dotcomgiftshop Gift Voucher £100.00 | 35 | 36 | 100.00 |
(6 rows)Three lessons in one result. The £ sign is one character but two bytes in UTF-8, which is exactly where MySQL's LENGTH misleads you. The regex '£(\d+\.\d{2})' returns only the part in brackets, which casts cleanly to a number. And the £100 voucher is filed under product code 22016, so a filter on stock_code LIKE 'gift%' would have missed it; searching the text found it. split_part returns an empty string, not NULL, when the piece does not exist.
Step 4: the space TRIM cannot see
SELECT length('SALE' || chr(160)) AS with_nbsp,
length(trim('SALE' || chr(160))) AS after_trim,
length(trim(BOTH ' ' || chr(160) FROM 'SALE' || chr(160))) AS trim_listed_chars,
length(regexp_replace('SALE' || chr(160), '[\s ]+$', '')) AS after_regex; with_nbsp | after_trim | trim_listed_chars | after_regex
-----------+------------+-------------------+-------------
5 | 5 | 4 | 4
(1 row)chr(160) is the non-breaking space. Plain trim left it in place (still 5 characters). Naming it in the TRIM list, or using a regex, removes it. When a join on text "should" match and does not, check for this first.
Step 5: stock codes in two cases, split with regex
SELECT count(DISTINCT stock_code) AS codes,
count(DISTINCT upper(stock_code)) AS codes_ignoring_case
FROM raw_retail;
SELECT stock_code, count(*) AS lines
FROM raw_retail
WHERE upper(stock_code) = '15056BL'
GROUP BY stock_code; codes | codes_ignoring_case
-------+---------------------
4070 | 3958
(1 row)
stock_code | lines
------------+-------
15056BL | 326
15056bl | 62
(2 rows)112 codes exist in both upper and lower case. 15056BL and 15056bl are the same parasol, so join and group on upper(stock_code).
Most product codes are five digits, sometimes followed by letters for a colour or size variant. A regex with a capture group splits them:
SELECT stock_code,
substring(stock_code FROM '^\d{5}') AS base_code,
upper(substring(stock_code FROM '^\d{5}([A-Za-z]+)$')) AS variant
FROM (VALUES ('85123A'), ('15056bl'), ('22423'), ('DCGS0066P'), ('POST')) AS t(stock_code); stock_code | base_code | variant
------------+-----------+---------
85123A | 85123 | A
15056bl | 15056 | BL
22423 | 22423 |
DCGS0066P | |
POST | |
(5 rows)When the pattern does not match, substring ... FROM returns NULL, which is exactly what you want for codes like POST (postage) that are not products. Applied to every code:
WITH parts AS (
SELECT DISTINCT upper(stock_code) AS code,
substring(stock_code FROM '^\d{5}') AS base_code,
upper(substring(stock_code FROM '^\d{5}([A-Za-z]+)$')) AS variant
FROM raw_retail
)
SELECT CASE WHEN base_code IS NULL THEN 'not a product code'
WHEN variant IS NULL THEN 'plain 5 digits'
ELSE 'with variant letters' END AS kind,
count(*) AS codes
FROM parts
GROUP BY 1
ORDER BY codes DESC;
WITH parts AS (
SELECT DISTINCT upper(stock_code) AS code,
substring(stock_code FROM '^\d{5}') AS base_code
FROM raw_retail
)
SELECT base_code, count(*) AS variants, string_agg(code, ', ' ORDER BY code) AS codes
FROM parts
WHERE base_code IS NOT NULL
GROUP BY base_code
ORDER BY variants DESC, base_code
LIMIT 3; kind | codes
----------------------+-------
plain 5 digits | 2946
with variant letters | 980
not a product code | 32
(3 rows)
base_code | variants | codes
-----------+----------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
90214 | 24 | 90214A, 90214B, 90214C, 90214D, 90214E, 90214F, 90214G, 90214H, 90214I, 90214J, 90214K, 90214L, 90214M, 90214N, 90214O, 90214P, 90214R, 90214S, 90214T, 90214U, 90214V, 90214W, 90214Y, 90214Z
85049 | 8 | 85049A, 85049B, 85049C, 85049D, 85049E, 85049F, 85049G, 85049H
79323 | 7 | 79323B, 79323G, 79323GR, 79323LP, 79323P, 79323S, 79323W
(3 rows)With the base code you can report sales for a whole product family (all 24 versions of 90214) as well as for each variant.
Step 6: building labels when values can be NULL
SELECT DISTINCT ON (invoice_no)
invoice_no,
customer_id,
'Customer ' || customer_id AS with_pipes,
concat('Customer ', customer_id) AS with_concat,
concat_ws(' / ', invoice_no, customer_id, country) AS with_concat_ws
FROM raw_retail
WHERE invoice_no IN ('536365', '536414')
ORDER BY invoice_no; invoice_no | customer_id | with_pipes | with_concat | with_concat_ws
------------+-------------+----------------+----------------+---------------------------------
536365 | 17850 | Customer 17850 | Customer 17850 | 536365 / 17850 / United Kingdom
536414 | | | Customer | 536414 / United Kingdom
(2 rows)Invoice 536414 has no customer ID. || turned the whole label into NULL, concat produced a half label, and concat_ws dropped the missing part and its separator. For anonymous lines the honest label is usually coalesce('Customer ' || customer_id, 'Guest').
Step 7: every product a customer bought, as one string
WITH bought AS (
SELECT DISTINCT r.customer_id, p.description
FROM raw_retail AS r
JOIN products_clean AS p ON p.stock_code = upper(r.stock_code)
WHERE r.customer_id IS NOT NULL AND r.quantity > 0
)
SELECT customer_id,
count(*) AS products,
string_agg(description, ', ' ORDER BY description) AS product_list
FROM bought
WHERE customer_id = 18280
GROUP BY customer_id; customer_id | products | product_list
-------------+----------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
18280 | 10 | ALARM CLOCK BAKELIKE CHOCOLATE, ALARM CLOCK BAKELIKE RED, GUMBALL COAT RACK, KINGS CHOICE TEA CADDY, PAPER CHAIN KIT EMPIRE, RETROSPOT LAMP, SET OF 2 ROUND TINS CAMEMBERT, VINTAGE UNION JACK SHOPPING BAG, WOOD BLACK BOARD ANT WHITE FINISH, WOODEN UNION JACK BUNTING
(1 row)DISTINCT in the CTE stops a product appearing twice because it was bought on two invoices, and joining to products_clean means each product shows its one clean name. Now check how long these lists get for everyone:
WITH bought AS (
SELECT DISTINCT r.customer_id, p.description
FROM raw_retail AS r
JOIN products_clean AS p ON p.stock_code = upper(r.stock_code)
WHERE r.customer_id IS NOT NULL AND r.quantity > 0
),
lists AS (
SELECT customer_id,
string_agg(description, ', ' ORDER BY description) AS product_list
FROM bought
GROUP BY customer_id
)
SELECT count(*) AS customers,
count(*) FILTER (WHERE length(product_list) > 1024) AS longer_than_1024,
max(length(product_list)) AS longest
FROM lists; customers | longer_than_1024 | longest
-----------+------------------+---------
4335 | 2137 | 51082
(1 row)2,137 of 4,335 customers have a list longer than 1,024 characters, and the longest is 51,082. Run the same query with MySQL's GROUP_CONCAT on default settings and almost half the lists would be cut off at 1,024 characters with only a warning. Set SET SESSION group_concat_max_len = 1000000; first, and ask whether a 51,000-character cell is useful to anyone; often a count plus the top five is the better output.
Common mistakes
Grouping on raw text. Trailing spaces, double spaces and case make one thing look like several. Normalise (
upper(regexp_replace(trim(s), '\s+', ' ', 'g'))) before you group or join.Expecting cleaning to fix renames. Whitespace and case cleaning fixed 8 of 215 multi-name products here. Pick one name per key in a dimension table.
Trusting TRIM with pasted data. Non-breaking spaces (character 160) survive it.
Measuring bytes by accident. MySQL
LENGTHcounts bytes; SQL ServerLENdrops trailing spaces.Forgetting the
'g'flag in PostgreSQL.regexp_replace(s, '\s+', ' ')replaces only the first run.Building labels with
||over nullable columns. One NULL makes the whole string NULL; useconcat_wsorcoalesce.Unordered string aggregation. Without
ORDER BYinsidestring_agg, list order can change between runs, which breaks diffs and tests.MySQL's silent
GROUP_CONCATcut-off at 1,024 characters.Filtering by code when the truth is in the text, or the reverse. The £100 voucher hid under a product code.
0-based thinking. SQL string positions start at 1;
POSITIONreturns 0 for "not found", not -1.
Interview angle
Extract the domain from an email column and count users per domain.
Trim, take everything after the @, lowercase it (domains are case-insensitive), and skip values without an @:
WITH users(email) AS (
VALUES ('asha@gmail.com'), ('Ravi.K@Gmail.com '), ('neha@company.co.in'),
('vendas@creditparts.com.br'), ('broken-email'), (NULL)
)
SELECT lower(split_part(trim(email), '@', 2)) AS domain, count(*) AS users
FROM users
WHERE email LIKE '%@%'
GROUP BY 1
ORDER BY users DESC, domain; domain | users
--------------------+-------
gmail.com | 2
company.co.in | 1
creditparts.com.br | 1
(3 rows)Without trim and lower, Gmail.com would be a separate domain. In MySQL use SUBSTRING_INDEX(email, '@', -1); in SQL Server, SUBSTRING(email, CHARINDEX('@', email) + 1, LEN(email)); in BigQuery, SPLIT(email, '@')[SAFE_OFFSET(1)]. Mention that co.in vs gmail.com is a second question (registrable domain), which needs a public suffix list, not string functions.
Why might TRIM not remove a space?
Because it is not character 32. Non-breaking spaces (160), tabs and zero-width spaces come in from web pages, Excel and PDFs. Find them with WHERE s ~ '[^\x20-\x7e]' or by comparing length before and after, and remove them with regexp_replace or by listing them in TRIM(BOTH ... FROM s).
What does CONCAT do with NULL?
It depends on the database. PostgreSQL and SQL Server treat NULL as an empty string in CONCAT; MySQL and BigQuery return NULL. The || operator returns NULL in most databases (Oracle is the exception). CONCAT_WS skips NULLs in PostgreSQL, MySQL and SQL Server, so it is the safest choice for labels; whichever you use, say which database you mean.
How would you list all products per customer in one row? Any pitfalls?
STRING_AGG(DISTINCT product, ', ' ORDER BY product) in PostgreSQL (GROUP_CONCAT in MySQL, LISTAGG in Oracle and Snowflake). Pitfalls: duplicates if you aggregate invoice lines without DISTINCT, unstable order without ORDER BY, MySQL's default 1,024-character limit, and Oracle's 4,000-byte limit. Also ask whether one huge text cell is really what the consumer needs.
Practice task
The Olist sellers file (3,095 Brazilian marketplace sellers, CC BY-NC-SA 4.0) has a free-text seller_city column typed by the sellers themselves. Load olist_sellers_dataset.csv into a table sellers (seller_id, seller_zip_code_prefix, seller_city, seller_state) and:
Count distinct cities and sellers whose city is exactly
'sao paulo'.Build a view
sellers_cleanthat lowercases, removes accents, replaces the´character with an apostrophe, cuts off anything after/,\,,,-or(, collapses spaces, removes a trailing state code that matchesseller_state, mapssptosao paulo, and sets e-mail addresses and pure numbers to NULL.Count distinct cities and São Paulo sellers again, and list every value your cleaning changed.
Hint: one são paulo in this file does not match translate(s, 'ã', 'a'). Compare its length with the same word typed by you.
Show a solution
CREATE TABLE sellers (
seller_id text PRIMARY KEY,
seller_zip_code_prefix text,
seller_city text,
seller_state text
);
\copy sellers FROM 'olist_sellers_dataset.csv' WITH (FORMAT csv, HEADER true)
SELECT count(DISTINCT seller_city) AS cities,
count(*) FILTER (WHERE seller_city = 'sao paulo') AS sao_paulo_sellers
FROM sellers;CREATE TABLE
COPY 3095
cities | sao_paulo_sellers
--------+-------------------
611 | 694
(1 row)First, the hint. The ã in the file is stored decomposed: a plain a followed by a combining tilde (two characters), while the one you type is a single precomposed character:
SELECT 'são paulo' AS typed,
length('são paulo') AS typed_chars,
translate('são paulo', 'ã', 'a') AS typed_translated;
SELECT raw, length(raw) AS chars, translate(raw, 'ã', 'a') AS translated,
regexp_replace(normalize(raw, NFD), '[̀-ͯ]', '', 'g') AS stripped
FROM (SELECT seller_city AS raw FROM sellers
WHERE seller_city LIKE 's_%o paulo' AND seller_city <> 'sao paulo') AS t; typed | typed_chars | typed_translated
-----------+-------------+------------------
são paulo | 9 | sao paulo
(1 row)
raw | chars | translated | stripped
-----------------------+-------+-----------------------+-----------------------
sao paulo / sao paulo | 21 | sao paulo / sao paulo | sao paulo / sao paulo
são paulo | 10 | são paulo | sao paulo
santo andre/sao paulo | 21 | santo andre/sao paulo | santo andre/sao paulo
(3 rows)Ten characters, and translate cannot see the ã. normalize(s, NFD) splits every accented letter into letter plus mark, and removing the combining marks (̀ to ͯ) strips accents whether the source was composed or not. That is more reliable than any translate list.
The cleaning view, one step per CTE so each rule is easy to test:
CREATE VIEW sellers_clean AS
WITH step1 AS (
SELECT seller_id, seller_state, seller_city AS raw_city,
replace(regexp_replace(normalize(lower(seller_city), NFD), '[̀-ͯ]', '', 'g'),
'´', '''') AS city
FROM sellers
),
step2 AS (
SELECT seller_id, seller_state, raw_city,
trim(regexp_replace(regexp_replace(city, '\s*(/|\\|,| - |\().*$', ''), '\s+', ' ', 'g')) AS city
FROM step1
),
step3 AS (
SELECT seller_id, seller_state, raw_city,
regexp_replace(city, '[ -]+' || lower(seller_state) || '$', '') AS city
FROM step2
)
SELECT seller_id, seller_state, raw_city,
CASE WHEN raw_city ~ '@|^[0-9 ]+$' THEN NULL
WHEN city = 'sp' THEN 'sao paulo'
ELSE city END AS city
FROM step3;
SELECT raw_city, city, count(*) AS sellers
FROM sellers_clean
WHERE raw_city <> coalesce(city, '')
GROUP BY 1, 2
ORDER BY city NULLS FIRST, raw_city;
SELECT count(DISTINCT city) AS cities,
count(*) FILTER (WHERE city = 'sao paulo') AS sao_paulo_sellers,
count(*) FILTER (WHERE city IS NULL) AS unusable
FROM sellers_clean; raw_city | city | sellers
------------------------------------------+------------------------+---------
4482255 | | 1
vendas@creditparts.com.br | | 1
andira-pr | andira | 1
angra dos reis rj | angra dos reis | 1
arraial d'ajuda (porto seguro) | arraial d'ajuda | 1
auriflama/sp | auriflama | 1
barbacena/ minas gerais | barbacena | 1
brasilia df | brasilia | 1
carapicuiba / sao paulo | carapicuiba | 1
cariacica / es | cariacica | 1
ferraz de vasconcelos | ferraz de vasconcelos | 1
jacarei / sao paulo | jacarei | 1
lages - sc | lages | 1
maua/sao paulo | maua | 1
mogi das cruzes / sp | mogi das cruzes | 1
novo hamburgo, rio grande do sul, brasil | novo hamburgo | 1
pinhais/pr | pinhais | 1
ribeirao preto / sao paulo | ribeirao preto | 1
rio de janeiro / rio de janeiro | rio de janeiro | 1
rio de janeiro \rio de janeiro | rio de janeiro | 1
rio de janeiro, rio de janeiro, brasil | rio de janeiro | 1
santa barbara d´oeste | santa barbara d'oeste | 2
santo andre/sao paulo | santo andre | 1
sao jose dos pinhais | sao jose dos pinhais | 1
sao paulo | sao paulo | 1
sao paulo - sp | sao paulo | 3
sao paulo / sao paulo | sao paulo | 1
sao paulo sp | sao paulo | 1
são paulo | sao paulo | 1
sp | sao paulo | 4
sp / sp | sao paulo | 1
sao sebastiao da grama/sp | sao sebastiao da grama | 1
sbc/sp | sbc | 1
(33 rows)
cities | sao_paulo_sellers | unusable
--------+-------------------+----------
581 | 706 | 2
(1 row)611 city spellings became 581, and São Paulo gained 12 sellers (694 to 706). Two values are left on purpose: sao paulop (a typo) and sbc (a local abbreviation for São Bernardo do Campo). Fixing those needs a small mapping table or fuzzy matching (PostgreSQL's fuzzystrmatch extension has levenshtein), and in production you would map on the ZIP code prefix instead of trusting typed text at all. Rules like "strip the state if it matches seller_state" are safer than "strip any two letters at the end", which would damage real names.
Further reading
PostgreSQL: string functions:
trim,split_part,normalize,string_aggand the rest.PostgreSQL: pattern matching:
~,substring ... FROM,regexp_replaceand its flags.SQL Server: LEN: the trailing-space rule, and STRING_AGG.
UCI Online Retail dataset (CC BY 4.0), CSV copy: online-retail-dataset.csv.
Olist datasets:
olist_sellers_dataset.csv(CC BY-NC-SA 4.0).