You've spent three lessons writing ::TEXT, ::NUMERIC and ::DATE without anybody explaining what they are. And you've been carrying a promise for two modules: 04-03 closed the subject of nulls by saying that COALESCE and NULLIF —the tools that replace a null with something presentable— would be explained here. The two topics go together because they're the same thing seen from two angles: what to do when a value doesn't have the shape you need. Sometimes it has the wrong type and has to be converted; sometimes there's no value at all and it has to be substituted. They're the glue holding everything before them together: without them, string functions break on a null, numeric ones fail dividing by zero and date ones refuse to read a piece of text.

Contents

  1. Implicit against explicit conversion
  2. CAST and the :: operator
  3. The usual conversions and their traps
  4. to_number, to_char, to_date: controlled conversion
  5. COALESCE: the first non-null value
  6. NULLIF and its two canonical uses
  7. COALESCE against CASE
  8. COALESCE in aggregates: 04-04's debt
  9. Comparison table by engine
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. Implicit against explicit conversion

An implicit conversion is one the engine performs by itself, without being asked. An explicit one is one you write.

SELECT 1 + '2' AS implicit_sum, 10 > '9' AS number_vs_literal,
       '10' > '9' AS literal_vs_literal;
implicit_sum number_vs_literal literal_vs_literal
3 true false

All three columns use the same '9' or '2' and they behave in three different ways. 1 + '2' gives 3 because PostgreSQL sees an integer on the left and resolves the literal as an integer; 10 > '9' gives true by the same mechanism; and '10' > '9' gives false, because there's no type there to give the hint, both literals get resolved as text and in alphabetical order '10' comes before '9'.

That last one is the example of why implicit conversion is dangerous: it doesn't fail, it returns something else. A WHERE code > '9' over a text column containing numbers filters the opposite way from what you think, and nobody warns you.

Implicit conversion Explicit conversion
Who decides The engine, depending on the context You
Visible when reading the code No Yes
Portable between engines No: each has its own rules Yes

The rule: when two types get mixed, convert them yourself. The engine knowing how to guess doesn't mean it guesses what you want, and it certainly doesn't mean the next engine will guess the same way.

  1. CAST and the :: operator

There are two syntaxes for the same thing:

SELECT CAST('123' AS INTEGER) AS standard_form,  '123'::INTEGER      AS postgresql_form,
       CAST(12.9 AS INTEGER)  AS numeric_to_int, CAST(42 AS TEXT)    AS number_to_text;
standard_form postgresql_form numeric_to_int number_to_text
123 123 13 42

CAST(expr AS type) is standard SQL and works on every engine; expr::type is PostgreSQL's shorthand, shorter and handy when chaining but not portable. The practical advice: :: in analysis queries, CAST in code that might end up on another engine. And watch the precedence: :: is applied before the arithmetic operators, so a + b::NUMERIC converts only b; to convert the sum, (a + b)::NUMERIC.

Notice the third column already: CAST(12.9 AS INTEGER) returns 13, not 12. That's the first trap.

  1. The usual conversions and their traps

Conversion Example Result Trap
Text → integer '123'::INTEGER 123 Fails on any non-numeric character
Invalid text → integer 'abc'::INTEGER ERROR See below
Text with a comma → numeric '12,50'::NUMERIC ERROR The decimal separator has to be .
Numeric → integer 12.9::INTEGER / 12.4::INTEGER 13 / 12 It rounds, it doesn't truncate
Number → text, ISO text → date 42::TEXT, '2025-03-04'::DATE '42', 2025-03-04 No problem
Ambiguous text → date '03/04/2025'::DATE Depends on DateStyle See below
Text → VARCHAR(n) 'Extra virgin olive oil'::VARCHAR(5) 'Extra' Truncates silently

The conversion that fails

SELECT 'abc'::INTEGER;
ERROR:  invalid input syntax for type integer: "abc"
LINE 1: SELECT 'abc'::INTEGER;
               ^

And this is good news: the error stops the query and forces you to look at the data. Compare it with SQLite, which for the same expression returns 0 without saying anything, so a SUM over that column would give a plausible and wrong total. PostgreSQL being strict is a feature, not a nuisance.

The real case shows up when importing a text column with '12,50' in continental European format. '12,50'::NUMERIC fails, and the solution is to normalise beforehand with 06-01's functions:

