We closed module 5 with a list of gaps: you're still returning name and last_name in two columns when you want one, you still can't put a piece of text in upper case, extract the size from a product name or pull the domain out of an email address. This module starts there, because text is what gets manipulated most when presenting results and what diverges most between engines. But before the first function there's a confusion to clear up that a lot of people carry around for years: the difference between a scalar function and an aggregate function. You saw the aggregate ones in 04-04; this module's belong to the other family. If that distinction is clear to you, the rest is vocabulary.
Contents
- Scalar against aggregate: one row in, one row out
- Measuring and changing case:
LENGTH,UPPER,LOWER,INITCAP - Cleaning and padding:
TRIM,LPAD,RPAD - Extracting, locating and splitting
- Composing text:
CONCAT_WS,FORMATand 02-02's|| - Extraction with regular expressions
- Real GreenStore cases
- Functions over columns and indexes
- Comparison table by engine
- Common Mistakes and Tips
- Exercises
- Conclusion
- Scalar against aggregate: one row in, one row out
A scalar function takes one or more values from the same row and returns one value for that row. If the query reads 4 rows, the result has 4 rows.
| id | name | name_length |
|---|---|---|
| 14 | Organic chamomile tea 20 bags | 29 |
| 15 | Ceremonial matcha green tea 30 g | 32 |
| 16 | Ginger kombucha 750 ml | 22 |
| 17 | Cold-pressed orange juice 1 L | 29 |
An aggregate function takes a set of rows and returns a single value:
SELECT COUNT(*) AS products,
MAX(LENGTH(name)) AS max_length,
MIN(LENGTH(name)) AS min_length
FROM products
WHERE category_id = 4;| products | max_length | min_length |
|---|---|---|
| 4 | 32 | 22 |
A single row. And look at MAX(LENGTH(name)): it combines the two families. First LENGTH is applied to each row (scalar), then MAX collapses the four results into one (aggregate). The order is never the other way round.
| Scalar function | Aggregate function | |
|---|---|---|
| Input | The values of one row | The values of many rows |
| Output | One value per row | One value per group |
| Rows in the result | The same as there were | One per group (or a single one with no GROUP BY) |
| Examples | LENGTH, UPPER, ROUND, COALESCE |
COUNT, SUM, AVG, MIN, MAX |
Can it go in the WHERE? |
Yes | No (that's what HAVING is for, 04-06) |
| Lesson | Module 6 | 04-04 |
The rule in one sentence: a scalar function transforms; an aggregate one summarises.
UPPER(name)transforms each name;COUNT(name)summarises them all into one number. Every function in module 6 is scalar.
- Measuring and changing case
| Function | What it does | Example | Result |
|---|---|---|---|
LENGTH(s), CHAR_LENGTH(s) |
Number of characters (synonyms) | LENGTH('Ceremonial matcha green tea 30 g') |
32 |
OCTET_LENGTH(s) |
Number of bytes | OCTET_LENGTH('Castellón') |
10 |
UPPER(s) |
Everything to upper case | UPPER('Raw orange blossom honey 500 g') |
RAW ORANGE BLOSSOM HONEY 500 G |
LOWER(s) |
Everything to lower case | LOWER('Ceremonial Matcha Green Tea') |
ceremonial matcha green tea |
INITCAP(s) |
First letter of each word in upper case, the rest in lower | INITCAP('raw orange blossom honey') |
Raw Orange Blossom Honey |
That Castellón —9 characters, 10 bytes— sums up an inexhaustible source of errors: in UTF-8, an accented character takes two bytes.
SELECT name || ' ' || last_name AS customer,
LENGTH(name || ' ' || last_name) AS characters,
OCTET_LENGTH(name || ' ' || last_name) AS bytes
FROM customers WHERE id IN (1, 2, 9) ORDER BY id;| customer | characters | bytes |
|---|---|---|
| Lucía Martínez Soler | 20 | 22 |
| Carlos Ferrer Ibáñez | 20 | 22 |
| Camille Dubois | 14 | 14 |
The third name carries no accented letters and the two numbers match; the first two each carry two, and the byte count runs two ahead. When VARCHAR(n) limits characters (PostgreSQL) none of this matters; when it limits bytes (MySQL with certain types, Oracle with VARCHAR2(n BYTE)) it's the classic cause of value too long with accented names.
Three warnings about INITCAP: it capitalises every word, short prepositions and units included (Loofah Scrubber (Pack Of 3), Extra Virgin Olive Oil 500 Ml), which looks wrong on a commercial title and is perfect for normalising city names; it respects accents (INITCAP('CASTELLÓN') → Castellón); and it doesn't exist in MySQL, SQLite or SQL Server, making it the function most missed when porting code from PostgreSQL or Oracle.
UPPER and LOWER handle accents properly because they depend on the database's collation, the same en-US-x-icu you configured for ORDER BY in 02-05.
- Cleaning and padding
| Function | What it does | Example | Result |
|---|---|---|---|
TRIM(s) |
Removes spaces on both sides | TRIM(' Valencia ') |
'Valencia' |
LTRIM(s) / RTRIM(s) |
Left only / right only | RTRIM(' Valencia ') |
' Valencia' |
TRIM(BOTH 'x' FROM s) |
Removes the character given, not spaces | TRIM(BOTH '0' FROM '00123400') |
'1234' |
TRIM(LEADING '0' FROM s) / TRIM(TRAILING '0' FROM s) |
One side only | TRIM(LEADING '0' FROM '00123400') |
'123400' |
LPAD(s, n, fill) |
Pads on the left up to n characters |
LPAD('7', 5, '0') |
'00007' |
RPAD(s, n, fill) |
Pads on the right | RPAD('Valencia', 12, '.') |
'Valencia....' |
TRIM(BOTH 'x' FROM s) is standard SQL syntax, not an ordinary call, and that's why it carries keywords instead of commas. It's used when cleaning up imports: leading zeros on a code, stray quotes from a CSV, trailing full stops.
Two traps.
TRIM(BOTH 'ab' FROM s)doesn't remove the string'ab': it removes any of the charactersaorb. AndLPADguarantees an exact length: if the string is longer it trims it (LPAD('Valencia', 5, '.')→'Valen').
- Extracting, locating and splitting
| Function | What it does | Example | Result |
|---|---|---|---|
SUBSTRING(s FROM p FOR n) |
n characters from position p |
SUBSTRING('Extra virgin olive oil' FROM 1 FOR 5) |
'Extra' |
SUBSTRING(s FROM p) |
From p to the end (SUBSTRING(s, p, n) is the comma variant) |
SUBSTRING('Extra virgin olive oil 500 ml' FROM 24) |
'500 ml' |
LEFT(s, n) / RIGHT(s, n) |
First / last n characters |
RIGHT('Valencia', 3) |
'cia' |
LEFT(s, -n) |
Everything except the last n |
LEFT('Valencia', -3) |
'Valen' |
POSITION(sub IN s) |
Position of the first occurrence | POSITION('olive' IN 'Extra virgin olive oil') |
14 |
STRPOS(s, sub) |
The same, arguments the other way round | STRPOS('lucia.martinez@example.com', '@') |
15 |
REPLACE(s, old, new) |
Replaces all the occurrences | REPLACE('… olive oil 500 ml', '500 ml', '1 L') |
'… olive oil 1 L' |
SPLIT_PART(s, sep, n) |
Splits by a separator, returns piece n |
SPLIT_PART('lucia@example.com', '@', 2) |
'example.com' |
REVERSE(s) / REPEAT(s, n) |
Reverses / repeats n times |
REPEAT('*', 5) |
'*****' |
Three things to commit to memory: positions start at 1, not at 0 (if you're coming from a programming language, that's the first week's mistake); POSITION and STRPOS return 0 when they don't find anything, not NULL, so you can compare them without dragging in 04-03's three-valued logic; and SPLIT_PART returns the empty string if piece n doesn't exist, again not NULL.
SPLIT_PART is one of PostgreSQL's most useful functions and it has no direct equivalent in most engines. What with STRPOS + SUBSTRING + an easily forgotten - 1 costs two nested expressions, here is a single call:
SELECT email,
SPLIT_PART(email, '@', 1) AS username,
SPLIT_PART(email, '@', 2) AS domain
FROM customers WHERE id IN (1, 7, 9) ORDER BY id;| username | domain | |
|---|---|---|
| lucia.martinez@example.com | lucia.martinez | example.com |
| sofia.moreira@example.pt | sofia.moreira | example.pt |
| camille.dubois@example.fr | camille.dubois | example.fr |
- Composing text:
CONCAT_WS, FORMAT and 02-02's ||
CONCAT_WS, FORMAT and 02-02's ||In 02-02 you used || to join name and last_name, and in 04-03 you discovered its trap: if one operand is NULL, the whole result is NULL. Here are the alternatives.
| Function | What it does | Example | Result |
|---|---|---|---|
a || b |
Concatenates. It propagates the NULL |
'Lucía' || NULL |
*(null)* |
CONCAT(a, b, c) |
Concatenates and ignores the NULLs |
CONCAT('Lucía', NULL, ' Soler') |
'Lucía Soler' |
CONCAT_WS(sep, a, b, c) |
Concatenates with a separator and ignores the NULLs |
CONCAT_WS(' ', 'Lucía', NULL, 'Soler') |
'Lucía Soler' |
FORMAT(pattern, …) |
A template with %s placeholders |
FORMAT('%s: %s €', 'Olive oil', 12.50) |
'Olive oil: 12.50 €' |
CONCAT_WS (With Separator) doesn't just ignore nulls: it also leaves the separator out where there's no value. Let's pick up 04-03's reflexive LEFT JOIN:
SELECT c.id,
CONCAT_WS(' ', c.name, c.last_name) AS customer,
ref.name || ' ' || ref.last_name AS referrer_pipe,
CONCAT_WS(' ', ref.name, ref.last_name) AS referrer_ws
FROM customers AS c
LEFT JOIN customers AS ref ON c.referred_by_id = ref.id
WHERE c.id <= 5
ORDER BY c.id;| id | customer | referrer_pipe | referrer_ws |
|---|---|---|---|
| 1 | Lucía Martínez Soler | (null) | |
| 2 | Carlos Ferrer Ibáñez | Lucía Martínez Soler | Lucía Martínez Soler |
| 3 | Marta Sanchis Gil | Lucía Martínez Soler | Lucía Martínez Soler |
| 4 | Javier Ortega Ruiz | (null) | |
| 5 | Ana Belmonte Roca | Carlos Ferrer Ibáñez | Carlos Ferrer Ibáñez |
Look at rows 1 and 4: referrer_pipe is *(null)* and referrer_ws is the empty string.
The honest conclusion:
CONCAT_WSsolves the case "one of the fields is missing". It doesn't solve "there's nobody there", which needs explicit text like'Direct signup'. For that you needCOALESCE, in 06-04.
FORMAT builds templates printf-style:
SELECT FORMAT('Product %s: %s units at %s €', id, stock, price) AS card
FROM products WHERE id IN (1, 13) ORDER BY id;| card |
|---|
| Product 1: 120 units at 12.50 € |
| Product 13: 0 units at 13.75 € |
Advantages over ||: it converts the types by itself (no ::TEXT needed), the template can be read at a glance and %s with a NULL argument produces the empty string instead of nulling everything out. The placeholders %I (identifier) and %L (quoted literal) are for generating safe dynamic SQL and they'll show up in module 10.
- Extraction with regular expressions
In 04-01 you used LIKE, ILIKE and ~ to decide whether a string matches a pattern. Here regular expressions do something else: they extract and replace.
| Function | What it does | Example | Result |
|---|---|---|---|
REGEXP_REPLACE(s, pattern, repl) |
Replaces the first match | REGEXP_REPLACE('a1b2c3', '[0-9]', '#') |
'a#b2c3' |
REGEXP_REPLACE(s, pattern, repl, 'g') |
Replaces all of them (the global flag) | REGEXP_REPLACE('a1b2c3', '[0-9]', '#', 'g') |
'a#b#c#' |
REGEXP_MATCHES(s, pattern) |
Returns an array with the captured groups | REGEXP_MATCHES('500 ml', '(\d+) (\w+)') |
{500,ml} |
SUBSTRING(s FROM pattern) |
Extracts the first match as text | SUBSTRING('Green tea 30 g' FROM '\d+ ?\w+$') |
'30 g' |
The practical case: pulling the size out of the product name. GreenStore's names end with a format (500 ml, 30 g, 1 kg, 20 bags) except four that don't carry one — the same four you found in 04-01.
SELECT id,
name,
SUBSTRING(name FROM '[0-9]+ ?(ml|kg|g|L|units|bags)$') AS size,
REGEXP_REPLACE(name, '\s*[0-9]+ ?(ml|kg|g|L|units|bags)$', '') AS short_name
FROM products
WHERE id IN (1, 2, 11, 14, 18)
ORDER BY id;| id | name | size | short_name |
|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 500 ml | Extra virgin olive oil |
| 2 | Organic brown rice 1 kg | 1 kg | Organic brown rice |
| 11 | Loofah scrubber (pack of 3) | (null) | Loofah scrubber (pack of 3) |
| 14 | Organic chamomile tea 20 bags | 20 bags | Organic chamomile tea |
| 18 | Bamboo toothbrush | (null) | Bamboo toothbrush |
The four products with no size —11, 12, 13 and 18— return NULL from SUBSTRING and the untouched name from REGEXP_REPLACE: no match, no replacement.
A detail about the alternation (ml|kg|g|L|units|bags): kg comes before g. The other way round, an unanchored engine would match the g of kg and the result would be wrong. The alternation tries the options in the order written, so the most specific goes first.
REGEXP_MATCHES's trap: it returns a set of rows, not a value; put in theSELECT, the rows with no match disappear (the 20 products would come out as 16). For one value per row useSUBSTRING(… FROM pattern)or, from PostgreSQL 15 onwards,REGEXP_SUBSTR(s, pattern).
- Real GreenStore cases
7.1. A customer card
SELECT id,
CONCAT_WS(' ', name, last_name) AS full_name,
LEFT(name, 1) || '.' || LEFT(last_name, 1) || '.' AS initials,
UPPER(SPLIT_PART(last_name, ' ', 1)) AS first_surname,
SPLIT_PART(email, '@', 2) AS domain
FROM customers
WHERE id <= 4
ORDER BY id;| id | full_name | initials | first_surname | domain |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | L.M. | MARTÍNEZ | example.com |
| 2 | Carlos Ferrer Ibáñez | C.F. | FERRER | example.com |
| 3 | Marta Sanchis Gil | M.S. | SANCHIS | example.com |
| 4 | Javier Ortega Ruiz | J.O. | ORTEGA | example.com |
SPLIT_PART(last_name, ' ', 1) takes advantage of the fact that the two surnames sit together separated by a space: it's the "backfill that splits a column" announced in 05-06.
7.2. Customers by email domain
SELECT SPLIT_PART(email, '@', 2) AS domain,
COUNT(*) AS customers
FROM customers
GROUP BY SPLIT_PART(email, '@', 2)
ORDER BY customers DESC, domain;| domain | customers |
|---|---|
| example.com | 11 |
| example.fr | 2 |
| example.pt | 2 |
Section 1's two families coexist here: SPLIT_PART is scalar and gets computed row by row; COUNT(*) summarises each group. And the GROUP BY repeats the whole expression, not the alias: it's the "grouping by an expression" 04-05 flagged.
7.3. Normalising city names
GreenStore's data is clean, but a real import file never is. The canonical recipe is a chain of three functions, in this order:
| normalised_city |
|---|
| Valencia |
INITCAP(LOWER(TRIM(x))) turns ' vaLENcia ', 'VALENCIA' and 'valencia' into the same 'Valencia': TRIM removes the edges, LOWER levels the case and INITCAP rebuilds it. It's the pattern you'll use whenever you group by a text column that was typed in by hand.
7.4. Masking the email address
SELECT id,
email,
LEFT(SPLIT_PART(email, '@', 1), 2)
|| REPEAT('*', LENGTH(SPLIT_PART(email, '@', 1)) - 2)
|| '@' || SPLIT_PART(email, '@', 2) AS masked_email
FROM customers
WHERE id <= 3
ORDER BY id;| id | masked_email | |
|---|---|---|
| 1 | lucia.martinez@example.com | lu************@example.com |
| 2 | carlos.ferrer@example.com | ca***********@example.com |
| 3 | marta.sanchis@example.com | ma***********@example.com |
Important warning: this is presentation masking, not anonymisation. The original value is still intact in the table and anybody with access can see it. Real anonymisation —pseudonymisation, aggregation with a minimum threshold, deletion— is a legal (GDPR) and architectural matter, not a string-function one. Use it so a report doesn't display full email addresses, never as a substitute for a data protection policy.
- Functions over columns and indexes
A warning for the future. A filter like WHERE LOWER(email) = 'lucia.martinez@example.com' returns the right row, but by wrapping the column in a function the engine stops comparing the column's values and starts comparing computed values, which no ordinary index contains. The solution exists —the expression index, CREATE INDEX … ON customers (LOWER(email))— but indexes are module 8 and there you'll see it measured with EXPLAIN in 08-03. For now, hold on to the rule: a function over a column in the WHERE has a cost. In the SELECT there's no problem: transforming what you've already read is free compared with reading it.
- Comparison table by engine
| Task | PostgreSQL 16 | MySQL 8 | SQLite | SQL Server | Oracle |
|---|---|---|---|---|---|
| Length in characters | LENGTH, CHAR_LENGTH |
CHAR_LENGTH (LENGTH gives bytes) |
LENGTH |
LEN (ignores trailing spaces) |
LENGTH |
| Length in bytes | OCTET_LENGTH |
LENGTH |
— | DATALENGTH |
LENGTHB |
| Substring | SUBSTRING(s FROM p FOR n) |
SUBSTRING, SUBSTR |
SUBSTR |
SUBSTRING(s,p,n) (n mandatory) |
SUBSTR(s,p,n) |
| First / last | LEFT, RIGHT |
LEFT, RIGHT |
SUBSTR |
LEFT, RIGHT |
SUBSTR |
| Capitalise words | INITCAP |
doesn't exist | doesn't exist | doesn't exist | INITCAP |
| Concatenate | ||, CONCAT, CONCAT_WS |
CONCAT, CONCAT_WS (|| only with PIPES_AS_CONCAT) |
|| |
+, CONCAT, CONCAT_WS (2012+) |
||, CONCAT (only 2 arguments) |
| Position of a substring | POSITION, STRPOS |
LOCATE, INSTR |
INSTR |
CHARINDEX |
INSTR |
| Pad / trim a character | LPAD, RPAD, TRIM(BOTH 'x' FROM s) |
the same | doesn't exist (printf); TRIM(s,'x') |
REPLICATE; TRIM('x' FROM s) (2022+) |
LPAD, RPAD, TRIM |
| Split by a separator | SPLIT_PART |
SUBSTRING_INDEX |
doesn't exist | STRING_SPLIT (returns a table) |
REGEXP_SUBSTR |
| Replace with regex | REGEXP_REPLACE |
REGEXP_REPLACE (8.0+) |
doesn't exist | doesn't exist | REGEXP_REPLACE |
| Repeat / reverse | REPEAT, REVERSE |
REPEAT, REVERSE |
don't exist | REPLICATE, REVERSE |
RPAD, REVERSE |
Two portability traps that cost whole afternoons. In Oracle, the empty string '' is NULL: LENGTH('') returns NULL, not 0, and everything you learned in 04-03 about telling '' apart from NULL doesn't apply there. And in SQL Server, LEN ignores trailing spaces: LEN('Valencia ') is 8, not 10; to count properly you need DATALENGTH.
Common Mistakes and Tips
- Confusing scalar with aggregate.
LENGTH(name)gives one row per product;MAX(LENGTH(name))gives a single one. If the result has fewer rows than you expected, check whether you've slipped an aggregate in by accident. - Counting from 0. In SQL, string positions start at 1. And
LPADguarantees an exact length, not a minimum one: if the string is longer, it trims it. - Using
||with nullable columns. A singleNULLnulls out the whole expression.CONCAT_WSto join fields,COALESCE(06-04) to put in default text. - Believing
CONCAT_WSsolves every null. With all the arguments null it returns'', which in a report reads as an empty cell. AndREGEXP_MATCHESin theSELECTmakes the rows with no match disappear: useSUBSTRING(… FROM pattern). - Getting a regex alternation's order wrong.
(g|kg)matches thegofkg. The most specific goes first. - Confusing
TRIM(BOTH 'ab' FROM s)with removing the string'ab'. It removes the individual charactersaandb. And careful: in PostgreSQLLENGTHcounts characters, in MySQL it counts bytes. - Tip: always normalise with
INITCAP(LOWER(TRIM(x))), in that order, before grouping by hand-typed text. - Tip: prefer
SPLIT_PARTtoSTRPOS+SUBSTRINGwhen the separator is fixed: it reads better and it doesn't have the- 1everybody forgets. And useFORMATfor templates with more than two pieces.
Exercises
Exercise 1
Marketing wants labels for the catalogue. For each active product in categories 1 and 4, return the id formatted as GS-00001 (a prefix, a hyphen and five digits with leading zeros), the name without its size and in title format, the size separately (or NULL if it doesn't carry one) and the length in characters of the original name. Sort by id.
Exercise 2
Customer support needs a masked contact view. For each customer, return the full name in one column, the initials of the first name and of both surnames (L.M.S. for Lucía Martínez Soler) and the email with the username hidden except for its first two letters. Add the top-level domain (com, pt, fr) and, in a second query, count how many customers there are of each. (Hint: the surnames are separated by a space; SPLIT_PART and LEFT are enough.)
Exercise 3
A colleague has written LEFT(last_name, POSITION(' ' IN last_name)) AS first_surname over customers. (1) What two problems does the result have? Look at customers 9 and 10. (2) Fix it using POSITION. (3) Fix it using SPLIT_PART and explain why that version has neither of the two problems.
Solutions
Solution 1
SELECT 'GS-' || LPAD(id::TEXT, 5, '0') AS reference,
INITCAP(REGEXP_REPLACE(name, '\s*[0-9]+ ?(ml|kg|g|L|units|bags)$', '')) AS title,
SUBSTRING(name FROM '[0-9]+ ?(ml|kg|g|L|units|bags)$') AS size,
LENGTH(name) AS name_length
FROM products
WHERE active = TRUE
AND category_id IN (1, 4)
ORDER BY id;| reference | title | size | name_length |
|---|---|---|---|
| GS-00001 | Extra Virgin Olive Oil | 500 ml | 29 |
| GS-00002 | Organic Brown Rice | 1 kg | 23 |
| GS-00003 | Raw Orange Blossom Honey | 500 g | 30 |
| GS-00004 | Spelt Pasta | 500 g | 17 |
| GS-00005 | Organic Crushed Tomato | 400 g | 28 |
| GS-00014 | Organic Chamomile Tea | 20 bags | 29 |
| GS-00015 | Ceremonial Matcha Green Tea | 30 g | 32 |
| GS-00016 | Ginger Kombucha | 750 ml | 22 |
| GS-00017 | Cold-Pressed Orange Juice | 1 L | 29 |
9 products: 5 from Food and 4 from Drinks. The ::TEXT is compulsory because LPAD expects text and id is an integer (conversions: 06-04). And there you can see section 2's INITCAP defect: Cold-Pressed Orange Juice, where the part after the hyphen has been capitalised because INITCAP treats the hyphen as a word boundary. Fixing it requires CASE (06-05) or a targeted REPLACE.
Solution 2
SELECT CONCAT_WS(' ', name, last_name) AS customer,
LEFT(name, 1) || '.'
|| LEFT(SPLIT_PART(last_name, ' ', 1), 1) || '.'
|| LEFT(SPLIT_PART(last_name, ' ', 2), 1) || '.' AS initials,
LEFT(SPLIT_PART(email, '@', 1), 2)
|| REPEAT('*', LENGTH(SPLIT_PART(email, '@', 1)) - 2)
|| '@' || SPLIT_PART(email, '@', 2) AS contact,
SPLIT_PART(email, '.', 3) AS domain_tld
FROM customers
WHERE id IN (1, 9, 10)
ORDER BY id;| customer | initials | contact | domain_tld |
|---|---|---|---|
| Lucía Martínez Soler | L.M.S. | lu************@example.com | com |
| Camille Dubois | C.D.. | ca************@example.fr | fr |
| Julien Moreau | J.M.. | ju***********@example.fr | fr |
The two French customers have a single surname, and SPLIT_PART(last_name, ' ', 2) returns the empty string: hence the C.D.. with two full stops in a row. SPLIT_PART doesn't fail and doesn't return NULL, it returns '', and the || concatenates it without complaining. Fixing it means asking "is there a second surname?", that is, NULLIF (06-04) or CASE (06-05).
The count —GROUP BY SPLIT_PART(email, '.', 3) with COUNT(*)— gives 11 com customers, 2 fr and 2 pt, the same 15 split the same way as in section 7.2.
Solution 3
1. Run over customers 1, 9 and 10 it gives Martínez (with a trailing space) for the first and the empty string for the other two.
- Problem A:
POSITIONreturns the position of the space, soLEFTtakes it too. There's a- 1missing. - Problem B: when there's no space,
POSITIONreturns0andLEFT(s, 0)is the empty string: customers 9 and 10, with a single surname, lose the surname entirely. With the- 1it would be even worse, becauseLEFT(s, -1)returns everything except the last character:Duboi.
2 and 3. With POSITION you have to guarantee there's always a space; with SPLIT_PART you need nothing at all:
-- ✅ CORRECT, but it needs a trick that has to be commented
SELECT id, last_name,
LEFT(last_name || ' ', POSITION(' ' IN last_name || ' ') - 1) AS first_surname
FROM customers WHERE id IN (1, 9, 10) ORDER BY id;
-- ✅ CORRECT and readable
SELECT id, last_name, SPLIT_PART(last_name, ' ', 1) AS first_surname
FROM customers WHERE id IN (1, 9, 10) ORDER BY id;Both return the same thing:
| id | last_name | first_surname |
|---|---|---|
| 1 | Martínez Soler | Martínez |
| 9 | Dubois | Dubois |
| 10 | Moreau | Moreau |
The second has neither of the two problems because SPLIT_PART doesn't work with positions: it splits by the separator and returns the piece you asked for. If there's no separator, piece 1 is the whole string. No - 1 to forget, no special case to handle. That's the moral: when a function exists that expresses your intent, use it instead of rebuilding it out of position arithmetic.
Conclusion
You now know how to transform text:
- You can tell a scalar function from an aggregate one: the first transforms row by row, the second summarises many rows into one. The whole of module 6 is scalar.
- You measure with
LENGTH(characters) andOCTET_LENGTH(bytes), you change case withUPPER,LOWERandINITCAPand you normalise withINITCAP(LOWER(TRIM(x))). - You clean with
TRIMand its variants, you pad withLPAD/RPAD(which also trim), you extract withSUBSTRING/LEFT/RIGHTremembering that positions start at 1, you locate withPOSITION/STRPOS(which return0when they find nothing) and you split withSPLIT_PART, the function that removes all the position arithmetic. - You compose with
CONCAT_WS, which solves 02-02'sNULLtrap —except when every argument is null, a case that waits forCOALESCEin 06-04— and withFORMATfor templates. - You extract and replace with
REGEXP_REPLACEandSUBSTRING(… FROM pattern), different from 04-01'sLIKEand~, which only decided whether something matched. And you know that a function over a column in theWHEREhas a cost that gets measured in module 8.
In the next lesson, numeric functions, the same idea applied to numbers: ROUND with its siblings TRUNC, CEIL and FLOOR, arithmetic with MOD, POWER and ABS, and two traps that cost real money. The first: 10 / 3 isn't 3.33, it's 3, and there are three different ways of fixing it. The second, more serious: ROUND(2.5) and ROUND(2.5::DOUBLE PRECISION) don't return the same thing in PostgreSQL, and that one-cent difference, multiplied by a million invoice lines, is the reason 01-04 said money is never stored in floating point. Let's prove it.
SQL Course
Module 1: Introduction to SQL
- What is SQL?
- Setting up your SQL environment
- Basic SQL syntax
- Understanding databases and tables
- The relational model: primary and foreign keys
- The course database: GreenStore
Module 2: Basic SQL queries
- The SELECT statement
- Aliases, expressions and calculated columns
- Filtering data with WHERE
- DISTINCT and removing duplicates
- Sorting data with ORDER BY
- Limiting results with LIMIT
Module 3: Working with multiple tables
- JOIN operations
- INNER JOIN
- LEFT JOIN
- RIGHT JOIN
- FULL OUTER JOIN
- SELF JOIN and CROSS JOIN
- Set operations: UNION, INTERSECT and EXCEPT
Module 4: Advanced data filtering
- Using LIKE for pattern matching
- The IN and BETWEEN operators
- NULL values and IS NULL
- Aggregate functions: COUNT, SUM, AVG, MIN and MAX
- Aggregating data with GROUP BY
- The HAVING clause
Module 5: Data manipulation
- Creating tables and constraints with CREATE TABLE
- The INSERT statement
- The UPDATE statement
- The DELETE statement
- The UPSERT (MERGE) statement
- Changing the schema: ALTER TABLE and safe migrations
Module 6: Advanced SQL functions
- String functions
- Numeric functions
- Date and time functions
- Type conversion and handling NULL: CAST and COALESCE
- Conditional expressions
Module 7: Subqueries and nested queries
- Introduction to subqueries
- Correlated subqueries
- EXISTS and NOT EXISTS
- Using subqueries in SELECT, FROM and WHERE
- Subquery or JOIN: which one to choose
Module 8: Indexes and performance tuning
- Understanding indexes
- Creating and managing indexes
- Index types and when not to index
- Query optimization techniques
- Analyzing query performance
Module 9: Transactions and concurrency
- Introduction to transactions
- ACID properties
- Transaction control statements
- Isolation levels and concurrency anomalies
- Handling concurrency: locks and deadlocks
Module 10: Advanced topics
- Views
- Common table expressions (CTEs)
- Window functions
- Stored procedures
- Triggers
- JSON and semi-structured data
Module 11: SQL in practice
- Real-world use cases
- Best practices
- Security: SQL injection, permissions and roles
- SQL for data analysis
- SQL in web development
