Home Database SQL Date and Time Functions Explained — Filtering, Formatting and Real-World Patterns

SQL Date and Time Functions Explained — Filtering, Formatting and Real-World Patterns

In Plain English 🔥
Imagine your database is a giant filing cabinet where every folder has a timestamp glued to it — the exact moment it was created or updated. SQL date and time functions are the tools you use to ask questions like 'show me every folder from last month' or 'how many days ago was this filed?' Without them, you'd have to pull every single folder out and check the timestamps yourself. They let the database do that heavy lifting for you, instantly.
⚡ Quick Answer
Imagine your database is a giant filing cabinet where every folder has a timestamp glued to it — the exact moment it was created or updated. SQL date and time functions are the tools you use to ask questions like 'show me every folder from last month' or 'how many days ago was this filed?' Without them, you'd have to pull every single folder out and check the timestamps yourself. They let the database do that heavy lifting for you, instantly.

Every serious application tracks time. An e-commerce site needs to know which orders came in today. A SaaS dashboard needs to show signups per week. A payroll system needs to calculate how many days an employee has worked. Dates and times aren't a niche feature of SQL — they're woven into almost every real query that drives a business decision. If you can't slice and dice timestamps, you can't build useful reports, and your application logic leaks into places it doesn't belong.

The problem SQL date functions solve is deceptively simple: raw timestamps are stored as a single value (like '2024-03-15 09:42:11'), but the questions we ask about time are rich and varied. We want to group by month, calculate age in days, find records between two dates, or strip out the time portion to compare only dates. Without built-in functions, you'd either pull millions of rows into your app and filter in code — which is slow and wasteful — or write complicated string manipulation that breaks the moment a format changes.

By the end of this article you'll know how to get the current date and time from the database, calculate differences between dates, format timestamps for display, filter records by date ranges correctly, and truncate dates for grouping in reports. You'll also understand why each function exists and when to reach for it — not just what its syntax looks like.

Getting the Current Date and Time — Your Database's Internal Clock

Every database engine has a built-in clock you can query. This sounds trivial, but it's one of the most important features you'll use. Why? Because it means you never have to pass 'today's date' in from your application. The database knows what time it is, and using its clock keeps your data consistent even if records are inserted by multiple services running in different time zones or on different servers.

In MySQL and MariaDB, NOW() returns the full datetime at the moment the query starts. CURDATE() gives you just the date portion, and CURTIME() gives you just the time. In PostgreSQL, CURRENT_TIMESTAMP and NOW() both work, while CURRENT_DATE and CURRENT_TIME give the split versions. SQL Server uses GETDATE() for the current datetime and CAST(GETDATE() AS DATE) to strip the time.

A critical distinction: NOW() and CURRENT_TIMESTAMP capture the time once when the query begins. If you're inserting a million rows in a loop, every row gets the same timestamp — which is usually what you want for audit purposes. Functions like SYSDATE() in Oracle (or MySQL's SYSDATE()) re-evaluate on every row, which can cause subtle inconsistencies in bulk operations. Stick with NOW() unless you explicitly need per-row timing.

current_datetime_basics.sql · SQL
123456789101112131415161718192021222324
-- ============================================================
-- Demonstrates retrieving the current date and time in MySQL.
-- Run this to understand what each function actually returns.
-- ============================================================

-- Full timestamp: date + time together
SELECT NOW() AS current_datetime;          -- e.g. 2024-03-15 09:42:11

-- Date only — no time portion
SELECT CURDATE() AS today_date;            -- e.g. 2024-03-15

-- Time only — no date portion
SELECT CURTIME() AS current_time;         -- e.g. 09:42:11

-- Practical use: stamping a new user registration
-- The created_at column is set to RIGHT NOW automatically
INSERT INTO user_accounts (username, email, created_at)
VALUES ('sarah_jones', 'sarah@example.com', NOW());

