Working with dates
Filter, group, shift and measure dates, and avoid the timestamp traps that make month-end numbers wrong.
In this lesson 9 sections
Date questions are everywhere in reporting: this month’s pipeline, deals per quarter, days to close. Every database handles dates a little differently, so this lesson teaches the ideas with the sandbox’s syntax, then maps each one to the other databases at the end.
How dates look
| Column | Type | Example | Holds |
|---|---|---|---|
created_at |
date | 2026-03-15 |
A day |
close_date |
date | 2026-05-01 |
A day |
closed_at |
timestamp | 2026-04-02 10:45:00 |
A day and a time |
Always write dates as 'YYYY-MM-DD'. Every major database reads that format, and it sorts correctly even as text.
Today’s date is CURRENT_DATE on most databases, including the sandbox.
The timestamp trap
Exactly one deal closed on April 2, 2026. Watch what happens when you ask for it:
SELECT COUNT(*) FROM opportunities WHERE closed_at = '2026-04-02'; -- 0
closed_at holds 2026-04-02 10:45:00. A date on its own means the very start of that day, midnight, so only a deal closed at exactly 00:00:00 would match. Two fixes:
-- 1. A range for the whole day. Works everywhere, and is the fastest on big tables.
WHERE closed_at >= '2026-04-02'
AND closed_at < '2026-04-03'
-- 2. Keep just the date part, then compare.
WHERE date(closed_at) = '2026-04-02'
This is the date version of lesson 4’s half-open range, and it’s why a “deals closed on the last day of the quarter” report can come back empty.
Pulling out parts: year, month, weekday
In the sandbox, strftime(format, date) returns any part of a date as text:
SELECT strftime('%Y', '2026-03-15'), -- '2026'
strftime('%m', '2026-03-15'), -- '03'
strftime('%Y-%m', '2026-03-15'); -- '2026-03'
| Code | Gives | Example |
|---|---|---|
%Y |
Year | 2026 |
%m |
Month, 01 to 12 | 03 |
%d |
Day of the month | 15 |
%w |
Day of the week, 0 = Sunday | 0 |
%Y-%m |
Year and month together | 2026-03 |
Grouping by month
This is the most common date query there is. Turn each date into its month, then group:
SELECT strftime('%Y-%m', created_at) AS month,
COUNT(*) AS deals
FROM opportunities
GROUP BY month
ORDER BY month;
Use year and month (%Y-%m), not just %m. Grouping by month alone puts March 2025 and March 2026 in the same group.
Moving a date forward or back
SELECT date('2026-03-15', '+30 days'), -- 2026-04-14
date('2026-03-15', '-1 year'), -- 2025-03-15
date('2026-03-15', 'start of month'); -- 2026-03-01
'start of month' snaps a date back to the first of its month, which is handy for “this month so far” filters.
Watch month ends: adding a month to January 31 has no clean answer, because February has no 31st. The sandbox overflows into March: date('2026-01-31', '+1 month') gives 2026-03-03. Most other databases give February 28. If month-end dates matter, snap to the start of the month first, then add.
Measuring time between dates
In the sandbox, julianday() turns a date into a day count, so subtracting two of them gives the days between:
SELECT opportunity_id,
julianday(closed_at) - julianday(created_at) AS days_with_time,
julianday(date(closed_at)) - julianday(created_at) AS whole_days
FROM opportunities
WHERE closed_at IS NOT NULL;
Deal 1001 was created on 2025-01-24 and closed at 2025-03-24 16:00:00. That’s 59.67 days with the time included, or 59 whole days. Decide which one the report needs. “Days to close” usually means whole days.
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
closed_at = '2026-04-02' on a timestamp |
Matches only midnight, so usually nothing | A half-open range, or compare date(closed_at) |
closed_at BETWEEN '2026-01-01' AND '2026-12-31' |
Misses everything on December 31 after midnight | >= '2026-01-01' AND < '2027-01-01' |
| Grouping by month number alone | Mixes the same month from different years | Group by year and month |
Dates in other formats, like '3/15/2026' |
Sorts and compares as text, so it’s wrong | Store and write 'YYYY-MM-DD' |
| Ignoring time zones | A deal closed at 7 p.m. in Chicago on March 31 is already April 1 in UTC | Find out which zone the system writes before trusting month-end totals |
Dialect notes
The ideas are the same everywhere. The function names aren’t:
| Task | Sandbox (SQLite) | SQL Server | PostgreSQL | MySQL |
|---|---|---|---|---|
| Today | CURRENT_DATE |
CAST(GETDATE() AS DATE) |
CURRENT_DATE |
CURRENT_DATE |
| Year | strftime('%Y', d) |
YEAR(d) |
EXTRACT(YEAR FROM d) |
YEAR(d) |
| First of the month | date(d, 'start of month') |
DATETRUNC(month, d) (2022+) |
date_trunc('month', d) |
DATE_FORMAT(d, '%Y-%m-01') |
As 'YYYY-MM' text |
strftime('%Y-%m', d) |
FORMAT(d, 'yyyy-MM') |
to_char(d, 'YYYY-MM') |
DATE_FORMAT(d, '%Y-%m') |
| Add 30 days | date(d, '+30 days') |
DATEADD(day, 30, d) |
d + INTERVAL '30 days' |
DATE_ADD(d, INTERVAL 30 DAY) |
| Days between | julianday(b) - julianday(a) |
DATEDIFF(day, a, b) |
b::date - a::date |
DATEDIFF(b, a) |
| Just the date of a timestamp | date(ts) |
CAST(ts AS DATE) |
ts::date |
DATE(ts) |
Watch the argument order for days between: SQL Server puts the start first, MySQL puts the end first. Swap them and every result flips sign.
Snowflake notes
| Task | Snowflake |
|---|---|
| Today | CURRENT_DATE |
| First of the month | DATE_TRUNC('month', d) |
| Add 30 days | DATEADD('day', 30, d) |
| Days between | DATEDIFF('day', a, b) |
| A part | EXTRACT(quarter FROM d), or YEAR(d), MONTH(d) |
DATEDIFF counts boundaries crossed, not time elapsed: DATEDIFF('day', '2026-10-31 23:59', '2026-11-01 00:01') is 1, though only two minutes passed. For hours or response times, measure in minutes or seconds and divide.
Snowflake has three timestamp types. TIMESTAMP_NTZ stores no time zone, TIMESTAMP_LTZ shows each viewer their own zone, and TIMESTAMP_TZ keeps the original offset. CONVERT_TIMEZONE('UTC', 'America/Chicago', ts) converts for display.
More in the field guide: Date functions, Dates and timestamps and Elapsed time. Exercise T2 is the timestamp trap in a real report.
Try it
How many deals were created in each month of 2026, and what were they worth? Return the month as YYYY-MM, the number of deals and their total amount, in month order.
Stretch: what’s the average number of whole days to close for each lead source? That’s field guide exercise I2, written for Snowflake. Rewrite it with the sandbox’s date functions.
Write it in the sandbox below, then press Check answer.
SQL sandbox
Run queries against the sample CRM tables (accounts, opportunities,users, contacts and opportunity_history), right in your browser. Nothing you run leaves your computer.