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

  1. Rounding and truncating: ROUND, TRUNC, CEIL, FLOOR
  2. .5 rounding: NUMERIC against DOUBLE PRECISION
  3. Arithmetic: ABS, SIGN, MOD, POWER, SQRT, logarithms
  4. GREATEST and LEAST aren't MAX and MIN
  5. Integer division and its three fixes
  6. Precision: GreenStore's lost cent
  7. Real cases: VAT, margin, discounts and shipping
  8. RANDOM and generate_series
  9. Comparison table by engine
  10. Common Mistakes and Tips
  11. Exercises
  12. Conclusion

  1. 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) and TRUNC(x, n) only exist for NUMERIC. If x is DOUBLE PRECISION, PostgreSQL fails with ERROR: function round(double precision, integer) does not exist. The fix is ROUND(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 a DOUBLE PRECISION to two decimals doesn't mean what you think it does. Section 6 proves it.

  1. .5 rounding: NUMERIC against DOUBLE PRECISION

When 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 round NUMERIC, and that way the .5 goes up. If you receive a DOUBLE PRECISION from outside, convert it with ::NUMERIC before 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.

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

SELECT id, MOD(id, 4) AS batch, order_date, status
FROM orders WHERE MOD(id, 4) = 0 ORDER BY id;
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.

  1. GREATEST and LEAST aren't MAX and MIN

The 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) returns 1 in PostgreSQL (it ignores nulls) and NULL in MySQL and Oracle. It's a real and dangerous difference when porting.

  1. Integer division and its three fixes

SELECT 10 / 3 AS division, 10 % 3 AS remainder;
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.

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

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

  1. RANDOM and generate_series

Two 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
SELECT n, n * n AS square FROM generate_series(1, 4) AS g(n) ORDER BY n;
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).

  1. 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 / 3 is 3. Convert one operand to NUMERIC, or use AVG when what you're computing is an average.
  • Using ROUND(x, n) over a DOUBLE PRECISION. That function doesn't exist, and the ERROR is warning you about something deeper: you shouldn't have money in floating point.
  • Assuming ROUND(2.5) always gives the same thing. 3 in NUMERIC, 2 in DOUBLE PRECISION.
  • Storing money in REAL or DOUBLE 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/LEAST with MAX/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 TRUNC and FLOOR to agree. Only with positives: TRUNC(-1.5) is -1 and FLOOR(-1.5) is -2. And MOD(-10, 3) is -1, not 2.
  • 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 ::NUMERIC as early as possible and the ROUND as 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 with TRUNC and approximate with CEIL/FLOOR, knowing that with negatives all four part company. You know the .5 trap: NUMERIC rounds half-up and DOUBLE PRECISION half-even, so ROUND(2.5) can be 3 or 2 depending on the type. And ROUND(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, SQRT and the logarithms, with the warning that LOG with no base doesn't mean the same thing in PostgreSQL as in MySQL. And you can tell GREATEST/LEAST —scalar— from MAX/MIN —aggregate—.
  • You avoid integer division with a decimal literal, a ::NUMERIC or a 1.0 *, and you can recognise it: SUM(quantity) / COUNT(*) gave 2 where the average is 2.4043. And you've seen the lost cent with real data: €727.95 in NUMERIC against €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 .95 and 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

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