-- Practical use: find all users who registered today
-- CURDATE() strips the time so we compare apples to apples
SELECT username, email, created_at
FROM   user_accounts
WHERE  DATE(created_at) = CURDATE();  -- DATE() strips time from the stored timestamp
▶ Output
current_datetime
---------------------
2024-03-15 09:42:11

today_date
-----------
2024-03-15

current_time
-----------
09:42:11

-- INSERT executes silently (1 row affected)

username | email | created_at
-------------|----------------------|--------------------
sarah_jones | sarah@example.com | 2024-03-15 09:42:11
⚠️
Watch Out: NOW() vs SYSDATE() in MySQLIn MySQL, NOW() returns the timestamp when the statement began — all rows in a batch get the same value. SYSDATE() returns the time at the moment each row is processed. For audit columns like created_at, always use NOW() so a batch insert doesn't produce microsecond differences across rows that look like a sequence but mean nothing.

Calculating Date Differences — How Long Ago, How Many Days Left

Once you can get the current date, the next question is always 'how far away is this other date?' This powers subscription expiry logic, overdue invoice detection, customer churn analysis, and age calculations. The function for this varies by database, but the concept is universal.

In MySQL, DATEDIFF(end_date, start_date) returns the number of days between two dates. It only counts whole days — it ignores the time component. For more granular differences (hours, minutes, seconds), you use TIMESTAMPDIFF(unit, start, end) where unit can be SECOND, MINUTE, HOUR, DAY, MONTH, or YEAR. PostgreSQL handles this more elegantly with the subtraction operator: 'end_date - start_date' returns an INTERVAL, and you extract the part you want. SQL Server uses DATEDIFF(unit, start_date, end_date) — note the argument order is reversed compared to MySQL, which is a classic gotcha.

Real-world pattern: a subscription platform needs to flag accounts where the trial expires within 7 days. You calculate the difference between the expiry date and today, then filter where that result is between 0 and 7. You'd run this as a scheduled daily query to feed a notification queue. This kind of date math is core to keeping any recurring-revenue business healthy.

date_difference_calculations.sql · SQL
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
-- ============================================================
-- Real-world subscription expiry and overdue invoice queries.
-- Uses MySQL syntax. PostgreSQL notes included in comments.
-- ============================================================

-- Sample data context:
-- subscriptions table: id, customer_name, trial_start, trial_end
-- invoices table: id, customer_id, amount_due, due_date, paid_at

-- -------------------------------------------------------
-- 1. Find trials expiring in the next 7 days (send a reminder)
-- -------------------------------------------------------
SELECT
    customer_name,
    trial_end,
    DATEDIFF(trial_end, CURDATE()) AS days_remaining  -- positive = future, 0 = today, negative = already expired
FROM subscriptions
WHERE DATEDIFF(trial_end, CURDATE()) BETWEEN 0 AND 7
ORDER BY days_remaining ASC;  -- most urgent first

-- -------------------------------------------------------
-- 2. Find overdue invoices and how many days overdue they are
-- -------------------------------------------------------
SELECT
    i.id                          AS invoice_id,
    c.company_name,
    i.amount_due,
    i.due_date,
    DATEDIFF(CURDATE(), i.due_date) AS days_overdue  -- note argument order: (later, earlier) = positive number
FROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.paid_at IS NULL                             -- not yet paid
  AND i.due_date < CURDATE()                        -- past the due date
ORDER BY days_overdue DESC;                         -- worst offenders first

-- -------------------------------------------------------
-- 3. Calculate customer account age in years and months
-- TIMESTAMPDIFF lets you choose the unit of measurement
-- -------------------------------------------------------
SELECT
    username,
    created_at,
    TIMESTAMPDIFF(YEAR,  created_at, NOW()) AS account_age_years,
    TIMESTAMPDIFF(MONTH, created_at, NOW()) AS account_age_months  -- total months, not just the leftover
FROM user_accounts
ORDER BY created_at ASC;