SELECT REPLACE('12,50', ',', '.')::NUMERIC                        AS amount,
       REPLACE(REPLACE('1.234,56', '.', ''), ',', '.')::NUMERIC   AS amount_with_thousands;
amount amount_with_thousands
12.50 1234.56

NUMERICINTEGER rounds

12.4::INTEGER is 12, but 12.5::INTEGER and 12.9::INTEGER are both 13. It's consistent with 06-02 —NUMERIC rounds half-up— and it contradicts the intuition of anybody coming from a programming language, where converting to an integer truncates. If you want to truncate, write TRUNC(12.9)::INTEGER, which does give 12.

The ambiguous date

'03/04/2025'::DATE has no single answer: it depends on the session parameter DateStyle, which defaults to ISO, MDY (ISO output, month-day-year input) and is inspected with SHOW DateStyle;. With that value, '03/04/2025'::DATE is 4 March; with SET DateStyle = 'ISO, DMY'; it becomes 3 April. The same string, two dates, depending on a session variable almost nobody looks at.

The rule, already seen in 06-03: for ambiguous dates always use TO_DATE(text, pattern) with an explicit pattern, or demand the ISO format YYYY-MM-DD at the source. Never let the interpretation depend on the server's configuration.

Silent truncation

SELECT 'Extra virgin olive oil'::VARCHAR(5); returns Extra. No error and no warning. And here's the inconsistency worth knowing about: if you try to insert that same string into a VARCHAR(5) column, PostgreSQL does fail with value too long for type character varying(5). The explicit conversion truncates; the insertion doesn't. It's the only PostgreSQL conversion that loses data silently. Finally, the usual warning: a WHERE order_date::TEXT LIKE '2025%' wraps the column in a conversion and can't use the index, just like 06-01's functions (08-03).

  1. to_number, to_char, to_date: controlled conversion

When the format isn't the standard one, the to_* functions take a pattern and do the conversion by your rules, not the engine's.

Function What it does Example Result
TO_CHAR(value, pattern) Number or date → formatted text TO_CHAR(1234.5, 'FM999G999D00') 1,234.50 (with English lc_numeric)
TO_NUMBER(text, pattern) Text → NUMERIC TO_NUMBER('12500', '99999') 12500
TO_DATE(text, pattern) Text → DATE TO_DATE('04/03/2025', 'DD/MM/YYYY') 2025-03-04
TO_TIMESTAMP(text, pattern) Text → TIMESTAMPTZ TO_TIMESTAMP('04/03/2025 18:30', 'DD/MM/YYYY HH24:MI') 2025-03-04 18:30:00+01

The G (thousands) and D (decimal) symbols in numeric patterns depend on lc_numeric: on an English server they produce 1,234.50 and on a Spanish one, 1.234,50. If you need a result that's independent of the server, use the literal , and . in the pattern or normalise with REPLACE. TO_CHAR over numbers completes the pair with TO_CHAR over dates from 06-03: the same function, different patterns.

  1. COALESCE: the first non-null value

COALESCE(a, b, c, …) returns the first of its arguments that isn't null, and NULL only if they all are. It takes any number of arguments.

SELECT COALESCE(NULL, NULL, 'third', 'fourth') AS first_non_null,
       COALESCE(NULL, NULL, NULL) AS all_null, COALESCE(1, 1/0) AS lazy;
first_non_null all_null lazy
third (null) 1

The third column deserves attention: 1/0 would raise a division-by-zero error… if it were ever evaluated. COALESCE is lazy: as soon as it finds a non-null argument it stops looking at the rest, which lets you put expensive or dangerous calculations as a last resort. And one requirement that surprises people: all the arguments have to be of compatible types, so COALESCE(employee_id, 'Web sale') fails because employee_id is an integer.

The presentation pattern

This is what 04-03 left outstanding: the ten web orders with employee_id IS NULL.

SELECT o.id, o.order_date, o.status,
       CONCAT_WS(' ', e.name, e.last_name)                        AS sales_rep_raw,
       COALESCE(CONCAT_WS(' ', e.name, e.last_name), 'Web sale')  AS channel_wrong,
       COALESCE(NULLIF(CONCAT_WS(' ', e.name, e.last_name), ''),
                'Web sale')                                       AS channel_right
