SQL Date Functions: YEAR, MONTH, DATEDIFF & Ranges
On this page 12 sections ▾
- Pulling a date apart: YEAR, MONTH, DAY
- The monthly report: grouping by a date part
- Filtering by date range — the safe pattern
- Today’s date, and the last 30 days
- Shifting a date: adding days and month starts
- The gap between two dates: DATEDIFF and julianday
- Earliest and latest: MIN and MAX
- Cohorts: grouping by year
- Two habits that prevent date bugs
- Practice this topic
- Frequently asked questions
- Related reading
SQL date functions let a query take a date apart into its year, month or day, measure the gap between two dates, shift a date forward or back, and ask for today’s date. Nearly every real question has a time dimension: how many orders this month, who was hired before 2020, how long has each employee been here. This guide covers the small set that appears in almost every query, with the dialect differences flagged where they bite, because date functions are where databases disagree most.
Which database runs these examples
The playground runs SQLite, which has no separate date type: the sample database keeps its dates as ISO text such as '2024-03-15', and SQLite’s own date functions, date(), strftime() and julianday(), work on that text. SQLite has no YEAR(), MONTH() or DAY(), so the playground adds those three as helpers, spelled the way MySQL and SQL Server spell them. Blocks marked not in SQLite show another database’s spelling and will not run here.
Pulling a date apart: YEAR, MONTH, DAY
SELECT order_id, order_date,
YEAR(order_date) AS yr, MONTH(order_date) AS mo, DAY(order_date) AS dy
FROM Orders
ORDER BY order_id
LIMIT 4;
| order_id | order_date | yr | mo | dy |
|---|---|---|---|---|
| 5001 | 2024-01-14 | 2024 | 1 | 14 |
| 5002 | 2024-01-22 | 2024 | 1 | 22 |
| 5003 | 2024-02-02 | 2024 | 2 | 2 |
| 5004 | 2024-02-11 | 2024 | 2 | 11 |
● 4 rows · produced by running this query on the sample database
These three extract one component of a date as a plain number. That number then behaves like any other
value — you can filter on it, sort by it, and, most usefully, group by it. YEAR,
MONTH and DAY are the MySQL and SQL Server names; here they are the
playground’s helpers. SQLite’s own tool is strftime, which formats a date with a
pattern and returns text:
SELECT order_id, order_date,
strftime('%Y', order_date) AS yr_text,
CAST(strftime('%m', order_date) AS INTEGER) AS mo
FROM Orders
ORDER BY order_id
LIMIT 4;
| order_id | order_date | yr_text | mo |
|---|---|---|---|
| 5001 | 2024-01-14 | 2024 | 1 |
| 5002 | 2024-01-22 | 2024 | 1 |
| 5003 | 2024-02-02 | 2024 | 2 |
| 5004 | 2024-02-11 | 2024 | 2 |
● 4 rows · produced by running this query on the sample database
strftime('%Y', ...) gives the text '2024', not the number 2024, so wrap it in
CAST(... AS INTEGER) whenever you compare it with a number or do arithmetic on it. The
concept is the same everywhere; only the spelling moves:
| Database | Expression |
|---|---|
| MySQL | YEAR(order_date) (EXTRACT(YEAR FROM order_date) also works) |
| SQL Server | YEAR(order_date) or DATEPART(year, order_date) |
| PostgreSQL | EXTRACT(YEAR FROM order_date) |
| SQLite | CAST(strftime('%Y', order_date) AS INTEGER) |
| This playground | the SQLite form, or the added YEAR(order_date) |
The monthly report: grouping by a date part
This is the single most common date-function pattern in working SQL — turn each date into its month, then aggregate per month:
SELECT MONTH(order_date) AS order_month, COUNT(*) AS orders, SUM(order_total) AS revenue
FROM Orders
GROUP BY MONTH(order_date)
ORDER BY order_month;
| order_month | orders | revenue |
|---|---|---|
| 1 | 2 | 18285 |
| 2 | 3 | 11202.42 |
| 3 | 4 | 16444.89 |
| 4 | 3 | 16776 |
| 5 | 3 | 15943 |
● 5 rows · produced by running this query on the sample database
One honest caveat: months with no orders simply do not appear, because GROUP BY can only summarise rows that exist. Reports that must show empty months as zero rows need a calendar table or a generated series to join against — a technique worth knowing exists, even before you need it.
A second caveat: every sample order falls in 2024, so grouping by the month alone is safe here. On data
that spans years, group by year and month — GROUP BY YEAR(order_date), MONTH(order_date),
or the month-start date shown further down — or January 2024 and January 2025 land in the same row.
Filtering by date range — the safe pattern
SELECT order_id, order_date, status
FROM Orders
WHERE order_date >= '2024-03-01' AND order_date < '2024-04-01';
Note the shape: inclusive start, exclusive end. Asking for >= March 1 and
< April 1 captures all of March no matter how the column stores time. The tempting
alternative, BETWEEN '2024-03-01' AND '2024-03-31', quietly drops rows stamped
2024-03-31 14:30 the day the column gains a time component — because
'2024-03-31' means midnight, and 14:30 is after midnight. The half-open range never has this
problem, which is why it is the professional habit.
Plain comparisons work on dates too, exactly as they do on numbers. In SQLite, where a date is ISO text, that works because year-month-day text sorts in date order:
SELECT first_name, hire_date
FROM Employees
WHERE hire_date < '2020-01-01'
ORDER BY hire_date;
Today’s date, and the last 30 days
Every database can tell you the current date, and each spells it differently. In SQLite:
SELECT date('now') AS today, CURRENT_DATE AS also_today;
The answer changes every day, which is why this page does not print it. For the time as well, use
datetime('now') or CURRENT_TIMESTAMP. SQLite reports 'now' in UTC,
so for part of the day, depending on your time zone, its “today” is not the date on your
calendar; date('now', 'localtime') gives the local date.
| Database | Today | Date and time now |
|---|---|---|
| SQLite (this playground) | date('now') or CURRENT_DATE | datetime('now') or CURRENT_TIMESTAMP |
| PostgreSQL | CURRENT_DATE | NOW() or CURRENT_TIMESTAMP |
| MySQL | CURDATE() or CURRENT_DATE | NOW() or CURRENT_TIMESTAMP |
| SQL Server | CAST(GETDATE() AS date) | GETDATE() or SYSDATETIME() |
The everyday use is a rolling window, such as orders from the last 30 days:
WHERE order_date >= date('now', '-30 days'). On the sample data that finds nothing,
because every order is from 2024, so the runnable version anchors the window to the latest order
instead of to today:
SELECT order_id, order_date, order_total
FROM Orders
WHERE order_date >= date((SELECT MAX(order_date) FROM Orders), '-30 days')
ORDER BY order_date;
| order_id | order_date | order_total |
|---|---|---|
| 5013 | 2024-05-06 | 5800 |
| 5014 | 2024-05-18 | 1050 |
| 5015 | 2024-05-29 | 9093 |
● 3 rows · produced by running this query on the sample database
The latest order is dated 29 May 2024, so the window opens on 29 April and three orders fall inside it.
The same filter against today is order_date >= CURRENT_DATE - INTERVAL '30 days' in
PostgreSQL, order_date >= CURDATE() - INTERVAL 30 DAY in MySQL and
order_date >= DATEADD(day, -30, CAST(GETDATE() AS date)) in SQL Server.
Shifting a date: adding days and month starts
SQLite moves a date by passing date() a modifier: a signed number and a unit.
SELECT order_id, order_date,
date(order_date, '+7 days') AS follow_up,
date(order_date, '-1 day') AS day_before
FROM Orders
ORDER BY order_id
LIMIT 4;
| order_id | order_date | follow_up | day_before |
|---|---|---|---|
| 5001 | 2024-01-14 | 2024-01-21 | 2024-01-13 |
| 5002 | 2024-01-22 | 2024-01-29 | 2024-01-21 |
| 5003 | 2024-02-02 | 2024-02-09 | 2024-02-01 |
| 5004 | 2024-02-11 | 2024-02-18 | 2024-02-10 |
● 4 rows · produced by running this query on the sample database
| Database | Expression |
|---|---|
| SQLite (this playground) | date(order_date, '+7 days') |
| PostgreSQL | order_date + INTERVAL '7 days' |
| MySQL | DATE_ADD(order_date, INTERVAL 7 DAY) |
| SQL Server | DATEADD(day, 7, order_date) |
Other units work the same way ('+1 month', '-1 year'), but month arithmetic is
where databases disagree. Ask SQLite for 31 January plus one month and it rolls past the short month:
SELECT date('2024-01-31', '+1 month') AS one_month_later;
| one_month_later |
|---|
| 2024-03-02 |
● 1 row · produced by running this query on the sample database
MySQL, PostgreSQL and SQL Server stop at the last day of February (2024-02-29) instead. Neither rule is wrong, but they are different, so test month arithmetic on the 29th, 30th and 31st before trusting it.
The first day of the month
The other everyday shift is back to the first of the month. It turns every date into a month label that sorts correctly and never merges two years:
SELECT strftime('%Y-%m-01', order_date) AS month_start, COUNT(*) AS orders
FROM Orders
GROUP BY strftime('%Y-%m-01', order_date)
ORDER BY month_start;
| month_start | orders |
|---|---|
| 2024-01-01 | 2 |
| 2024-02-01 | 3 |
| 2024-03-01 | 4 |
| 2024-04-01 | 3 |
| 2024-05-01 | 3 |
● 5 rows · produced by running this query on the sample database
strftime('%Y-%m-01', order_date) keeps the year and month and pins the day to 01;
date(order_date, 'start of month') does the same. Elsewhere it is
DATE_TRUNC('month', order_date) in PostgreSQL, DATE_FORMAT(order_date, '%Y-%m-01')
in MySQL, and DATEFROMPARTS(YEAR(order_date), MONTH(order_date), 1) in SQL Server, which also
offers DATETRUNC(month, order_date) from SQL Server 2022.
The gap between two dates: DATEDIFF and julianday
How many days has each employee been here? SQLite has no DATEDIFF. It turns each date into a
day number with julianday(), and the difference between two day numbers is the gap in days:
SELECT first_name, hire_date,
julianday('2026-08-29') - julianday(hire_date) AS days_employed
FROM Employees
ORDER BY days_employed DESC
LIMIT 5;
| first_name | hire_date | days_employed |
|---|---|---|
| Chen | 2016-05-20 | 3753 |
| Tomas | 2017-11-05 | 3219 |
| Ananya | 2018-03-12 | 3092 |
| Marcus | 2019-07-01 | 2616 |
| Priya | 2019-09-30 | 2525 |
● 5 rows · produced by running this query on the sample database
julianday() counts days from a fixed starting point in 4714 BC and returns a real number,
because it can carry a time of day as a fraction. For plain dates the difference is a whole number of
days; if the columns hold times as well, wrap it in ROUND() or
CAST(... AS INTEGER). MySQL and SQL Server call this DATEDIFF, and they put
its arguments in opposite orders:
-- SQL Server: DATEDIFF(unit, start, end)
SELECT TOP 5 first_name, hire_date,
DATEDIFF(day, hire_date, '2026-08-29') AS days_employed
FROM Employees
ORDER BY days_employed DESC;
-- MySQL: DATEDIFF(end, start), always in days
SELECT first_name, hire_date,
DATEDIFF('2026-08-29', hire_date) AS days_employed
FROM Employees
ORDER BY days_employed DESC
LIMIT 5;
Same name, opposite order: MySQL subtracts its second argument from its first, SQL Server subtracts its
second from its third. PostgreSQL has no DATEDIFF and subtracts one date from the other
directly (DATE '2026-08-29' - hire_date). If a “days since” report comes out
negative after a move between databases, check the argument order first.
Earliest and latest: MIN and MAX
MIN and MAX work on dates as well as numbers: the earliest date is the smallest
and the latest is the largest. Per customer, that gives a first-and-latest-order report in one query:
SELECT c.company, MIN(o.order_date) AS first_order, MAX(o.order_date) AS latest_order
FROM Customers c
JOIN Orders o ON o.customer_id = c.customer_id
GROUP BY c.customer_id, c.company
ORDER BY latest_order DESC, c.company;
| company | first_order | latest_order |
|---|---|---|
| Cedar Health | 2024-03-27 | 2024-05-29 |
| Meridian Trading Co. | 2024-01-14 | 2024-05-18 |
| Fjord Analytics | 2024-04-04 | 2024-05-06 |
| Andes Textiles | 2024-03-01 | 2024-04-25 |
| Sakura Logistics | 2024-02-02 | 2024-04-12 |
| Baltic Systems | 2024-03-15 | 2024-03-15 |
| Acme Robotics | 2024-01-22 | 2024-03-08 |
| Kilimanjaro Foods | 2024-02-19 | 2024-02-19 |
● 8 rows · produced by running this query on the sample database
Cedar Health ordered most recently, on 29 May 2024. Baltic Systems and Kilimanjaro Foods ordered only
once, so their first and latest orders are the same day. In SQLite these dates are text, so
MIN and MAX are comparing text here; that gives the right answer only because
year-month-day text sorts in date order, which is one more reason to keep dates in that format.
Cohorts: grouping by year
SELECT YEAR(hire_date) AS hire_year, COUNT(*) AS hires
FROM Employees
GROUP BY YEAR(hire_date)
ORDER BY hire_year;
| hire_year | hires |
|---|---|
| 2016 | 1 |
| 2017 | 1 |
| 2018 | 1 |
| 2019 | 2 |
| 2020 | 2 |
| 2021 | 2 |
| 2022 | 2 |
| 2023 | 1 |
● 8 rows · produced by running this query on the sample database
The same monthly-report pattern at a coarser grain — one row per hiring year. Swap in any date column and any aggregate and this shape produces most of the "X per year" tables you have ever seen in a slide deck.
Try it yourself
Every query marked runs in the playground works on the sample database as-is. Change a range, swap MONTH for YEAR, or try a different date modifier, and watch the report reshape itself.
Open the PlaygroundTwo habits that prevent date bugs
Store dates as dates, write them as ISO
Use a real DATE or TIMESTAMP column where the database has one. SQLite has no
date type at all, which is why the sample database keeps its dates as text in one fixed format. Either
way, write dates as '2024-03-01' (year-month-day): it is the ISO 8601 standard, every database
accepts it, and it sorts correctly even when compared as text. Regional formats like 03/01/2024 mean
different dates in different countries and have no place inside a query.
Prefer half-open ranges over BETWEEN
>= start AND < next-start keeps working when a date column gains a time component,
when months have 28, 30 or 31 days, and when ranges need to chain without overlap. BETWEEN breaks quietly
on all three.
Practice this topic
Reading explains it; solving it is what makes it stick. This exercise uses the monthly-report pattern from this page, and your answer is checked by running it against the same database:
Frequently asked questions
How do I get the year or month out of a date in SQL?
In MySQL and SQL Server, YEAR(column) and MONTH(column) return plain numbers. PostgreSQL spells it EXTRACT(YEAR FROM column). SQLite has neither and uses strftime('%Y', column), which returns text, so wrap it in CAST(... AS INTEGER) when you need a number. This site's playground runs SQLite and adds YEAR, MONTH and DAY as helpers so the MySQL-style examples run.
How do I get today's date in SQL?
CURRENT_DATE works in PostgreSQL, MySQL and SQLite; in SQL Server write CAST(GETDATE() AS date). For the date and time, use NOW() in PostgreSQL and MySQL, GETDATE() in SQL Server, and datetime('now') in SQLite, which reports it in UTC.
Why should I avoid BETWEEN for date ranges?
BETWEEN is inclusive on both ends, and a date literal like '2024-03-31' means midnight at the start of that day. The moment the column carries a time, rows from later that day fall outside the range. The half-open pattern - greater-or-equal to the start, strictly less than the next period's start - never has this problem.
How do I calculate the number of days between two dates?
It depends on the database. SQL Server uses DATEDIFF(day, start, end). MySQL uses DATEDIFF(end, start), which always counts days. PostgreSQL subtracts one date from the other directly. SQLite, which runs this site's playground, has no DATEDIFF: write julianday(end) - julianday(start).
Can I GROUP BY a date function?
Yes. Grouping by a date part, such as MONTH(order_date), YEAR(hire_date) or strftime('%Y-%m', order_date) in SQLite, is how monthly and yearly reports are built. When the data spans several years, group by year and month together, or January of one year merges with January of the next. Groups only appear for values that exist in the data; empty months return no row at all.
What date format should I write in SQL queries?
ISO 8601: 'YYYY-MM-DD', like '2024-03-01'. Every database accepts it, it is unambiguous across countries, and it sorts correctly even as text. Regional formats such as 03/01/2024 are ambiguous and should never appear inside a query.
Do these functions change the stored dates?
No. Like all functions in a SELECT, they only shape the output of the query. The stored values change only if you use the expressions inside an UPDATE.
Related reading
About the author
SQL Practice
SQL Practice publishes free SQL tutorials, an in-browser playground and 42 practice exercises, built so you can run what you read. Every runnable example in our tutorials is executed against the site’s sample database before it is published. Read how we keep tutorials accurate, or report a mistake.