String functions were for presenting. Numeric ones are for calculating, and there the margin for error stops being cosmetic: a badly done rounding doesn't just look bad, it costs money. This lesson covers the scalar functions that work with numbers and, above all, the two traps separating a correct report from one that's out by cents: integer division and .5 rounding. You've been using ROUND since module 2 with no explanation; here it's time to really understand it, including the uncomfortable part: in PostgreSQL, ROUND(2.5) and ROUND(2.5::DOUBLE PRECISION) don't return the same thing.
Contents
- Rounding and truncating:
ROUND,TRUNC,CEIL,FLOOR .5rounding:NUMERICagainstDOUBLE PRECISION- Arithmetic:
ABS,SIGN,MOD,POWER,SQRT, logarithms GREATESTandLEASTaren'tMAXandMIN- Integer division and its three fixes
- Precision: GreenStore's lost cent
- Real cases: VAT, margin, discounts and shipping
RANDOMandgenerate_series- Comparison table by engine
- Common Mistakes and Tips
- Exercises
- Conclusion
- Rounding and truncating
| Function | What it does | Example | Result |
|---|---|---|---|
ROUND(x) |
Rounds to the nearest integer | ROUND(12.789) |
13 |
ROUND(x, n) |
Rounds to n decimals |
ROUND(12.789, 2) |
12.79 |
TRUNC(x) |
Cuts towards zero, without rounding | TRUNC(12.789) |
12 |
TRUNC(x, n) |
Cuts to n decimals |
TRUNC(12.789, 2) |
12.78 |
CEIL(x) / CEILING(x) |
The equal or higher integer | CEIL(12.001) |
13 |
FLOOR(x) |
The equal or lower integer | FLOOR(12.999) |
12 |
With negative numbers all four part company, and that's where people get it wrong:
SELECT TRUNC(-12.789) AS trunc, ROUND(-12.789) AS round,
CEIL(-12.789) AS ceil, FLOOR(-12.789) AS floor;| trunc | round | ceil | floor |
|---|---|---|---|
| -12 | -13 | -12 | -13 |
TRUNC cuts towards zero (-12.789 → -12), so it agrees with CEIL on negatives and with FLOOR on positives; FLOOR always goes downwards, towards -∞ (-13); and ROUND goes to the nearest, which here is also -13.
A type trap.
ROUND(x, n)andTRUNC(x, n)only exist forNUMERIC. IfxisDOUBLE PRECISION, PostgreSQL fails withERROR: function round(double precision, integer) does not exist. The fix isROUND(x::NUMERIC, 2), and the fact that the two-argument version doesn't exist for floating point isn't an oversight: it's the engine telling you that rounding aDOUBLE PRECISIONto two decimals doesn't mean what you think it does. Section 6 proves it.
.5 rounding: NUMERIC against DOUBLE PRECISION
.5 rounding: NUMERIC against DOUBLE PRECISIONWhen the value falls exactly on the halfway mark, a direction has to be chosen. And PostgreSQL chooses differently depending on the type:
SELECT ROUND(0.5) AS num_05, ROUND(2.5) AS num_25,
ROUND(0.5::FLOAT8) AS flt_05, ROUND(2.5::FLOAT8) AS flt_25;| num_05 | num_25 | flt_05 | flt_25 |
|---|---|---|---|
| 1 | 3 | 0 | 2 |
Two different answers to the same question. The literals 0.5 and 2.5 are NUMERIC, and NUMERIC rounds half-up (half away from zero). Converting them to floating point, PostgreSQL delegates to the system's rint() function, which rounds half-even: to the nearest even number.
| Strategy | Common name | 0.5 |
1.5 |
2.5 |
3.5 |
-2.5 |
|---|---|---|---|---|---|---|
Half-up (NUMERIC) |
Commercial rounding | 1 |
2 |
3 |
4 |
-3 |
Half-even (DOUBLE PRECISION) |
Banker's rounding | 0 |
2 |
2 |
4 |
-2 |
Neither is "the correct one". Half-even doesn't bias sums —half the ties go up and half go down— and half-up is the one taught at school and the one most accounting regulations demand. The problem isn't choosing badly, it's choosing without knowing: if half your report uses NUMERIC and the other half DOUBLE PRECISION, your totals won't agree with each other and nobody will know why.
The course's rule: money is
NUMERIC(01-04). Always roundNUMERIC, and that way the.5goes up. If you receive aDOUBLE PRECISIONfrom outside, convert it with::NUMERICbefore rounding, not after.
And an important clarification: the famous ROUND(1.005, 2) behaves properly with NUMERIC —it returns 1.01— because 1.005 is stored exactly. In DOUBLE PRECISION that literal is really 1.00499999999999989… and it would round to 1.00. It isn't a ROUND bug: it's that the number you gave it is no longer 1.005.
- Arithmetic:
ABS, SIGN, MOD, POWER, SQRT, logarithms
ABS, SIGN, MOD, POWER, SQRT, logarithms| Function | What it does | Example | Result |
|---|---|---|---|
ABS(x) |
Absolute value | ABS(-12.789) |
12.789 |
SIGN(x) |
-1, 0 or 1 depending on the sign |
SIGN(-12.789) |
-1 |
MOD(a, b), a % b |
Remainder of the integer division | MOD(10, 3) |
1 |
POWER(a, b) |
a raised to b |
POWER(2, 10) |
1024 |
SQRT(x) |
Square root | SQRT(16) |
4 |
EXP(x) / LN(x) |
Exponential / natural logarithm | EXP(0) |
1 |
LOG(x) |
Logarithm in base 10 | LOG(100) |
2 |
LOG(b, x) |
Logarithm in base b |
LOG(2, 8) |
3 |
Two warnings about that table. The results you see are the mathematical values: PostgreSQL computes these functions over NUMERIC with 16 significant digits, so SELECT SQRT(16); actually displays 4.0000000000000000. It isn't an error, it's the working precision; wrap it in ROUND when presenting. And LOG with no base is base 10 in PostgreSQL but the natural logarithm in MySQL: the same SQL gives different results and neither engine warns you.
MOD with negatives deserves a note: in SQL the remainder inherits the dividend's sign, so MOD(-10, 3) is -1, not 2 (it agrees with C and Java, and not with Python). Its practical use is splitting a set into batches:
| id | batch | order_date | status |
|---|---|---|---|
| 4 | 0 | 2025-04-19 | delivered |
| 8 | 0 | 2025-06-28 | delivered |
| 12 | 0 | 2025-10-01 | delivered |
| 16 | 0 | 2025-12-19 | shipped |
| 20 | 0 | 2026-02-21 | pending |
It's the batched backfill pattern 05-06 recommended: four concurrent processes, each with its own remainder, never overlapping.
GREATEST and LEAST aren't MAX and MIN
GREATEST and LEAST aren't MAX and MINThe module's classic confusion, resolved by 06-01's distinction:
GREATEST / LEAST |
MAX / MIN |
|
|---|---|---|
| Family | Scalar | Aggregate |
| It compares | Several columns of the same row | One column across many rows |
| Rows in the result | The same as there were | One per group |
SELECT id, price, cost,
GREATEST(price, cost) AS larger_of_the_two,
LEAST(price, cost) AS smaller_of_the_two
FROM products WHERE id IN (1, 5, 15) ORDER BY id;| id | price | cost | larger_of_the_two | smaller_of_the_two |
|---|---|---|---|---|
| 1 | 12.50 | 7.80 | 12.50 | 7.80 |
| 5 | 1.95 | 0.90 | 1.95 | 0.90 |
| 15 | 22.00 | 12.50 | 22.00 | 12.50 |
Three rows. The same query with MAX and MIN would return one, with the most expensive and the cheapest price in the whole catalogue (22.00 and 1.95).
Their most frequent use is clamping a value: GREATEST(stock - 5, 0) never goes below zero and LEAST(discount, 0.30) never exceeds 30 %. A CASE would do the same in four more lines (06-05).
Dialect note: faced with a null,
GREATEST(1, NULL)returns1in PostgreSQL (it ignores nulls) andNULLin MySQL and Oracle. It's a real and dangerous difference when porting.
- Integer division and its three fixes
| division | remainder |
|---|---|
| 3 | 1 |
3, not 3.33. If both operands are integers, SQL performs integer division and discards the decimal part: it doesn't round, it truncates. It isn't a PostgreSQL whim but the standard —the result's type is derived from the operands', and INTEGER / INTEGER is INTEGER—. The three ways of avoiding it:
SELECT 10.0 / 3 AS with_decimal_literal,
10::NUMERIC / 3 AS with_cast,
ROUND(10.0 / 3, 4) AS rounded;| with_decimal_literal | with_cast | rounded |
|---|---|---|
| 3.3333333333333333 | 3.3333333333333333 | 3.3333 |
(1) One operand with decimals: 10.0 / 3 — the literal 10.0 is NUMERIC and it drags the other one along. (2) An explicit conversion: 10::NUMERIC / 3 or CAST(10 AS NUMERIC) / 3, the only one that works when both operands are integer columns, where you can't "type in a decimal point". (3) Multiplying by 1.0 before dividing: 1.0 * quantity / total, an old trick, portable and legitimate.
The sixteen decimals of the first two columns are the working precision of NUMERIC division: at least 16 significant digits. Compute at that precision and round only when presenting.
The real case that bites
"How many units does an order line have on average?" The answer is 113 / 47 units:
SELECT SUM(quantity) AS units, COUNT(*) AS lines_,
SUM(quantity) / COUNT(*) AS avg_wrong, ROUND(AVG(quantity), 4) AS avg_right
FROM order_lines;| units | lines_ | avg_wrong | avg_right |
|---|---|---|---|
| 113 | 47 | 2 | 2.4043 |
SUM(quantity) is BIGINT and so is COUNT(*), so the division is integer and returns 2 instead of 2.4043: a 17 % error, with no warning, in a query that looks obviously correct. AVG doesn't fall into the trap because it returns NUMERIC when the input is an integer; that's the reason it exists.
- Precision: GreenStore's lost cent
In 01-04 we said money goes in NUMERIC and never in floating point. Time to prove it.
SELECT 0.1 + 0.2 AS as_numeric, 0.1::FLOAT8 + 0.2::FLOAT8 AS as_float8,
(0.1::FLOAT8 + 0.2::FLOAT8) = 0.3::FLOAT8 AS are_equal;| as_numeric | as_float8 | are_equal |
|---|---|---|
| 0.3 | 0.30000000000000004 | false |
REAL and DOUBLE PRECISION store numbers in base 2, and 0.1 has no exact representation in base 2, just as 1/3 has none in base 10. The error is tiny… until it accumulates over real data. GreenStore's product revenue —the course's canonical figure— computed both ways:
SELECT SUM(quantity * unit_price * (1 - discount)) AS sum_numeric,
ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS total_numeric,
SUM(quantity * unit_price::FLOAT8 * (1 - discount::FLOAT8)) AS sum_float8,
ROUND(SUM(quantity * unit_price::FLOAT8
* (1 - discount::FLOAT8))::NUMERIC, 2) AS total_float8
FROM order_lines;| sum_numeric | total_numeric | sum_float8 | total_float8 |
|---|---|---|---|
| 727.9450 | 727.95 | 727.9449999999998 | 727.94 |
€727.95 against €727.94. One cent, over 47 lines and 20 orders: the exact sum is 727.9450, which in NUMERIC rounds upwards, while the floating-point accumulation stops at 727.9449999999998 and rounds down. And there's something worse than the cent: the last digits of sum_float8 depend on the order in which the engine adds the rows. A parallel plan, a different index or simply more data can change them, so the same query over the same data can give two different results. With NUMERIC that never happens.
NUMERIC(10,2) |
REAL / DOUBLE PRECISION |
|
|---|---|---|
| Representation | Exact decimal | Approximate binary |
| Sum of the 47 lines | 727.9450 |
727.9449999999998 |
| Reproducible result? | Always | It depends on the order of addition |
= reliable |
Yes | No: compare with a tolerance |
| Speed / use | Slower · money and exact quantities | Faster · physical measurements, statistics |
The course's rule, now justified: money in
NUMERIC; compute at full precision and round only when presenting. Rounding at each intermediate step introduces accumulation error; computing in floating point introduces a worse one, because it's unpredictable.
- Real cases: VAT, margin, discounts and shipping
7.1. Price with VAT and margin
SELECT id, name, price, cost,
ROUND(price * 1.21, 2) AS price_with_vat,
ROUND(price - cost, 2) AS margin,
ROUND((price - cost) / price * 100, 1) AS margin_pct
FROM products WHERE id IN (1, 5, 15, 18) ORDER BY id;| id | name | price | cost | price_with_vat | margin | margin_pct |
|---|---|---|---|---|---|---|
| 1 | Extra virgin olive oil 500 ml | 12.50 | 7.80 | 15.13 | 4.70 | 37.6 |
| 5 | Organic crushed tomato 400 g | 1.95 | 0.90 | 2.36 | 1.05 | 53.8 |
| 15 | Ceremonial matcha green tea 30 g | 22.00 | 12.50 | 26.62 | 9.50 | 43.2 |
| 18 | Bamboo toothbrush | 3.50 | 1.20 | 4.24 | 2.30 | 65.7 |
Look at (price - cost) / price * 100: the operands are NUMERIC, so there's no integer division. If price and cost were INTEGER (cents, say), that expression would give 0 for all twenty products: it's section 5's error dressed up as a business formula.
7.2. Discounts applied
SELECT id, order_id, quantity, unit_price, discount,
ROUND(quantity * unit_price, 2) AS gross,
ROUND(quantity * unit_price * discount, 2) AS saving,
ROUND(quantity * unit_price * (1 - discount), 2) AS amount
FROM order_lines WHERE discount > 0 ORDER BY id;| id | order_id | quantity | unit_price | discount | gross | saving | amount |
|---|---|---|---|---|---|---|---|
| 6 | 3 | 6 | 1.95 | 0.10 | 11.70 | 1.17 | 10.53 |
| 18 | 8 | 3 | 12.50 | 0.05 | 37.50 | 1.88 | 35.63 |
| 24 | 10 | 2 | 18.90 | 0.10 | 37.80 | 3.78 | 34.02 |
| 27 | 11 | 8 | 1.95 | 0.15 | 15.60 | 2.34 | 13.26 |
| 39 | 16 | 2 | 11.20 | 0.05 | 22.40 | 1.12 | 21.28 |
| 45 | 19 | 6 | 4.95 | 0.10 | 29.70 | 2.97 | 26.73 |
Six discounted lines out of the 47, with a total saving of €13.26. Notice line 18: the exact saving is €1.875, which ROUND(…, 2) takes up to 1.88 because NUMERIC rounds half-up; in floating point it would have given 1.87.
7.3. Commercial rounding to .95
Marketing wants to raise prices by 10 % and leave them ending in .95:
SELECT id, name,
price AS current_price,
ROUND(price * 1.10, 2) AS raw_increase,
FLOOR(price * 1.10) + 0.95 AS commercial_price
FROM products WHERE id IN (2, 6, 15, 18) ORDER BY id;| id | name | current_price | raw_increase | commercial_price |
|---|---|---|---|---|
| 2 | Organic brown rice 1 kg | 3.90 | 4.29 | 4.95 |
| 6 | Aloe vera face cream 50 ml | 18.90 | 20.79 | 20.95 |
| 15 | Ceremonial matcha green tea 30 g | 22.00 | 24.20 | 24.95 |
| 18 | Bamboo toothbrush | 3.50 | 3.85 | 3.95 |
FLOOR(x) + 0.95 is the canonical pattern: the integer part plus whatever cents you want. Watch the effect, though: the rice goes from 4.29 to 4.95 (an extra 15 %) and the matcha from 24.20 to 24.95 (3 %). Commercial rounding isn't neutral; measure it before applying it.
7.4. Splitting the shipping cost across the lines
Order 1 has three lines (€42.10 of goods) and €4.95 of shipping. How much shipping falls to each line, in proportion to its amount?
SELECT ol.id AS line_,
ROUND(ol.quantity * ol.unit_price * (1 - ol.discount), 2) AS amount,
ROUND(ol.quantity * ol.unit_price * (1 - ol.discount) / 42.10 * 100, 2) AS weight_pct,
ROUND(o.shipping_cost * ol.quantity * ol.unit_price
* (1 - ol.discount) / 42.10, 2) AS shipping_share
FROM order_lines AS ol
JOIN orders AS o ON o.id = ol.order_id
WHERE ol.order_id = 1 ORDER BY ol.id;| line_ | amount | weight_pct | shipping_share |
|---|---|---|---|
| 1 | 23.90 | 56.77 | 2.81 |
| 2 | 11.70 | 27.79 | 1.38 |
| 3 | 6.50 | 15.44 | 0.76 |
2.81 + 1.38 + 0.76 = 4.95. It adds up. But it doesn't always add up: repeat the calculation for order 3 (€4.95 of shipping over €29.53 of goods) and the three rounded shares total €4.96; for order 8, €9.91 instead of €9.90. That leftover cent is unavoidable, because rounding three numbers to two decimals and adding them doesn't have to give the rounded sum. The standard solution is to assign the residue to one line, usually the largest: you compute n − 1 rounded shares and get the last one by subtraction. Here you'd need to compare each line with the maximum of its order, which is a correlated subquery and arrives in 07-02.
The lesson behind it: proportional splitting with rounding always produces residues. If a financial report adds up rounded line items, somebody has to decide where the cent goes. Let your query decide it, not chance.
RANDOM and generate_series
RANDOM and generate_seriesTwo utilities that will come back later.
| Function | What it does |
|---|---|
RANDOM() |
A random DOUBLE PRECISION in [0, 1) |
generate_series(a, b [, step]) |
Generates the rows a, a+1, …, b |
| n | square |
|---|---|
| 1 | 1 |
| 2 | 4 |
| 3 | 9 |
| 4 | 16 |
generate_series doesn't read any table: it manufactures rows out of nothing. That makes it the tool for building reports with no gaps —one row per month even if that month has no sales—, which is what 03-06 announced and what you'll see in 06-03 with dates. RANDOM() is for sampling (ORDER BY RANDOM() LIMIT 10 returns ten rows at random) and for test data; I'm not showing its result because it changes on every run, and for that same reason never use it in a column DEFAULT without thinking twice (05-06: a volatile DEFAULT rewrites the whole table).
- Comparison table by engine
| Task | PostgreSQL 16 | MySQL 8 | SQLite | SQL Server | Oracle |
|---|---|---|---|---|---|
INTEGER / INTEGER |
Integer (3) |
Decimal (3.3333) |
Integer (3) |
Integer (3) |
Decimal (there's no INTEGER) |
| Explicit integer division | a / b with integers |
a DIV b |
a / b |
a / b |
TRUNC(a/b) |
.5 rounding |
half-up in NUMERIC, half-even in DOUBLE |
half-up in DECIMAL, system-dependent in DOUBLE |
half-up (everything is REAL) |
half-up | half-up |
Truncate to n decimals |
TRUNC(x, n) |
TRUNCATE(x, n) |
— (use CAST) |
ROUND(x, n, 1) |
TRUNC(x, n) |
| Ceiling / floor | CEIL, CEILING, FLOOR |
CEIL, CEILING, FLOOR |
CEIL, FLOOR (3.35+) |
CEILING, FLOOR |
CEIL, FLOOR |
| Modulo | MOD(a,b), a % b |
MOD(a,b), a % b |
a % b |
a % b |
MOD(a,b) (no %) |
| Logarithm with no base | LOG(x) = base 10 |
LOG(x) = natural |
LOG(x) = base 10 |
LOG(x) = natural |
LOG(b,x) mandatory |
GREATEST / LEAST |
Yes, they ignore nulls | Yes, they return NULL with a null |
scalar MAX(a,b) / MIN(a,b) |
Yes (2022+) | Yes, NULL with a null |
Random in [0,1) |
RANDOM() |
RAND() |
RANDOM() returns a 64-bit integer |
RAND() |
DBMS_RANDOM.VALUE |
| Series of integers | generate_series(a,b) |
Recursive CTE | Recursive CTE | GENERATE_SERIES (2022+) |
CONNECT BY LEVEL |
The three rows that break the most code when migrating: integer division (a query that works in MySQL returns zeros in PostgreSQL), the base-less logarithm (same SQL, different result, no error) and SQLite's RANDOM(), which returns a huge signed integer and not a decimal between 0 and 1.
Common Mistakes and Tips
- Dividing two integers and expecting decimals.
10 / 3is3. Convert one operand toNUMERIC, or useAVGwhen what you're computing is an average. - Using
ROUND(x, n)over aDOUBLE PRECISION. That function doesn't exist, and theERRORis warning you about something deeper: you shouldn't have money in floating point. - Assuming
ROUND(2.5)always gives the same thing.3inNUMERIC,2inDOUBLE PRECISION. - Storing money in
REALorDOUBLE PRECISION. The error accumulates, the totals stop adding up and the result isn't even reproducible. And don't round at each intermediate step: compute at full precision and round only when presenting. - Confusing
GREATEST/LEASTwithMAX/MIN. The first compare columns of one row; the second, rows of one column. And with nulls they behave differently depending on the engine. - Expecting
TRUNCandFLOORto agree. Only with positives:TRUNC(-1.5)is-1andFLOOR(-1.5)is-2. AndMOD(-10, 3)is-1, not2. - Adding up rounded amounts and expecting them to match the total. They don't: somebody has to decide where the residue goes.
- Tip: put the
::NUMERICas early as possible and theROUNDas late as possible, and always check that the sum of the parts is the total. It's the numeric equivalent of 04-03's "the filter and its negation add up to the total". - Tip: use
MOD(id, n)to split a process into disjoint batches. Simple, deterministic and with no control table.
Exercises
Exercise 1
Management wants a profitability analysis of the catalogue. For each active product, return the price, the cost, the absolute margin, the percentage margin to one decimal and the inventory value (stock * cost) to two decimals. Sort by percentage margin descending and show the top five. Which kind of product dominates the ranking and why?
Exercise 2
Over reviews.rating: (1) compute the overall average to 4 decimals and to 0 decimals; (2) compute it again writing SUM(rating) / COUNT(*) — what do you get and why?; (3) compute the average per product rounded to 2 decimals, worst to best. How many products appear and why aren't there 20?
Exercise 3
A colleague claims these two queries over order_lines are equivalent:
-- A
SELECT ROUND(SUM(quantity * unit_price * (1 - discount)), 2) AS total FROM order_lines;
-- B
SELECT SUM(ROUND(quantity * unit_price * (1 - discount), 2)) AS total FROM order_lines;(1) Predict whether they'll give the same number and run them. (2) Explain how they differ conceptually. (3) Which one matches what the shop really bills, if every invoice prints each line's amount rounded to two decimals?
Solutions
Solution 1
SELECT id, name, price, cost,
ROUND(price - cost, 2) AS margin,
ROUND((price - cost) / price * 100, 1) AS margin_pct,
ROUND(stock * cost, 2) AS inventory_value
FROM products WHERE active = TRUE
ORDER BY margin_pct DESC, id LIMIT 5;| id | name | price | cost | margin | margin_pct | inventory_value |
|---|---|---|---|---|---|---|
| 18 | Bamboo toothbrush | 3.50 | 1.20 | 2.30 | 65.7 | 288.00 |
| 9 | Calendula lip balm 15 ml | 4.60 | 1.80 | 2.80 | 60.9 | 234.00 |
| 11 | Loofah scrubber (pack of 3) | 5.50 | 2.20 | 3.30 | 60.0 | 242.00 |
| 19 | Natural stick deodorant 50 g | 7.80 | 3.30 | 4.50 | 57.7 | 247.50 |
| 7 | Rosemary solid shampoo 80 g | 8.40 | 3.60 | 4.80 | 57.1 | 342.00 |
(The catalogue's average percentage margin is 52.03 %.) The ranking is dominated by cheap hygiene and household products: on a small price, a small cost leaves a high percentage even though the absolute margin is two euros. Percentage margin and absolute margin sort differently: the matcha, with €9.50 of margin, is the one leaving the most money per unit and it isn't here. And product 20 can never come out: it's discontinued and the WHERE active = TRUE excludes it.
Solution 2
SELECT ROUND(AVG(rating), 4) AS avg_4dec, ROUND(AVG(rating), 0) AS avg_0dec,
SUM(rating) AS total_, COUNT(*) AS reviews,
SUM(rating) / COUNT(*) AS integer_avg
FROM reviews;| avg_4dec | avg_0dec | total_ | reviews | integer_avg |
|---|---|---|---|---|
| 4.0833 | 4 | 49 | 12 | 4 |
1 and 2. AVG(rating) returns 4.0833333333333333: rating is a SMALLINT, but AVG over integers returns NUMERIC. SUM(rating) / COUNT(*), on the other hand, divides 49 by 12 as integers and returns 4. The fact that it matches the rounding to 0 decimals is a coincidence: if the average were 4.9, the integer division would still give 4 and the rounding would give 5. 3.
SELECT product_id, COUNT(*) AS reviews, ROUND(AVG(rating), 2) AS avg_rating
FROM reviews GROUP BY product_id ORDER BY avg_rating, product_id;| product_id | reviews | avg_rating |
|---|---|---|
| 16 | 1 | 2.00 |
| 5 | 1 | 3.00 |
| 12 | 1 | 3.00 |
| 10 | 1 | 4.00 |
| 18 | 1 | 4.00 |
| 2 | 2 | 4.50 |
| 6 | 2 | 4.50 |
| 1 | 2 | 5.00 |
| 15 | 1 | 5.00 |
9 products, not 20: reviews only has 12 rows across 9 distinct products, and a GROUP BY over that table can't invent the 11 nobody has rated. For them to appear you'd have to start from products with a LEFT JOIN (03-03), and their average would be NULL — which isn't 0, and which gets presented with COALESCE (06-04).
Solution 3
1. Both return 727.95, but by luck: GreenStore's 47 line amounts have at most three decimals and the rounding residues cancel out. With other data they wouldn't match. 2. They aren't the same question. A adds first and rounds at the end: it's the mathematically exact amount, rounded a single time, with a maximum error of half a cent, always. B rounds each line and then adds: it introduces up to half a cent of error per line, so with 47 lines the accumulated error can reach about 24 cents and with a million lines, €5,000.
3. And here the brief inverts the answer: if the invoice the customer receives prints each line rounded to two decimals, what the shop actually charges is the sum of those rounded lines, that is, B. In that case B isn't an error, it's the definition of the billed amount, and A would be the figure that doesn't match the paperwork. The moral isn't "always round at the end" but "round where the business rounds": the course's rule applies to analytical reports, while in invoicing per-line rounding is a legal requirement in many countries. What's never acceptable is not knowing which of the two you're computing.
Conclusion
You now compute with judgement:
- You round with
ROUND, cut withTRUNCand approximate withCEIL/FLOOR, knowing that with negatives all four part company. You know the.5trap:NUMERICrounds half-up andDOUBLE PRECISIONhalf-even, soROUND(2.5)can be3or2depending on the type. AndROUND(x, n)doesn't exist for floating point, which is a warning, not a limitation. - You handle
ABS,SIGN,MOD(whose remainder inherits the dividend's sign),POWER,SQRTand the logarithms, with the warning thatLOGwith no base doesn't mean the same thing in PostgreSQL as in MySQL. And you can tellGREATEST/LEAST—scalar— fromMAX/MIN—aggregate—. - You avoid integer division with a decimal literal, a
::NUMERICor a1.0 *, and you can recognise it:SUM(quantity) / COUNT(*)gave2where the average is2.4043. And you've seen the lost cent with real data: €727.95 inNUMERICagainst €727.94 in floating point, with the aggravating factor that the floating-point result isn't even reproducible. - You apply all that to GreenStore: VAT, margins, discounts, commercial rounding to
.95and proportional shipping splits, with the warning that a rounded split doesn't always add up and somebody has to decide where the residue goes.
In the next lesson, date and time functions, comes the data type that answers the most business questions and hides the most errors. You'll see why GreenStore stores everything as DATE and what would change with TIMESTAMP and time zones, the difference between NOW() and CLOCK_TIMESTAMP(), arithmetic with INTERVAL, and at last EXTRACT and DATE_TRUNC, which are the debt 04-05 left outstanding: grouping revenue by month and by quarter without writing twenty conditions by hand. And generate_series will be back, this time so a monthly report shows the months in which nothing was sold.
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
