Of all the questions GreenStore's management asks, most of them have a date inside: how much did we bill this month? which customers haven't bought for six months? how many days did that return take? are we growing quarter on quarter? Up to now all you could do was compare dates with >= and <. This lesson turns the order_date column into an analysis tool.
It also settles 04-05's debt, where EXTRACT appeared in passing to group by year and was left explained "for module 6". And it brings the missing piece for reports: DATE_TRUNC, the function that turns 20 separate orders into a monthly series.
A note on reproducibility: the examples that need "today" use the fixed date DATE '2026-03-01' instead of CURRENT_DATE, so the results you see here match yours. In production you'd use CURRENT_DATE.
Contents
- The temporal types, and why GreenStore uses
DATE - "Now":
CURRENT_DATE,NOW()andCLOCK_TIMESTAMP() - Date arithmetic,
INTERVALandAGE() EXTRACTandDATE_PARTDATE_TRUNC: the temporal report pattern- Formatting with
TO_CHAR, parsing withTO_DATE - Reports with no gaps using
generate_series - Comparison table by engine
- Common Mistakes and Tips
- Exercises
- Conclusion
- The temporal types, and why GreenStore uses
DATE
DATE| Type | What it stores | Example literal | Size |
|---|---|---|---|
DATE |
A calendar date, with no time | DATE '2025-03-04' |
4 bytes |
TIME |
A time of day, with no date | TIME '18:30:00' |
8 bytes |
TIMESTAMP |
Date + time, without a time zone | TIMESTAMP '2025-03-04 18:30:00' |
8 bytes |
TIMESTAMPTZ |
Date + time, with a time zone | TIMESTAMPTZ '2025-03-04 18:30:00+01' |
8 bytes |
INTERVAL |
A duration, not an instant | INTERVAL '30 days' |
16 bytes |
GreenStore uses DATE in every one of its temporal columns —order_date, signup_date, added_date, hire_date, reviews.date, returns.date— because in this model they're calendar facts: an order is "from 4 March", not "from 18:47:03 on 4 March". Storing a time nobody is going to use adds ambiguity without adding information.
What would change with timestamps
In a real shop you'd want the time: to measure preparation time, to know when people buy most, to audit who changed what and when. And as soon as you add the time, the time zone shows up:
| Situation | With DATE |
With TIMESTAMP |
With TIMESTAMPTZ |
|---|---|---|---|
| An order at 23:50 in Lyon (CET) | "4 March", nothing more | 2025-03-04 23:50 — whose clock? |
An unambiguous instant; shown in each user's zone |
| Comparing with an order from Lisbon (WET) | Straightforward | Incomparable: an invisible one-hour difference | Correct: the engine normalises |
| The October clock change | The problem doesn't exist | Two instants with the same representation | Solved |
date >= '2025-03-01' AND date < '2025-04-01' |
Exact | Exact | It depends on the session's zone |
The professional recommendation: in a real application,
TIMESTAMPTZis almost always the right choice.TIMESTAMPwith no zone looks simpler and it's a trap: it stores a number without saying which clock it belongs to, and the day you have users in two countries there's no way of finding out.TIMESTAMPTZstores an instant in UTC internally and converts it to each session's zone on reading. AndDATEis correct only when the value really is a calendar date: a date of birth, a billing day, a due date.
That's exactly the criterion by which GreenStore uses DATE, and the reason your next application probably shouldn't.
- "Now":
CURRENT_DATE, NOW() and CLOCK_TIMESTAMP()
CURRENT_DATE, NOW() and CLOCK_TIMESTAMP()| Function | What it returns | When it's evaluated |
|---|---|---|
CURRENT_DATE / CURRENT_TIME |
Today's DATE / TIME WITH TIME ZONE |
Start of the transaction |
CURRENT_TIMESTAMP |
TIMESTAMPTZ |
Start of the transaction |
NOW() |
Identical to CURRENT_TIMESTAMP |
Start of the transaction |
STATEMENT_TIMESTAMP() |
TIMESTAMPTZ |
Start of the statement |
CLOCK_TIMESTAMP() |
TIMESTAMPTZ |
At the instant of the call |
The difference between NOW() and CLOCK_TIMESTAMP() is surprising the first time. If you open BEGIN;, run SELECT NOW(), CLOCK_TIMESTAMP();, wait a few seconds and repeat the query before the COMMIT, the first column returns exactly the same value both times and the second doesn't. NOW() is frozen for the whole transaction, and that's a deliberate guarantee: if a process inserts a hundred rows with NOW(), all hundred share the same stamp and form an identifiable batch. To measure how long something takes inside a transaction, NOW() is no use: you need CLOCK_TIMESTAMP().
Practical consequence:
signup_date DATE NOT NULL DEFAULT CURRENT_DATE(thecustomersDEFAULTfrom 01-06) is stable and correct. ADEFAULT clock_timestamp()would be volatile and, as you saw in 05-06, it would force a rewrite of the whole table when adding the column.
- Date arithmetic,
INTERVAL and AGE()
INTERVAL and AGE()| Operation | Result type | Example | Result |
|---|---|---|---|
date - date |
INTEGER (days) |
DATE '2026-02-21' - DATE '2025-03-04' |
354 |
date + integer |
DATE |
DATE '2025-03-04' + 30 |
2025-04-03 |
date + INTERVAL |
TIMESTAMP |
DATE '2025-03-04' + INTERVAL '30 days' |
2025-04-03 00:00:00 |
timestamp - timestamp |
INTERVAL |
— | 1 day 02:15:00 |
AGE(a, b) |
A readable INTERVAL |
AGE(DATE '2026-03-01', DATE '2025-01-10') |
1 year 1 mon 19 days |
AGE(x) |
INTERVAL from today |
AGE(signup_date) |
equivalent to AGE(CURRENT_DATE, signup_date) |
Two traps in that table. date - date gives an integer and timestamp - timestamp gives an interval: the same - sign with two semantics, so migrating a column from DATE to TIMESTAMP silently changes the type of all your subtractions. And date + INTERVAL returns a TIMESTAMP, not a DATE; if you need a date, add ::DATE.
INTERVAL and its units
SELECT DATE '2025-01-31' + INTERVAL '1 month' AS end_of_january,
DATE '2025-03-04' + INTERVAL '2 weeks' AS two_weeks,
DATE '2026-02-21' - INTERVAL '6 months' AS six_months_ago;| end_of_january | two_weeks | six_months_ago |
|---|---|---|
| 2025-02-28 00:00:00 | 2025-03-18 00:00:00 | 2025-08-21 00:00:00 |
Look at the first one: 31 January + 1 month = 28 February, because 31 February doesn't exist and PostgreSQL clamps to the last day of the month. A month isn't a fixed number of days: INTERVAL '1 month' and INTERVAL '30 days' are different things.
AGE(): readable tenure
AGE doesn't return days: it returns years, months and days, the way a person would say it.
SELECT id, CONCAT_WS(' ', name, last_name) AS customer, signup_date,
DATE '2026-03-01' - signup_date AS days,
AGE(DATE '2026-03-01', signup_date) AS tenure
FROM customers WHERE id IN (1, 4, 7, 12, 15) ORDER BY id;| id | customer | signup_date | days | tenure |
|---|---|---|---|---|
| 1 | Lucía Martínez Soler | 2025-01-10 | 415 | 1 year 1 mon 19 days |
| 4 | Javier Ortega Ruiz | 2025-02-14 | 380 | 1 year 15 days |
| 7 | Sofia Moreira Costa | 2025-03-21 | 345 | 11 mons 8 days |
| 12 | Diego Ramos Herrera | 2025-06-01 | 273 | 9 mons |
| 15 | Inés Carrasco Vega | 2026-01-08 | 52 | 1 mon 21 days |
Two details: PostgreSQL omits the components that are zero (Javier shows no "0 mons"; Diego, neither months nor days), and AGE is what you want for showing to a human while the subtraction in days is what you want for sorting and comparing.
Case: days between the order and the return
SELECT rt.id AS return_, rt.order_id, o.order_date,
rt.date AS return_date,
rt.date - o.order_date AS days_elapsed,
rt.amount
FROM returns AS rt
JOIN orders AS o ON o.id = rt.order_id
ORDER BY rt.id;| return_ | order_id | order_date | return_date | days_elapsed | amount |
|---|---|---|---|---|---|
| 1 | 6 | 2025-05-23 | 2025-05-25 | 2 | 26.75 |
| 2 | 10 | 2025-08-03 | 2025-08-11 | 8 | 34.02 |
| 3 | 13 | 2025-10-22 | 2025-10-30 | 8 | 19.80 |
GreenStore's three returns: the one from the cancelled order was processed in 2 days and the two product-issue ones in 8. With TIMESTAMP instead of DATE this subtraction would return an INTERVAL like 8 days 03:12:00, more precise and less convenient to aggregate.
EXTRACT and DATE_PART
EXTRACT and DATE_PARTEXTRACT(field FROM date) pulls out a component. DATE_PART('field', date) does the same with function syntax; EXTRACT is the SQL standard and is the preferable form.
| Field | What it returns | For DATE '2025-03-04' |
|---|---|---|
YEAR |
Year | 2025 |
MONTH |
Month, 1-12 | 3 |
DAY |
Day of the month | 4 |
QUARTER |
Quarter, 1-4 | 1 |
WEEK |
ISO week, 1-53 | 10 |
DOW / ISODOW |
Day of the week: 0 = Sunday / 1 = Monday | 2 / 2 |
DOY |
Day of the year, 1-366 | 63 |
EPOCH |
Seconds since 1970-01-01 | 1741046400 |
SELECT id, order_date,
EXTRACT(YEAR FROM order_date) AS year_,
EXTRACT(MONTH FROM order_date) AS month,
EXTRACT(QUARTER FROM order_date) AS quarter,
EXTRACT(DOW FROM order_date) AS dow,
EXTRACT(WEEK FROM order_date) AS iso_week
FROM orders WHERE id IN (1, 8, 13, 17, 20) ORDER BY id;| id | order_date | year_ | month | quarter | dow | iso_week |
|---|---|---|---|---|---|---|
| 1 | 2025-03-04 | 2025 | 3 | 1 | 2 | 10 |
| 8 | 2025-06-28 | 2025 | 6 | 2 | 6 | 26 |
| 13 | 2025-10-22 | 2025 | 10 | 4 | 3 | 43 |
| 17 | 2026-01-13 | 2026 | 1 | 1 | 2 | 3 |
| 20 | 2026-02-21 | 2026 | 2 | 1 | 6 | 8 |
Three warnings: DOW starts on Sunday with 0 (the C convention, not the European one); WEEK is the ISO week, so the first days of January can belong to week 52 or 53 of the previous year; and EXTRACT returns NUMERIC from PostgreSQL 14 onwards. And the performance one, already familiar: WHERE EXTRACT(YEAR FROM order_date) = 2025 works but it wraps the column in a function and can't use the index; the indexable form is 02-03's, WHERE order_date >= '2025-01-01' AND order_date < '2026-01-01'. You'll measure it in 08-03.
DATE_TRUNC: the temporal report pattern
DATE_TRUNC: the temporal report patternDATE_TRUNC(unit, date) lowers the date to the start of the unit you ask for: 22 October truncated to month is 1 October. That's the whole idea, and it's the basis of almost every temporal report.
| Call | Result for 2025-10-22 |
|---|---|
DATE_TRUNC('day', d) |
2025-10-22 00:00:00 |
DATE_TRUNC('week', d) |
2025-10-20 00:00:00 (ISO Monday) |
DATE_TRUNC('month', d) |
2025-10-01 00:00:00 |
DATE_TRUNC('quarter', d) |
2025-10-01 00:00:00 |
DATE_TRUNC('year', d) |
2025-01-01 00:00:00 |
Careful with the type: even if you pass it a
DATE,DATE_TRUNCreturns aTIMESTAMPand that's why you see the00:00:00. For the report to show a clean date, add::DATE.
Monthly revenue
SELECT DATE_TRUNC('month', o.order_date)::DATE AS month,
COUNT(DISTINCT o.id) AS orders,
COUNT(*) AS lines_,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY DATE_TRUNC('month', o.order_date) ORDER BY month;| month | orders | lines_ | revenue |
|---|---|---|---|
| 2025-03-01 | 2 | 5 | 68.80 |
| 2025-04-01 | 2 | 5 | 61.28 |
| 2025-05-01 | 2 | 5 | 58.85 |
| 2025-06-01 | 2 | 5 | 95.48 |
| 2025-07-01 | 1 | 3 | 44.60 |
| 2025-08-01 | 1 | 2 | 48.27 |
| 2025-09-01 | 1 | 2 | 32.76 |
| 2025-10-01 | 2 | 5 | 97.20 |
| 2025-11-01 | 1 | 3 | 31.70 |
| 2025-12-01 | 2 | 5 | 64.58 |
| 2026-01-01 | 2 | 4 | 75.10 |
| 2026-02-01 | 2 | 3 | 49.33 |
12 months, 20 orders, 47 lines and €727.95 adding up the last column: module 4's canonical figure, now broken down. And the years add up: the first ten months total €603.52 (2025) and the last two, €124.43 (2026). The best month is October 2025 with €97.20; the worst, November 2025 with €31.70.
By quarter
SELECT DATE_TRUNC('quarter', o.order_date)::DATE AS quarter,
COUNT(DISTINCT o.id) AS orders,
ROUND(SUM(ol.quantity * ol.unit_price * (1 - ol.discount)), 2) AS revenue
FROM orders AS o
JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY DATE_TRUNC('quarter', o.order_date) ORDER BY quarter;| quarter | orders | revenue |
|---|---|---|
| 2025-01-01 | 2 | 68.80 |
| 2025-04-01 | 6 | 215.61 |
| 2025-07-01 | 3 | 125.63 |
| 2025-10-01 | 5 | 193.48 |
| 2026-01-01 | 4 | 124.43 |
DATE_TRUNC against EXTRACT for grouping: both work but they aren't equivalent. EXTRACT(MONTH FROM d) returns 3 for March of any year, so March 2025 and March 2026 would fall into the same group; DATE_TRUNC('month', d) keeps the year and sorts chronologically with no tricks. EXTRACT for seasonality, DATE_TRUNC for time series.
- Formatting with
TO_CHAR, parsing with TO_DATE
TO_CHAR, parsing with TO_DATETO_CHAR(date, pattern) turns a date into text in whatever format you like.
| Pattern | What it produces | For 2025-03-04 |
|---|---|---|
'YYYY-MM-DD' |
ISO | 2025-03-04 |
'DD/MM/YYYY' |
Day-first (European) format | 04/03/2025 |
'YYYY-MM' |
Year and month, sortable as text | 2025-03 |
'YYYY"-Q"Q' |
Year and quarter (quoted literal text) | 2025-Q1 |
'Month' / 'FMMonth' |
Month name, with and without padding to 9 characters | March / March |
'TMMonth' |
Month localised according to lc_time, with no padding |
March |
'TMDay' |
Localised day of the week | Tuesday |
'IW' / 'Q' |
ISO week / quarter | 10 / 1 |
For the TM prefix to follow the language you want, you set the session's locale:
SET lc_time = 'en_US.UTF-8';
SELECT DATE_TRUNC('month', order_date)::DATE AS month,
TO_CHAR(order_date, 'YYYY-MM') AS period,
TO_CHAR(order_date, 'DD/MM/YYYY') AS date_eu,
TO_CHAR(order_date, 'TMMonth YYYY') AS long_month,
TO_CHAR(order_date, 'TMDay') AS weekday
FROM orders WHERE id IN (1, 13, 20) ORDER BY id;| month | period | date_eu | long_month | weekday |
|---|---|---|---|---|
| 2025-03-01 | 2025-03 | 04/03/2025 | March 2025 | Tuesday |
| 2025-10-01 | 2025-10 | 22/10/2025 | October 2025 | Wednesday |
| 2026-02-01 | 2026-02 | 21/02/2026 | February 2026 | Saturday |
With lc_time set to en_US.UTF-8 the TM output is English and matches the plain 'Month' pattern; set it to 'fr_FR.UTF-8' and the same query would return mars, octobre and février. That's the whole point of the prefix: the pattern stays the same and the language travels with the session.
Golden rule: format only when presenting.
TO_CHAR(d, 'DD/MM/YYYY')produces text, and text sorts alphabetically:'01/12/2025'would come before'04/03/2025'. If you need to sort or group, do it by the date and format afterwards. The only safe exception is'YYYY-MM', which does sort correctly as text because it's ISO.
TO_DATE(text, pattern) goes the other way, and that's where the classic ambiguity lives:
SELECT TO_DATE('03/04/2025', 'DD/MM/YYYY') AS european_reading,
TO_DATE('03/04/2025', 'MM/DD/YYYY') AS american_reading;| european_reading | american_reading |
|---|---|
| 2025-04-03 | 2025-03-04 |
The same text, two different dates, neither of them incorrect. That's why the pattern is compulsory and why the ISO format YYYY-MM-DD is the only one that's never misread. When you import a CSV, demand the format in the specification and don't deduce it from the data: '03/04/2025' won't tell you which it is.
- Reports with no gaps using
generate_series
generate_seriesHere 03-06's and 06-02's promise comes due. A GROUP BY only returns the groups that exist in the data: if there were no signups in a given month, that month doesn't appear and the report lies by omission. Customer signups by month, straight out of the GROUP BY, give 8 rows for 13 months of history. The solution is to manufacture the complete series of months and join it from the left:
SELECT s.month::DATE AS month,
COUNT(c.id) AS signups
FROM generate_series(DATE '2025-01-01', DATE '2026-01-01', INTERVAL '1 month') AS s(month)
LEFT JOIN customers AS c
ON DATE_TRUNC('month', c.signup_date) = s.month
GROUP BY s.month
ORDER BY s.month;| month | signups |
|---|---|
| 2025-01-01 | 2 |
| 2025-02-01 | 3 |
| 2025-03-01 | 2 |
| 2025-04-01 | 2 |
| 2025-05-01 | 2 |
| 2025-06-01 | 2 |
| 2025-07-01 | 0 |
| 2025-08-01 | 0 |
| 2025-09-01 | 1 |
| 2025-10-01 | 0 |
| 2025-11-01 | 0 |
| 2025-12-01 | 0 |
| 2026-01-01 | 1 |
13 rows and 15 customers. Now you can see what the GROUP BY was hiding: GreenStore acquired customers steadily until June 2025 and then dried up during the second half —five months with zero signups—. That's a business conclusion that simply didn't exist in the 8-row report.
Three details make the pattern work: the LEFT JOIN goes from the series to the data and never the other way round; you count COUNT(c.id) and not COUNT(*), because COUNT(*) would count the row manufactured by the LEFT JOIN and would give 1 where it should give 0 (04-04); and generate_series with INTERVAL '1 month' generates timestamps, which the ::DATE cleans up and the DATE_TRUNC in the ON makes comparable. The same scheme works for days of the week, quarters or the time axis of a dashboard: it's the most reusable report pattern in the course.
- Comparison table by engine
| Task | PostgreSQL 16 | MySQL 8 | SQLite | SQL Server | Oracle |
|---|---|---|---|---|---|
| Today's date | CURRENT_DATE |
CURDATE() |
DATE('now') |
CAST(GETDATE() AS DATE) |
TRUNC(SYSDATE) |
| The current instant | NOW(), CURRENT_TIMESTAMP |
NOW() |
DATETIME('now') |
SYSDATETIME() |
SYSTIMESTAMP |
| Difference in days | d2 - d1 |
DATEDIFF(d2, d1) |
JULIANDAY(d2) - JULIANDAY(d1) |
DATEDIFF(day, d1, d2) |
d2 - d1 |
| Adding days | d + 30 |
DATE_ADD(d, INTERVAL 30 DAY) |
DATE(d, '+30 days') |
DATEADD(day, 30, d) |
d + 30 |
| Extracting a component | EXTRACT(YEAR FROM d) |
EXTRACT, YEAR(d) |
STRFTIME('%Y', d) |
DATEPART(year, d) |
EXTRACT(YEAR FROM d) |
| Truncating to month | DATE_TRUNC('month', d) |
DATE_FORMAT(d, '%Y-%m-01') |
DATE(d, 'start of month') |
DATETRUNC(month, d) (2022+) |
TRUNC(d, 'MM') |
| Formatting | TO_CHAR(d, 'DD/MM/YYYY') |
DATE_FORMAT(d, '%d/%m/%Y') |
STRFTIME('%d/%m/%Y', d) |
FORMAT(d, 'dd/MM/yyyy') |
TO_CHAR(d, 'DD/MM/YYYY') |
| Parsing text | TO_DATE(t, 'DD/MM/YYYY') |
STR_TO_DATE(t, '%d/%m/%Y') |
— | PARSE(t AS date …) |
TO_DATE(t, 'DD/MM/YYYY') |
| Type with a time zone | TIMESTAMPTZ |
TIMESTAMP (stores in UTC) |
there's no date type | DATETIMEOFFSET |
TIMESTAMP WITH TIME ZONE |
| Series of dates | generate_series(d1, d2, '1 day') |
Recursive CTE | Recursive CTE | GENERATE_SERIES (2022+) |
CONNECT BY LEVEL |
Two warnings are worth the whole table. SQLite has no date type: it stores text, Julian numbers or epoch seconds, and all its functions are STRFTIME over text; it works, but nothing stops somebody putting '04/03/2025' in the same column. And MySQL's and SQL Server's DATEDIFF take their arguments in a different order —DATEDIFF(d2, d1) against DATEDIFF(day, d1, d2)—, so a careless migration flips the sign of every result.
And the mistake to avoid on any engine: storing dates as text. A
VARCHAR(10)column holding'2025-03-04'accepts'2025-13-45', can't be added to or subtracted from, sorts wrongly the moment somebody writes'4/3/2025'and can't use a range index effectively. If the value is a date, the type isDATE.
Common Mistakes and Tips
- Storing dates as text. The root error.
DATEvalidates, sorts, subtracts and indexes;VARCHARdoes none of that. - Using
TIMESTAMPwith no zone in an application with users in several countries.TIMESTAMPTZis the default choice. - Expecting
date + INTERVAL '1 day'to return aDATE. It returns aTIMESTAMP, just likeDATE_TRUNC: hence the00:00:00in reports. Add::DATE. - Believing
INTERVAL '1 month'is 30 days.2025-01-31 + 1 monthis2025-02-28. And don't confuseDOW(Sunday = 0) withISODOW(Monday = 1). - Grouping by
EXTRACT(MONTH …)over a multi-year series. March 2025 and March 2026 fall into the same group. For time series,DATE_TRUNC. - Filtering with
EXTRACT(YEAR FROM d) = 2025. Correct but not indexable. Use>= '2025-01-01' AND < '2026-01-01'(02-03). - Sorting by a date already formatted with
TO_CHAR. It sorts as text. Sort by the date and format afterwards;'YYYY-MM'is the only safe exception. - Interpreting
'03/04/2025'without specifying the pattern. They're two different dates depending on the country. - Counting with
COUNT(*)in agenerate_seriesreport. It counts the row manufactured by theLEFT JOIN; count a column of the data table. - Tip: prefer
>= start AND < endtoBETWEENwith dates. WithDATEthey behave the same, but the day the column becomes aTIMESTAMP,BETWEENwill lose the whole last day except for 00:00. - Tip: store in UTC and convert when presenting, and to debug, fix a reference date (
DATE '2026-03-01') instead ofCURRENT_DATE: your results will be reproducible tomorrow.
Exercises
Exercise 1
Management wants the complete 2025 monthly report. For each of the twelve months —even if there's nothing— show the month in YYYY-MM format, the number of orders and the product revenue. Use generate_series and explain why January and February appear as zero.
Exercise 2
Marketing wants to measure conversion speed: how many days pass between a customer registering and their first order. For customers 1 to 8, show the full name, the signup_date, the date of the first order and the days elapsed, sorted by days. (Hint: MIN(order_date) grouping by customer.)
Exercise 3
A colleague has written this seasonality report and claims that "March is our weakest month":
-- ⚠️ Suspicious
SELECT EXTRACT(MONTH FROM order_date) AS month, COUNT(*) AS orders
FROM orders GROUP BY EXTRACT(MONTH FROM order_date) ORDER BY month;- What is it really measuring and why is the conclusion fragile with GreenStore's data?
- Write the
DATE_TRUNCversion that answers "how is the business evolving month by month?". - In what case would the original query be correct?
Solutions
Solution 1
SELECT TO_CHAR(s.month, 'YYYY-MM') AS month,
COUNT(DISTINCT o.id) AS orders,
COALESCE(ROUND(SUM(ol.quantity * ol.unit_price
* (1 - ol.discount)), 2), 0) AS revenue
FROM generate_series(DATE '2025-01-01', DATE '2025-12-01', INTERVAL '1 month') AS s(month)
LEFT JOIN orders AS o ON DATE_TRUNC('month', o.order_date) = s.month
LEFT JOIN order_lines AS ol ON ol.order_id = o.id
GROUP BY s.month
ORDER BY s.month;| month | orders | revenue |
|---|---|---|
| 2025-01 | 0 | 0.00 |
| 2025-02 | 0 | 0.00 |
| 2025-03 | 2 | 68.80 |
| 2025-04 | 2 | 61.28 |
| 2025-05 | 2 | 58.85 |
| 2025-06 | 2 | 95.48 |
| 2025-07 | 1 | 44.60 |
| 2025-08 | 1 | 48.27 |
| 2025-09 | 1 | 32.76 |
| 2025-10 | 2 | 97.20 |
| 2025-11 | 1 | 31.70 |
| 2025-12 | 2 | 64.58 |
12 rows, 16 orders and €603.52: module 4's 2025 revenue. January and February come out at zero because GreenStore's first order is from 4 March 2025: the shop existed but wasn't selling yet. Without generate_series those two months wouldn't appear and a line chart would start in March, implying there's no earlier history. The COALESCE (06-04) turns SUM's NULL over an empty set into 0.00.
Solution 2
SELECT c.id, CONCAT_WS(' ', c.name, c.last_name) AS customer, c.signup_date,
MIN(o.order_date) AS first_order,
MIN(o.order_date) - c.signup_date AS days
FROM customers AS c
JOIN orders AS o ON o.customer_id = c.id
WHERE c.id <= 8
GROUP BY c.id, c.name, c.last_name, c.signup_date ORDER BY days;| id | customer | signup_date | first_order | days |
|---|---|---|---|---|
| 2 | Carlos Ferrer Ibáñez | 2025-01-22 | 2025-03-12 | 49 |
| 1 | Lucía Martínez Soler | 2025-01-10 | 2025-03-04 | 53 |
| 3 | Marta Sanchis Gil | 2025-02-03 | 2025-04-02 | 58 |
| 4 | Javier Ortega Ruiz | 2025-02-14 | 2025-04-19 | 64 |
| 5 | Ana Belmonte Roca | 2025-02-27 | 2025-05-23 | 85 |
| 6 | Pau Llorens Vidal | 2025-03-09 | 2025-06-11 | 94 |
| 7 | Sofia Moreira Costa | 2025-03-21 | 2025-06-28 | 99 |
| 8 | Tiago Almeida Nunes | 2025-04-04 | 2025-07-15 | 102 |
The business reading is uncomfortable: conversion gets worse as the year goes on, from 49 to 102 days. And there's a bias to declare: more recent customers have had less time to buy. With a plain JOIN you also lose customers 13, 14 and 15, who have never ordered; with a LEFT JOIN they'd appear with NULL in the last two columns, which is the honest answer.
Solution 3
1. EXTRACT(MONTH …) returns 3 for March 2025 as well as for March 2026, so it measures seasonality, not evolution: it groups every March in history together. With GreenStore's data the conclusion is fragile because there's only one March with data and twelve months of history, so each "month of the year" has one or two orders. With 20 orders across 12 months there's no seasonality to measure: there's noise.
2. The time series is section 5's query, and its reading is the opposite: good and bad months alternating, a peak in October 2025 (€97.20) and a trough in November (€31.70), with no clear trend.
3. It would be correct if the question really were seasonal —"in which month of the year do we sell most, averaged over several years?"— and there were enough years to average. What's wrong isn't the function: it's using it to answer a question about evolution.
Conclusion
You now know how to work with time:
- You know the five temporal types and why GreenStore uses
DATE, with the firm recommendation that in a real applicationTIMESTAMPTZis almost always the right choice. And you can tellNOW()—frozen for the whole transaction— fromCLOCK_TIMESTAMP(). - You do date arithmetic knowing that
date - dategives whole days, thatdate + INTERVALgives aTIMESTAMPand thatINTERVAL '1 month'isn't 30 days. And you useAGE()when the reader is a person. - You pull out components with
EXTRACT(DOWstarts on Sunday,WEEKis ISO) and truncate withDATE_TRUNC, which turns 20 orders into a monthly series — 04-05's debt, settled. And you know when to use each:EXTRACTfor seasonality,DATE_TRUNCfor evolution. - You format with
TO_CHAR(includingTMMonthwithlc_time) and parse withTO_DATE, remembering that'03/04/2025'is two different dates and that sorting text isn't sorting dates. - You build reports with no gaps using
generate_series+LEFT JOIN+COUNT(column), and you've seen what they were hiding: five consecutive months without a single customer signup.
There's a loose end you've seen three times in this lesson and haven't been able to tie: in the signups-per-month report you needed COUNT(c.id) instead of COUNT(*) for 0 to come out; in the monthly revenue one you needed a COALESCE so an empty month wouldn't show NULL; and in the listing of customers with no orders, the date columns would appear empty without saying why. All three are the same problem —what to do when there's no value— and the next lesson, type conversion and handling NULL, solves it once and for all with CAST, COALESCE and NULLIF. It'll also settle another outstanding account: you've been writing ::DATE, ::NUMERIC and ::TEXT for three lessons without anybody explaining what they are.
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