-- PostgreSQL equivalent for days_overdue (uses interval subtraction):
-- SELECT id, CURRENT_DATE - due_date AS days_overdue FROM invoices WHERE paid_at IS NULL;
▶ Output
-- Query 1: Trials expiring soon
customer_name | trial_end | days_remaining
----------------|-------------|---------------
Acme Corp | 2024-03-15 | 0
Blue Ridge LLC | 2024-03-18 | 3
Nova Systems | 2024-03-22 | 7

-- Query 2: Overdue invoices
invoice_id | company_name | amount_due | due_date | days_overdue
-----------|----------------|------------|------------|-------------
1042 | Stark Ind. | 5200.00 | 2024-02-01 | 43
1089 | Wayne Corp | 870.50 | 2024-02-28 | 16

-- Query 3: Account ages
username | created_at | account_age_years | account_age_months
-------------|---------------------|-------------------|-------------------
john_doe | 2021-01-10 08:00:00 | 3 | 38
sarah_jones | 2024-03-15 09:42:11 | 0 | 0
⚠️
Pro Tip: Argument Order Is a Silent KillerMySQL's DATEDIFF(end, start) and SQL Server's DATEDIFF(unit, start, end) have reversed argument orders. If you mix them up, you get negative numbers where you expect positive, and your 'overdue' filter silently shows nothing. Always sanity-check with a known date pair before deploying date-diff logic to production.

Extracting and Truncating Dates — The Secret to Clean Reports

Reporting is where date functions earn their keep. When a product manager asks 'show me signups per month for the last year', your raw created_at column has thousands of unique timestamps — one per user. You need to collapse them into monthly buckets. That's what extraction and truncation are for.

EXTRACT(part FROM date) — available in MySQL, PostgreSQL, and SQL Server (as DATEPART) — pulls out a single component: the year, month, day, hour, etc. It returns a number, which makes it perfect for GROUP BY clauses. DATE_TRUNC('month', timestamp) in PostgreSQL (and DATE_FORMAT in MySQL) rounds a timestamp down to the start of a period — so '2024-03-15 09:42:11' becomes '2024-03-01 00:00:00'. This is more powerful for grouping because you keep a valid date value instead of just a number, which means your charting tools and ORDER BY clauses work correctly without extra manipulation.

In MySQL, there's no DATE_TRUNC, so the idiomatic equivalent is DATE_FORMAT(created_at, '%Y-%m-01') — format the date but hardcode the day as 01. It's a bit of a hack but it's universally used in MySQL shops. Understanding both approaches makes you fluent across database engines, which interviewers love.

date_grouping_and_reporting.sql · SQL
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
-- ============================================================
-- Monthly signup report and weekly revenue aggregation.
-- Shows both MySQL and PostgreSQL approaches side by side.
-- ============================================================

-- -------------------------------------------------------
-- MySQL: Signups grouped by month (last 12 months)
-- DATE_FORMAT with '%Y-%m-01' normalises all days to the 1st
-- so every row in a calendar month gets the same bucket key
-- -------------------------------------------------------
SELECT
    DATE_FORMAT(created_at, '%Y-%m-01')   AS month_start,    -- '2024-02-01', '2024-03-01', etc.
    DATE_FORMAT(created_at, '%M %Y')      AS month_label,    -- 'February 2024' — human-friendly for reports
    COUNT(*)                              AS new_signups,
    SUM(CASE WHEN plan_type = 'paid' THEN 1 ELSE 0 END) AS paid_signups  -- break out paid vs free
FROM user_accounts
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH)  -- rolling 12-month window
GROUP BY DATE_FORMAT(created_at, '%Y-%m-01')                -- group by the normalised bucket
ORDER BY month_start ASC;                                   -- chronological order

-- -------------------------------------------------------
-- PostgreSQL equivalent using DATE_TRUNC (cleaner syntax)
-- DATE_TRUNC rounds DOWN to the start of the given period
-- -------------------------------------------------------
-- SELECT
--     DATE_TRUNC('month', created_at) AS month_start,
--     COUNT(*) AS new_signups
-- FROM user_accounts
-- WHERE created_at >= NOW() - INTERVAL '12 months'
-- GROUP BY DATE_TRUNC('month', created_at)
-- ORDER BY month_start ASC;

