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
- Implicit against explicit conversion
CASTand the::operator- The usual conversions and their traps
to_number,to_char,to_date: controlled conversionCOALESCE: the first non-null valueNULLIFand its two canonical usesCOALESCEagainstCASECOALESCEin aggregates: 04-04's debt- Comparison table by engine
- Common Mistakes and Tips
- Exercises
- Conclusion
- Implicit against explicit conversion
An implicit conversion is one the engine performs by itself, without being asked. An explicit one is one you write.
| 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.
CAST and the :: operator
CAST and the :: operatorThere 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.
- 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
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 |
NUMERIC → INTEGER 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 formatYYYY-MM-DDat 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).
to_number, to_char, to_date: controlled conversion
to_number, to_char, to_date: controlled conversionWhen 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.
COALESCE: the first non-null value
COALESCE: the first non-null valueCOALESCE(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:
COALESCEis 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 a0inside a calculation changes the result, and that's section 8.
NULLIF and its two canonical uses
NULLIF and its two canonical usesNULLIF(a, b) returns NULL if a = b, and a otherwise. It's exactly the opposite of COALESCE: instead of removing nulls, it manufactures them.
| 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), '').
COALESCE against CASE
COALESCE against CASECOALESCE is syntactic sugar over a CASE. These two expressions are exactly equivalent:
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.
COALESCE in aggregates: 04-04's debt
COALESCE in aggregates: 04-04's debtHere 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
COALESCEoutside 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.
- 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, CONVERT → 0 + warning |
CAST → 0 silently |
CAST, CONVERT → ERROR |
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'isfalse. Convert it yourself and what you mean will be written down. - Expecting
NUMERIC::INTEGERto truncate. It rounds:12.9::INTEGERis13. 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'::DATEdecide for you. It depends onDateStyle. UseTO_DATEwith a pattern, or ISO at the source. - Believing
COALESCEfixes empty strings.''isn't null: the correct idiom isCOALESCE(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.SUMover zero rows returnsNULL, not0, 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
CASTto a column in theWHERE. 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 scatterREPLACEacross fifty reports. - Tip:
COALESCEto present, never to compute; and if you do compute with it, write a comment saying why the zero is legitimate. - Tip: use
CASTinstead 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'beingfalse— from explicit conversion, which you write and which stays documented. You convert withCAST(expr AS type)(standard) orexpr::type(PostgreSQL), knowing that::is applied before the arithmetic. - You know the traps: non-numeric text produces an
ERROR(and that's good),NUMERIC::INTEGERrounds instead of truncating,'03/04/2025'depends onDateStyleand the conversion toVARCHAR(n)truncates silently. And you control the format withTO_NUMBER,TO_CHARandTO_DATEwhen the standard isn't enough. - You replace nulls with
COALESCE, which is lazy, takes several arguments and demands compatible types; and you manufacture nulls withNULLIF, 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||andCONCAT_WSproblem you'd been carrying since 02-02. And you knowCOALESCEis equivalent to aCASE, and when to use each. - And above all, you know that
COALESCEinside an aggregate changes the result: 4.08 became 2.13 by counting as zeros eleven products nobody had rated.COALESCEoutside 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
- 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
