← All cheatsheets
Data Analyst · #037 · August 29, 2026 · 2 min read

How do you handle dates in SQL?

Truncate, extract, date math, the monthly report skeleton, month-over-month growth with LAG, and the timestamp traps: every SQL date pattern you keep googling, on one printable page.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

Time breaks every query eventually: two date formats, a timezone, a BETWEEN that quietly drops the last day. Here is every date pattern you keep googling, on one page. The print-ready A4 PDF is at the bottom.

Truncate

  • DATE_TRUNC('month', d): first of the month, the backbone of every report.
  • DATE_TRUNC('week', d): Monday.
  • ts::DATE: drop the time part.

Parts and labels

  • EXTRACT(dow FROM d): day of week, 0 = Sunday.
  • EXTRACT(hour FROM ts): for hourly heatmaps.
  • TO_CHAR(d, 'YYYY-MM'): month labels for humans; store dates, format at the end.

Date math

  • d + INTERVAL '1 month': add.
  • d2 - d1: days between.
  • NOW() - INTERVAL '30 day': the rolling window.
  • AGE(d2, d1): gap in years/months/days.

The monthly report, complete

WITH m AS (
  SELECT DATE_TRUNC('month', d) AS mo,
         SUM(rev) AS rev
  FROM sales GROUP BY 1)
SELECT mo, rev,
  ROUND(100.0 * (rev / LAG(rev)
    OVER (ORDER BY mo) - 1), 1) AS mom
FROM m ORDER BY mo;

LAG reads the previous row of the ordered result: last month, no self-join. The same shape gives running totals (SUM() OVER (ORDER BY d)) and 7-day moving averages (ROWS 6 PRECEDING).

The traps: timestamps end at midnight

You wroteWhat happensWrite instead
BETWEEN '08-01' AND '08-31'drops Aug 31 after 00:00>= '08-01' AND < '09-01'
WHERE ts::DATE = CURRENT_DATEindex unused, full scanrange on the raw ts
GROUP BY day, no seriesempty days just vanishgenerate_series + LEFT JOIN

And two habits that prevent the rest: store timestamps in UTC (AT TIME ZONE 'UTC') and convert at display time; treat '1970-01-01' as what it usually is, a fake null wearing a date costume.

Frequently asked questions

How do I group by month in SQL?
GROUP BY DATE_TRUNC('month', date_col). DATE_TRUNC returns the first instant of the month, so every row in the same month lands in the same bucket, and the result column is a real date you can sort and plot. TO_CHAR(d, 'YYYY-MM') is for labels only.
How do I compute month-over-month growth in SQL?
Aggregate by month in a CTE, then use LAG(rev) OVER (ORDER BY month) to read the previous month's value on the same row: rev / LAG(rev) - 1 is the growth rate. No self-join needed.
Why does BETWEEN miss rows on the last day of the month?
Because a date literal used against a timestamp column means midnight: BETWEEN '2026-08-01' AND '2026-08-31' ends at Aug 31, 00:00:00, so everything later that day is excluded. Use >= '2026-08-01' AND < '2026-09-01' instead.
Why do missing days disappear from my daily report?
GROUP BY only returns groups that exist: a day with zero rows produces no group, so the chart silently skips it. Generate the calendar with generate_series and LEFT JOIN your data onto it, so empty days show as zeros.

Get the free PDF

One page, print-ready, free to share. No signup needed.

Download the PDF

More cheatsheets