FROM orders         AS o
LEFT JOIN employees AS e ON e.id = o.employee_id
WHERE o.id <= 4 ORDER BY o.id;
id order_date status sales_rep_raw channel_wrong channel_right
1 2025-03-04 delivered Web sale
2 2025-03-12 delivered Óscar Peris Blasco Óscar Peris Blasco Óscar Peris Blasco
3 2025-04-02 delivered Web sale
4 2025-04-19 delivered Laia Puig Sanchis Laia Puig Sanchis Laia Puig Sanchis

Here 06-01's circle closes. CONCAT_WS ignores nulls, so for the web orders it returns the empty string and not NULL; and COALESCE doesn't replace it, because '' isn't null, so channel_wrong comes out empty. The combination COALESCE(NULLIF(x, ''), 'Web sale') is the correct form and the idiom to memorise: NULLIF turns the empty string into a null and then COALESCE can do its job. If you concatenate with || instead of CONCAT_WS, the null propagates and COALESCE(e.name || ' ' || e.last_name, 'Web sale') works directly; both routes are valid, what isn't valid is mixing them without noticing.

The same pattern with the seven customers with no referrer (04-03), this time with ||:

SELECT c.id, CONCAT_WS(' ', c.name, c.last_name) AS customer,
       COALESCE(ref.name || ' ' || ref.last_name, 'Direct signup') AS source
FROM customers AS c
LEFT JOIN customers AS ref ON ref.id = c.referred_by_id
WHERE c.id <= 5 ORDER BY c.id;
id customer source
1 Lucía Martínez Soler Direct signup
2 Carlos Ferrer Ibáñez Lucía Martínez Soler
3 Marta Sanchis Gil Lucía Martínez Soler
4 Javier Ortega Ruiz Direct signup
5 Ana Belmonte Roca Carlos Ferrer Ibáñez

Important: COALESCE is a presentation tool, not an analysis one. Replacing a null with a piece of text is fine for a report a person reads; replacing it with a 0 inside a calculation changes the result, and that's section 8.

  1. NULLIF and its two canonical uses

NULLIF(a, b) returns NULL if a = b, and a otherwise. It's exactly the opposite of COALESCE: instead of removing nulls, it manufactures them.

SELECT NULLIF(5, 5) AS equal_, NULLIF(5, 3) AS different, NULLIF('', '') AS empty_string;
equal_ different empty_string
(null) 5 (null)

It looks useless until you see what it's for. It has two canonical cases and practically no others.

Use 1: avoiding division by zero

Remember 04-03's warning: you can't protect yourself with AND stock <> 0, because the planner reorders the conditions. NULLIF does protect you, because it acts inside the expression:

SELECT p.id, p.name, p.stock,
       SUM(ol.quantity)                                        AS sold,
       ROUND(SUM(ol.quantity) * 100.0 / NULLIF(p.stock, 0), 2) AS turnover_pct
FROM products          AS p
LEFT JOIN order_lines  AS ol ON ol.product_id = p.id
WHERE p.id IN (2, 13, 15, 18)
GROUP BY p.id, p.name, p.stock ORDER BY p.id;
id name stock sold turnover_pct
2 Organic brown rice 1 kg 200 14 7.00
13 Soy wax candles (pack of 2) 0 (null) (null)
15 Ceremonial matcha green tea 30 g 40 4 10.00
18 Bamboo toothbrush 240 9 3.75

Product 13 has stock 0: without NULLIF, that row would raise ERROR: division by zero and the whole query would fail. With NULLIF(p.stock, 0) the denominator becomes null, the division returns NULL and the report honestly says "can't be computed", which is the truth: the turnover of a product with no stock isn't zero, it's undefined.

Use 2: treating the empty string as null

It's the second-surname problem left open in 06-01:

SELECT id, last_name,
       SPLIT_PART(last_name, ' ', 2)                            AS second_raw,
       NULLIF(SPLIT_PART(last_name, ' ', 2), '')                AS second_clean,
       COALESCE(NULLIF(SPLIT_PART(last_name, ' ', 2), ''), '—') AS second_display
FROM customers WHERE id IN (1, 9, 10) ORDER BY id;
id last_name second_raw second_clean second_display
1 Martínez Soler Soler Soler Soler
9 Dubois (null)
10 Moreau (null)

Many legacy systems store '' where they should store NULL —web forms with empty fields, CSV imports—. NULLIF(column, '') is the standard cleanup; in a sanitising UPDATE it would be SET city = NULLIF(TRIM(city), '').

  1. COALESCE against CASE