-- -------------------------------------------------------
-- EXTRACT: Pull individual components for pivot-style reports
-- Useful when you want day-of-week or hour-of-day analysis
-- -------------------------------------------------------
SELECT
    EXTRACT(HOUR FROM order_placed_at)  AS hour_of_day,     -- 0–23
    DAYNAME(order_placed_at)            AS day_name,         -- 'Monday', 'Tuesday', etc. (MySQL)
    COUNT(*)                            AS total_orders,
    ROUND(AVG(order_total), 2)          AS avg_order_value
FROM orders
WHERE order_placed_at >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY EXTRACT(HOUR FROM order_placed_at), DAYNAME(order_placed_at)
ORDER BY total_orders DESC
LIMIT 10;  -- top 10 busiest hour/day combinations
▶ Output
-- Monthly signup report
month_start | month_label | new_signups | paid_signups
-------------|----------------|-------------|-------------
2023-04-01 | April 2023 | 312 | 88
2023-05-01 | May 2023 | 401 | 119
... | ... | ... | ...
2024-03-01 | March 2024 | 587 | 201

-- Busiest hours/days
hour_of_day | day_name | total_orders | avg_order_value
------------|-----------|--------------|----------------
12 | Friday | 4821 | 47.30
13 | Friday | 4602 | 51.15
11 | Saturday | 4398 | 43.80
19 | Sunday | 3971 | 62.40
🔥
Interview Gold: DATE_TRUNC vs EXTRACTDATE_TRUNC returns a full timestamp rounded to a period boundary — ideal for GROUP BY because the result is sortable as a date. EXTRACT returns a plain integer — great for filtering or pivoting on a specific component like hour or month number, but you lose the year context (month 3 could be March of any year). Knowing when to use each shows real query design maturity.

Filtering by Date Ranges — The Right Way to Query Time Windows

Filtering by date is where most SQL bugs in production originate. The query looks right, the logic sounds right, but the results are subtly wrong — usually because of how datetime precision interacts with comparison operators.

The safest, most portable pattern for filtering a date range is a half-open interval: WHERE created_at >= '2024-03-01' AND created_at < '2024-04-01'. This includes every record from the first millisecond of March through the last moment of March, without accidentally including midnight on April 1st. Using BETWEEN with a full date like '2024-03-31' is dangerous because BETWEEN is inclusive — on a datetime column it only captures records up to '2024-03-31 00:00:00', silently missing everything from 00:00:01 onwards.

For dynamic rolling windows — 'the last 30 days', 'the last 7 days' — use DATE_SUB in MySQL or interval arithmetic in PostgreSQL. The key insight is to keep your filter on the raw column, not on a function applied to it. Writing WHERE DATE(created_at) = CURDATE() forces a full table scan because the database can't use an index on a transformed column. Writing WHERE created_at >= CURDATE() AND created_at < DATE_ADD(CURDATE(), INTERVAL 1 DAY) keeps the left side clean and lets your index do its job.

date_range_filtering.sql · SQL
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
-- ============================================================
-- Safe, index-friendly date range filtering patterns.
-- These patterns work across MySQL, PostgreSQL, and SQL Server.
-- ============================================================

-- -------------------------------------------------------
-- PATTERN 1: Specific calendar month — use half-open interval
-- BETWEEN '2024-03-01' AND '2024-03-31' MISSES the last 23h 59m 59s
-- This pattern captures every row in March, no matter the time
-- -------------------------------------------------------
SELECT
    order_id,
    customer_id,
    order_total,
    order_placed_at
FROM orders
WHERE order_placed_at >= '2024-03-01 00:00:00'   -- first moment of March
  AND order_placed_at <  '2024-04-01 00:00:00'   -- stops BEFORE April (half-open)
ORDER BY order_placed_at DESC;

-- -------------------------------------------------------
-- PATTERN 2: Rolling window — last 30 days from right now
-- DATE_SUB dynamically calculates the window start
-- Index-friendly because the column is untouched on the left side
-- -------------------------------------------------------
SELECT
    customer_id,
    COUNT(*)          AS orders_last_30_days,
    SUM(order_total)  AS revenue_last_30_days
