← SQL on CSV
Lesson 10 of 15 by Arif Aslam 9 minute read

Dates and months

Almost every business question has a date in it. Not "what did we sell", but "what did we sell last quarter", or "is this month better than the one before". The sales file has one row per day for a hundred days, and nobody wants to read a hundred numbers. They want six, one per month, and this lesson is how you get from one to the other.

Before any of that: the date column here is a real DATE, not text. DuckDB works that out while reading the CSV because the values are in ISO format, year first. That detection is the reason everything below works, and when a date column arrives as text instead, every date function refuses until you cast it. ISO 8601 in your files is the single cheapest thing you can do to make your future self's queries work.

date_trunc rounds a date down

date_trunc('month', date) takes any date and returns the first of its month. The seventeenth of March becomes the first of March, and so does the second, and so does the thirty-first. Group on that and every day in a month lands in the same bucket.

The first argument names the unit, and the useful ones are 'day', 'week', 'month', 'quarter' and 'year'. Swapping one word turns a monthly report into a quarterly one, which is a good demonstration of why this is worth learning properly rather than doing in a spreadsheet afterwards.

The crucial property is that the result is still a date. It sorts chronologically, it can be compared to other dates, and a chart library will treat it as a time axis. That is why date_trunc is better than formatting the date as text, which brings us to the trap.

The string-month trap

strftime(date, '%Y-%m') gives you '2025-03', which is a perfectly good label. Because the year comes first and the month is zero-padded, it even sorts correctly as text. So it works, and I use it for labels.

Change the format and it stops working. '%b %Y' gives you 'Mar 2025', and sorting that alphabetically puts April first and September last. Every analyst has shipped that chart once. The rule that avoids it forever: group and sort by the truncated date, and only format at the very end, in the presentation layer or as a second column that nobody sorts on.

EXTRACT pulls out a part

EXTRACT(month FROM date) returns the number 3 for any March date, in any year. That is a different question from date_trunc, and mixing them up produces confidently wrong reports.

Use EXTRACT for seasonality: which month of the year is busiest across several years, which day of the week gets the most tickets. Use date_trunc for a trend over time. If your file spans two years, grouping by EXTRACT(month FROM date) will silently add last March to this March, and the resulting chart is a seasonality analysis wearing the costume of a trend line.

Other parts worth knowing: year, day, dow for day of week, doy for day of year, and quarter. DuckDB also exposes shorthand functions such as year(date) and monthname(date) if you prefer them to the EXTRACT spelling.

Comparing to a date

Write the literal as DATE '2025-03-01'. Most engines also accept a bare quoted string and cast it for you, but being explicit costs four characters and removes any question about how the string is being read.

For a whole month, BETWEEN DATE '2025-03-01' AND DATE '2025-03-31' is correct on a DATE column, because both ends are included and there is no time component to lose. On a TIMESTAMP column it quietly drops almost the entire last day, and the fix is the half-open range: >= DATE '2025-03-01' AND < DATE '2025-04-01'. Get in the habit of writing it that way even on date columns and you never have to remember which sort of column you are looking at.

Date arithmetic works the way you would hope. date + INTERVAL 7 DAY moves a week forward, and subtracting two dates gives you the number of days between them. That is how you compute an age, a lead time, or a days-to-resolution column on the ticket file.

Towards month over month

A monthly total is one query. Comparing each month to the one before it needs one more idea: reaching into the previous row. That is LAG, and it arrives in lesson 13. Get the monthly table right here, because the growth calculation is built directly on top of it, and a percentage computed from the wrong buckets is worse than no percentage at all.

Exercises

1. Rows per month number

Return the month number as month_number and the row count as orders, one row per month, in month order. Order is checked.

Hint

EXTRACT gives you the number. Group by the alias and sort by it.

2. Units by year-month label

Return a month column formatted as 2025-01 and the total units in each, oldest first. Order is checked.

Hint

strftime(date, '%Y-%m'). This format happens to sort correctly as text.

3. Two quarters

Return one row per quarter with the revenue total headed total, with the quarter column headed quarter, oldest first. Order is checked.

Hint

Same as the starter query with one word changed inside date_trunc.

4. One month of rows

Return every column of every row dated in March 2025.

Hint

Either BETWEEN two DATE literals, or a half-open range. Both give the same answer on a DATE column.

What to look up next

Look up "date spine" or generate_series for dates. Grouping by month only returns months that have rows, so a month with no sales silently vanishes from your trend line. Joining against a generated list of every month is how you get the zero back.