COALESCE is syntactic sugar over a CASE. These two expressions are exactly equivalent:

COALESCE(a, b)

CASE WHEN a IS NOT NULL THEN a ELSE b END
COALESCE CASE
Readability Very high for "replace the null" Verbose for that case
Condition Only "is it null?" Any condition
When to use it Replacing nulls Classifying, comparing ranges, deciding by value

The rule is simple: if the question is "is it null?", COALESCE; if it's anything else, CASE — the next lesson. Writing CASE WHEN x IS NULL THEN 0 ELSE x END isn't wrong, but it's four times longer than COALESCE(x, 0) and it hides the intent.

  1. COALESCE in aggregates: 04-04's debt

Here COALESCE stops being cosmetic and changes the result. In 04-04 you learned that aggregate functions ignore nulls; let's see what happens if you hand them zeros instead. The average rating of the products, joining the catalogue's 20 rows with the 12 reviews:

SELECT COUNT(*)                            AS rows_,
       COUNT(r.rating)                     AS with_review,
       SUM(r.rating)                       AS total_,
       ROUND(AVG(r.rating), 4)             AS avg_ignoring_nulls,
       ROUND(AVG(COALESCE(r.rating, 0)), 4) AS avg_counting_zeros
FROM products     AS p
LEFT JOIN reviews AS r ON r.product_id = p.id;
rows_ with_review total_ avg_ignoring_nulls avg_counting_zeros
23 12 49 4.0833 2.1304

4.08 against 2.13. The same data, two answers that look nothing alike. AVG(r.rating) divides 49 by 12: the average of the reviews that exist, the answer to "what do the customers who have given an opinion think?". AVG(COALESCE(r.rating, 0)) divides 49 by 23, counting the 11 products with no review as if they'd been given a zero.

The first is almost always the correct one, because "nobody has given an opinion" isn't "everybody gave a bad one". The second is exactly the error 04-03 described when talking about sentinel values: inventing data so you don't have to handle its absence.

The same effect with salaries, joining each order with its sales rep:

Expression Divides by Result What it means
AVG(e.salary) 10 (the non-nulls) €27,420.00 The average salary of whoever handles orders with a sales rep
AVG(COALESCE(e.salary, 0)) 20 (every row) €13,710.00 As if the web orders were handled by somebody paid €0

The rule: use COALESCE outside the aggregate to present (COALESCE(SUM(x), 0)) and inside only when the zero is a real value, not a gap. The question to ask is: "does that gap mean zero, or does it mean there's no data?".

COALESCE outside the aggregate: the empty set

The exact opposite case. SUM over zero rows doesn't return 0, it returns NULL:

SELECT cat.id, cat.name,
       ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue,
       COALESCE(ROUND(SUM(ol.quantity * ol.unit_price
                          * (1 - ol.discount)), 2), 0)                AS revenue_display
FROM categories        AS cat
LEFT JOIN products     AS p  ON p.category_id = cat.id
LEFT JOIN order_lines  AS ol ON ol.product_id = p.id
GROUP BY cat.id, cat.name ORDER BY cat.id;
id name revenue revenue_display
1 Food 256.27 256.27
2 Natural cosmetics 156.32 156.32
3 Sustainable home 88.58 88.58
4 Drinks 195.28 195.28
5 Personal hygiene 31.50 31.50
6 Supplements (null) 0.00

The first five add up to €727.95, the canonical figure. The Supplements category only contains product 20, discontinued and never sold: SUM over an empty set gives NULL. And here COALESCE(…, 0) is correct, because "nothing has been sold" is exactly zero euros. The difference from the reviews case is one of meaning, not of syntax.

  1. Comparison table by engine