FROM orders
WHERE order_placed_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)  -- 30 days ago to now
GROUP BY customer_id
HAVING COUNT(*) >= 3  -- customers with at least 3 orders — loyalty segment
ORDER BY revenue_last_30_days DESC;

-- -------------------------------------------------------
-- PATTERN 3: Today only — the right and WRONG way
-- WRONG (breaks index): WHERE DATE(created_at) = CURDATE()
-- RIGHT (index-friendly): use explicit range
-- -------------------------------------------------------

-- RIGHT WAY — the database can use an index on created_at
SELECT COUNT(*) AS signups_today
FROM user_accounts
WHERE created_at >= CURDATE()                              -- start of today (midnight)
  AND created_at <  DATE_ADD(CURDATE(), INTERVAL 1 DAY);  -- start of tomorrow

-- -------------------------------------------------------
-- PATTERN 4: Year-to-date (Jan 1 of current year to now)
-- -------------------------------------------------------
SELECT
    MONTHNAME(order_placed_at)   AS month_name,
    COUNT(*)                     AS total_orders,
    SUM(order_total)             AS total_revenue
FROM orders
WHERE order_placed_at >= DATE_FORMAT(CURDATE(), '%Y-01-01')  -- Jan 1 of current year
  AND order_placed_at <  NOW()                               -- up to this moment
GROUP BY MONTH(order_placed_at), MONTHNAME(order_placed_at)
ORDER BY MONTH(order_placed_at) ASC;
▶ Output
-- Pattern 1: March 2024 orders
order_id | customer_id | order_total | order_placed_at
---------|-------------|-------------|--------------------
20481 | C-00312 | 149.99 | 2024-03-31 23:58:01
20480 | C-00089 | 67.50 | 2024-03-31 22:10:44
... | ... | ... | ...

-- Pattern 2: Top customers last 30 days
customer_id | orders_last_30_days | revenue_last_30_days
------------|---------------------|---------------------
C-00089 | 7 | 891.40
C-00312 | 5 | 612.00

-- Pattern 3: Signups today
signups_today
--------------
47

-- Pattern 4: Year-to-date by month
month_name | total_orders | total_revenue
-----------|--------------|---------------
January | 1842 | 94210.50
February | 2103 | 107845.00
March | 1491 | 76320.75
⚠️
Watch Out: Wrapping Columns in Functions Kills PerformanceWHERE DATE(created_at) = CURDATE() looks clean but wraps your indexed column in a function call. The database can't use the index anymore and scans every row. On a table with 10 million records, this turns a 2ms query into a 45-second timeout. Always put date math on the right side of the comparison, leaving your column bare on the left.
Function / FeatureMySQLPostgreSQLSQL Server
Current timestampNOW()NOW() / CURRENT_TIMESTAMPGETDATE() / SYSDATETIME()
Current date onlyCURDATE()CURRENT_DATECAST(GETDATE() AS DATE)
Difference in daysDATEDIFF(end, start)end_date - start_date (returns interval)DATEDIFF(day, start, end)
Difference with unitTIMESTAMPDIFF(unit, start, end)EXTRACT(unit FROM end - start)DATEDIFF(unit, start, end)
Truncate to periodDATE_FORMAT(col, '%Y-%m-01')DATE_TRUNC('month', col)DATETRUNC('month', col) (SQL Server 2022+)
Extract componentEXTRACT(YEAR FROM col) / YEAR(col)EXTRACT(YEAR FROM col)DATEPART(year, col)
Add/subtract intervalDATE_ADD(col, INTERVAL 7 DAY)col + INTERVAL '7 days'DATEADD(day, 7, col)
Format for displayDATE_FORMAT(col, '%d/%m/%Y')TO_CHAR(col, 'DD/MM/YYYY')FORMAT(col, 'dd/MM/yyyy')
Argument order trapDATEDIFF(end, start)N/A (uses subtraction)DATEDIFF(unit, start, end) — reversed!

