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

  1. Scalar against aggregate: one row in, one row out
  2. Measuring and changing case: LENGTH, UPPER, LOWER, INITCAP
  3. Cleaning and padding: TRIM, LPAD, RPAD
  4. Extracting, locating and splitting
  5. Composing text: CONCAT_WS, FORMAT and 02-02's ||
  6. Extraction with regular expressions
  7. Real GreenStore cases
  8. Functions over columns and indexes
  9. Comparison table by engine
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. 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.

SELECT id,
       name,
       LENGTH(name) AS name_length
FROM products
WHERE category_id = 4
ORDER BY id;
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.

  1. 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.

  1. 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 characters a or b. And LPAD guarantees an exact length: if the string is longer it trims it (LPAD('Valencia', 5, '.')'Valen').

  1. 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;
email 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

  1. Composing text: 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_WS solves 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 need COALESCE, 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.

  1. 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 the SELECT, the rows with no match disappear (the 20 products would come out as 16). For one value per row use SUBSTRING(… FROM pattern) or, from PostgreSQL 15 onwards, REGEXP_SUBSTR(s, pattern).

  1. 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:

SELECT INITCAP(LOWER(TRIM('  vaLENcia  '))) AS normalised_city;
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 email 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.

  1. 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.

  1. 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 LPAD guarantees an exact length, not a minimum one: if the string is longer, it trims it.
  • Using || with nullable columns. A single NULL nulls out the whole expression. CONCAT_WS to join fields, COALESCE (06-04) to put in default text.
  • Believing CONCAT_WS solves every null. With all the arguments null it returns '', which in a report reads as an empty cell. And REGEXP_MATCHES in the SELECT makes the rows with no match disappear: use SUBSTRING(… FROM pattern).
  • Getting a regex alternation's order wrong. (g|kg) matches the g of kg. The most specific goes first.
  • Confusing TRIM(BOTH 'ab' FROM s) with removing the string 'ab'. It removes the individual characters a and b. And careful: in PostgreSQL LENGTH counts 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_PART to STRPOS + SUBSTRING when the separator is fixed: it reads better and it doesn't have the - 1 everybody forgets. And use FORMAT for 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: POSITION returns the position of the space, so LEFT takes it too. There's a - 1 missing.
  • Problem B: when there's no space, POSITION returns 0 and LEFT(s, 0) is the empty string: customers 9 and 10, with a single surname, lose the surname entirely. With the - 1 it would be even worse, because LEFT(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) and OCTET_LENGTH (bytes), you change case with UPPER, LOWER and INITCAP and you normalise with INITCAP(LOWER(TRIM(x))).
  • You clean with TRIM and its variants, you pad with LPAD/RPAD (which also trim), you extract with SUBSTRING/LEFT/RIGHT remembering that positions start at 1, you locate with POSITION/STRPOS (which return 0 when they find nothing) and you split with SPLIT_PART, the function that removes all the position arithmetic.
  • You compose with CONCAT_WS, which solves 02-02's NULL trap —except when every argument is null, a case that waits for COALESCE in 06-04— and with FORMAT for templates.
  • You extract and replace with REGEXP_REPLACE and SUBSTRING(… FROM pattern), different from 04-01's LIKE and ~, which only decided whether something matched. And you know that a function over a column in the WHERE has 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

Module 2: Basic SQL queries

Module 3: Working with multiple tables

Module 4: Advanced data filtering

Module 5: Data manipulation

Module 6: Advanced SQL functions

Module 7: Subqueries and nested queries

Module 8: Indexes and performance tuning

Module 9: Transactions and concurrency

Module 10: Advanced topics

Module 11: SQL in practice

Module 12: Final project

© Copyright 2026. All rights reserved