Task PostgreSQL 16 MySQL 8 SQLite SQL Server Oracle
First non-null (n arguments) COALESCE COALESCE COALESCE COALESCE COALESCE
Two-argument version COALESCE(a,b) IFNULL(a,b) IFNULL(a,b) ISNULL(a,b) NVL(a,b)
"If not null X, if null Y" CASE IF(a IS NULL, y, x) IIF(...) IIF(...) NVL2(a, x, y)
Turn into null if equal NULLIF NULLIF NULLIF NULLIF NULLIF
Conversion / the failing one CAST, ::ERROR CAST, CONVERT0 + warning CAST0 silently CAST, CONVERTERROR CAST, TO_*ERROR
Tolerant conversion — (there's no TRY_CAST) TRY_CAST, TRY_CONVERT CAST(… DEFAULT … ON CONVERSION ERROR)
'' against NULL They're different They're different They're different They're different '' IS NULL

Three warnings. SQL Server's ISNULL isn't IS NULL: they're different things and they're written almost the same. In Oracle, '' is NULL, so NULLIF's use 2 is redundant there… and all the code that depends on telling them apart stops working when you migrate. And PostgreSQL has no TRY_CAST: for a tolerant conversion you have to validate beforehand with a regular expression (text ~ '^[0-9]+$', from 04-01) or write a function of your own (module 10).

Common Mistakes and Tips

  • Trusting implicit conversion. '10' > '9' is false. Convert it yourself and what you mean will be written down.
  • Expecting NUMERIC::INTEGER to truncate. It rounds: 12.9::INTEGER is 13. To truncate, TRUNC.
  • Converting to VARCHAR(n) without counting the characters. It's the only PostgreSQL conversion that loses data silently.
  • Letting '03/04/2025'::DATE decide for you. It depends on DateStyle. Use TO_DATE with a pattern, or ISO at the source.
  • Believing COALESCE fixes empty strings. '' isn't null: the correct idiom is COALESCE(NULLIF(x, ''), 'value'). And every argument has to be of a compatible type.
  • Putting COALESCE(col, 0) inside an aggregate without thinking. It changes the denominator: 4.08 became 2.13. Only if the zero is a real value.
  • Forgetting COALESCE(SUM(x), 0) in reports. SUM over zero rows returns NULL, not 0, and the cell comes out empty.
  • Protecting yourself from division by zero with an AND. The planner reorders. NULLIF(divisor, 0) does protect you.
  • Applying a CAST to a column in the WHERE. Just like a function: it prevents the index from being used (08-03).
  • Tip: normalise on the way in, not in every query. If the CSV brings '12,50', fix it on loading; don't scatter REPLACE across fifty reports.
  • Tip: COALESCE to present, never to compute; and if you do compute with it, write a comment saying why the zero is legitimate.
  • Tip: use CAST instead of :: in code you're going to publish or port. It costs five more characters and it works everywhere.

Exercises

Exercise 1

Prepare the order listing for management: id, date, status, customer name and a handled_by column with the sales rep's full name or the text 'Web channel' when there isn't one. Add a method column with the payment method in upper case, show orders 15 to 20 and check that no cell is left empty.

Exercise 2

Over products, compute the defensive percentage margin(price - cost) / price * 100 to two decimals— so that the query never fails even if one day price is 0 or cost is NULL. Show the cost with a COALESCE to 0.00 and explain why that particular substitution is debatable.

Exercise 3

A colleague presents this satisfaction report and concludes that "the catalogue's average rating is 2.13 out of 5, a disaster":

SELECT ROUND(AVG(COALESCE(r.rating, 0)), 2) AS avg_rating
FROM products     AS p
LEFT JOIN reviews AS r ON r.product_id = p.id;

(1) Where does the 2.13 come from and why is it misleading? (2) Write the correct query and give the figure. (3) Write a third one giving both useful metrics at once: the average of the rated products and how many products have no review at all.

Solutions

Solution 1

SELECT o.id, o.order_date, o.status,
       CONCAT_WS(' ', c.name, c.last_name)                                      AS customer,
       COALESCE(NULLIF(CONCAT_WS(' ', e.name, e.last_name), ''), 'Web channel') AS handled_by,
       UPPER(o.payment_method)                                                  AS method
FROM orders         AS o
JOIN customers      AS c ON c.id = o.customer_id
LEFT JOIN employees AS e ON e.id = o.employee_id
WHERE o.id BETWEEN 15 AND 20 ORDER BY o.id;
id order_date status customer handled_by method
15 2025-12-02 delivered Lucía Martínez Soler Web channel CARD
16 2025-12-19 shipped Javier Ortega Ruiz Óscar Peris Blasco CARD
17 2026-01-13 shipped Sofia Moreira Costa Web channel PAYPAL
18 2026-01-27 paid Ana Belmonte Roca Laia Puig Sanchis TRANSFER
19 2026-02-09 paid Pau Llorens Vidal Web channel CARD
20 2026-02-21 pending Camille Dubois Marc Estévez Roig CASH_ON_DELIVERY

The JOIN with customers can be an inner one because customer_id is NOT NULL; the one with employees has to be a LEFT JOIN, or you'd lose half the orders (03-03). And the NULLIF is essential: without it, the three web rows would show an empty cell instead of 'Web channel'.

Solution 2

SELECT id, name, price,
       COALESCE(cost, 0.00)                                              AS cost_shown,
       ROUND((price - COALESCE(cost, 0)) * 100.0 / NULLIF(price, 0), 2)  AS margin_pct
FROM products WHERE id IN (1, 5, 15, 18) ORDER BY id;
id name price cost_shown margin_pct
1 Extra virgin olive oil 500 ml 12.50 7.80 37.60
5 Organic crushed tomato 400 g 1.95 0.90 53.85
15 Ceremonial matcha green tea 30 g 22.00 12.50 43.18
18 Bamboo toothbrush 3.50 1.20 65.71

NULLIF(price, 0) shields the division and 100.0 avoids 06-02's integer division. But the COALESCE(cost, 0) is debatable, very much so: an unknown cost isn't a cost of zero euros. With that substitution, a product whose supplier hasn't yet communicated the purchase price would appear with a 100 % margin, the most optimistic possible figure and the most false. The honest thing is to let the margin come out NULL(price - cost) already does that on its own— and have the report show "no data".

Solution 3

1. The LEFT JOIN produces 23 rows: the 12 reviews plus one manufactured row for each of the 11 products with no review. COALESCE(r.rating, 0) turns those 11 absences into eleven zeros, so the average is 49 / 23 = 2.13. It's misleading because a product with no reviews hasn't received a zero: it hasn't received anything. The report measures the lack of reviews, not satisfaction.

2. The correct query is the one that lets AVG do what it's been doing since 04-04 —ignore nulls— and returns 4.08 out of 5, module 4's canonical figure. It isn't a disaster: it's a good rating. 3. And the two metrics separated, each answering its own question:

SELECT ROUND(AVG(r.rating), 4)                            AS avg_rated,
       COUNT(r.id)                                        AS reviews,
       COUNT(DISTINCT r.product_id)                       AS products_rated,
       COUNT(DISTINCT p.id) - COUNT(DISTINCT r.product_id) AS products_without_review
FROM products     AS p
LEFT JOIN reviews AS r ON r.product_id = p.id;
avg_rated reviews products_rated products_without_review
4.0833 12 9 11

Now the report tells the whole truth: the rated products score 4.08 on average, but 11 of the 20 have no review at all. That second number is GreenStore's real problem, and it was hidden inside the 2.13. When a COALESCE blends two questions into one figure, the solution isn't to refine the figure: it's to separate the questions.

Conclusion

You now have the glue:

  • You can tell implicit conversion —which the engine performs without warning and which can return something else, like '10' > '9' being false— from explicit conversion, which you write and which stays documented. You convert with CAST(expr AS type) (standard) or expr::type (PostgreSQL), knowing that :: is applied before the arithmetic.
  • You know the traps: non-numeric text produces an ERROR (and that's good), NUMERIC::INTEGER rounds instead of truncating, '03/04/2025' depends on DateStyle and the conversion to VARCHAR(n) truncates silently. And you control the format with TO_NUMBER, TO_CHAR and TO_DATE when the standard isn't enough.
  • You replace nulls with COALESCE, which is lazy, takes several arguments and demands compatible types; and you manufacture nulls with NULLIF, whose two uses are avoiding division by zero and treating '' as null.
  • You've memorised the idiom COALESCE(NULLIF(x, ''), 'value'), which definitively closes the || and CONCAT_WS problem you'd been carrying since 02-02. And you know COALESCE is equivalent to a CASE, and when to use each.
  • And above all, you know that COALESCE inside an aggregate changes the result: 4.08 became 2.13 by counting as zeros eleven products nobody had rated. COALESCE outside the aggregate presents; inside, it decides.

One last piece is missing. COALESCE only knows how to answer one question —"is it null?"— and all the others are still out of your reach: classifying a product as "budget", "mid" or "premium" according to its price; putting a traffic light on the stock; sorting an order's statuses by their flow order and not alphabetically; or turning the rows of a GROUP BY into the columns of a report. That needs conditional logic inside the query, and it's what the module's last lesson brings: CASE, conditional expressions.

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