🎯 Key Takeaways

  • Use NOW() over SYSDATE() for batch inserts — NOW() captures the statement start time so all rows in a batch get an identical, consistent timestamp instead of microsecond noise.
  • Half-open intervals (>= start AND < next_period) are the safest date range pattern — BETWEEN with datetime columns silently misses everything after midnight on the end date.
  • Never wrap your indexed datetime column in a function on the left side of a WHERE clause — DATE(col) = today kills your index; col >= today AND col < tomorrow preserves it.
  • DATEDIFF argument order is flipped between MySQL and SQL Server — MySQL takes (end, start) while SQL Server takes (unit, start, end). This is one of the most common bugs when migrating queries between engines.

⚠ Common Mistakes to Avoid

  • Mistake 1: Using BETWEEN with a full datetime column — WHERE order_date BETWEEN '2024-03-01' AND '2024-03-31' silently excludes every record on March 31st after midnight (00:00:00) because BETWEEN is inclusive at exactly '2024-03-31 00:00:00' but not later — Fix it: use the half-open interval pattern: WHERE order_date >= '2024-03-01' AND order_date < '2024-04-01'.
  • Mistake 2: Wrapping indexed columns in date functions — WHERE YEAR(created_at) = 2024 or WHERE DATE(created_at) = CURDATE() forces a full table scan because MySQL can't use a B-tree index on a transformed value — Symptom is queries that are fast on dev with 1,000 rows and catastrophically slow on production with 10 million rows — Fix it: rewrite as WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' so the column is bare and the index is used.
  • Mistake 3: Confusing DATEDIFF argument order between MySQL and SQL Server — In MySQL it's DATEDIFF(end_date, start_date) and in SQL Server it's DATEDIFF(unit, start_date, end_date) — Symptom is negative numbers where you expect positive, or a syntax error about missing the unit argument — Fix it: always test with a known pair like DATEDIFF('2024-03-15', '2024-03-01') (MySQL, should return 14) before deploying, and add a comment noting the engine-specific order.

Interview Questions on This Topic

  • QYou need to report total revenue grouped by week for the past quarter. Walk me through how you'd write that query, and explain what pitfalls you'd watch out for.
  • QWhat's the difference between DATE_TRUNC and EXTRACT in PostgreSQL, and when would you use one over the other in a GROUP BY clause?
  • QA developer complains that a date-filter query is running slowly despite an index on the created_at column. They show you: WHERE DATE(created_at) = CURDATE(). What's wrong and how do you fix it?

Frequently Asked Questions

How do I filter SQL records from the last 30 days?

Use WHERE your_date_column >= DATE_SUB(NOW(), INTERVAL 30 DAY) in MySQL, or WHERE your_date_column >= NOW() - INTERVAL '30 days' in PostgreSQL. Keep the column bare on the left side of the comparison so the database can use an index on it — don't wrap it in DATE() or any other function.

What is the difference between DATE() and DATETIME in SQL?

DATE stores only the calendar date (e.g., 2024-03-15) with no time component, while DATETIME stores both date and time (e.g., 2024-03-15 09:42:11). When you compare a DATETIME column to a plain date string, the database implicitly assumes midnight (00:00:00), which can cause you to miss records later that same day. Always use explicit time components or the half-open interval pattern when filtering DATETIME columns.

Why does grouping by month in SQL sometimes show wrong results?

The classic trap is using EXTRACT(MONTH FROM date_column) alone — month 3 appears for March 2023 and March 2024 combined, because you're discarding the year. Always group by both year and month together: GROUP BY EXTRACT(YEAR FROM col), EXTRACT(MONTH FROM col), or use DATE_TRUNC('month', col) in PostgreSQL which preserves the full year-month boundary as a proper date value.

🔥
TheCodeForge Editorial Team Verified Author

Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.

← PreviousTypeORM BasicsNext →Partitioning Tables in SQL
Forged with 🔥 at TheCodeForge.io — Where Developers Are Forged