SQL · Intermediate

SQL string functions: TRIM, SUBSTRING, REPLACE and regex

20 min read

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

DatabaseCharactersBytesWatch out
PostgreSQLlength(s), char_length(s)octet_length(s)
MySQLCHAR_LENGTH(s)LENGTH(s)LENGTH counts bytes, so '£10' is 4
SQL ServerLEN(s)DATALENGTH(s)LEN ignores trailing spaces
BigQueryLENGTH(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.

sql
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);
Output
            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

sql
position('HEART' IN s)   -- or strpos(s, 'HEART')
substring(s FROM 7 FOR 7) -- or substr(s, 7, 7)
split_part(s, '_', 3)     -- third piece

Tab 2

sql
LOCATE('HEART', s)        -- or INSTR(s, 'HEART'), note the swapped order
SUBSTRING(s, 7, 7)
SUBSTRING_INDEX(SUBSTRING_INDEX(s, '_', 3), '_', -1)  -- third piece

Tab 3

sql
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 = 3

Tab 4

sql
STRPOS(s, 'HEART')
SUBSTR(s, 7, 7)
SPLIT(s, '_')[SAFE_OFFSET(2)]   -- arrays here are 0-based

Case 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 nested REPLACE or 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

DatabaseFunctionLimit to know
PostgreSQL, SQL Server 2017+STRING_AGG(x, ', ' ORDER BY x) (SQL Server: WITHIN GROUP (ORDER BY x))none worth worrying about
MySQLGROUP_CONCAT(x ORDER BY x SEPARATOR ', ')cut at 1,024 characters by default (group_concat_max_len), with only a warning
BigQuerySTRING_AGG(x, ', ' ORDER BY x)
Oracle, SnowflakeLISTAGG(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

PatternMeans
^ / $start / end of the string
\da 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

sql
s ~ '^\d{5}$'                              -- matches? (~* ignores case)
substring(s FROM '^\d{5}([A-Za-z]+)$')     -- first capture group
regexp_replace(s, '\s+', ' ', 'g')

Tab 2

sql
s REGEXP '^[0-9]{5}$'
REGEXP_SUBSTR(s, '[A-Za-z]+$')             -- whole match; no capture groups
REGEXP_REPLACE(s, '[[:space:]]+', ' ')

Tab 3

sql
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

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

sql
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)
Output
CREATE TABLE
COPY 541909

Step 1: measure the mess

sql
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;
Output
 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.

sql
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;
Output
 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:

sql
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;
Output
 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:

sql
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');
Output
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

sql
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;
Output
  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

sql
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;
Output
 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

sql
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;
Output
 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:

sql
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);
Output
 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:

sql
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;
Output
         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

sql
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;
Output
 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

sql
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;
Output
 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:

sql
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;
Output
 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 LENGTH counts bytes; SQL Server LEN drops 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; use concat_ws or coalesce.

  • Unordered string aggregation. Without ORDER BY inside string_agg, list order can change between runs, which breaks diffs and tests.

  • MySQL's silent GROUP_CONCAT cut-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; POSITION returns 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 @:

sql
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;
Output
       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:

  1. Count distinct cities and sellers whose city is exactly 'sao paulo'.

  2. Build a view sellers_clean that lowercases, removes accents, replaces the ´ character with an apostrophe, cuts off anything after /, \, ,, - or (, collapses spaces, removes a trailing state code that matches seller_state, maps sp to sao paulo, and sets e-mail addresses and pure numbers to NULL.

  3. 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
sql
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;
Output
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:

sql
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;
Output
   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:

sql
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;
Output
                 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