Home›Database›SQL Date Range Bug - BETWEEN Missed 1,847 Orders
Intermediate
9 min · March 06, 2026
SQL Date and Time Functions
SQL Date Range Bug - BETWEEN Missed 1,847 Orders
The BETWEEN TIMESTAMP bug cost 1,847 monthly orders - here's the exact alternative, index-friendly pattern with >= and < that preserves index performance..
N
NarenFounder & Principal Engineer
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
CURRENT_TIMESTAMP / NOW() returns the current date and time in the session timezone
Date arithmetic: DATEADD/DATE_ADD adds intervals; DATEDIFF computes the difference between two dates
DATE_TRUNC (PostgreSQL) / DATE_FORMAT (MySQL) truncates a timestamp to a period for grouping reports
EXTRACT or YEAR/MONTH/DAY functions pull components from a date — but they disable index usage
Filter date ranges with >= and < on the raw column, never with BETWEEN on dates with times
Biggest mistake: BETWEEN with a datetime column — BETWEEN '2026-01-01' AND '2026-01-31' misses records at 2026-01-31 23:59:59
✦ Definition~90s read
What is SQL Date and Time Functions?
SQL date and time functions are the built-in tools every database provides for handling temporal data — and they're the single most common source of silent, expensive bugs in production reporting. The infamous BETWEEN operator on a DATETIME column, for example, looks innocent but excludes the final second of a day, causing off-by-one errors that can miss thousands of orders.
★
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.
This article dissects a real-world case where that exact mistake cost a company over $1M in misreported revenue. You'll learn why functions like NOW(), DATE_TRUNC(), DATEDIFF(), and INTERVAL exist, how they map to your database's internal clock, and — critically — when they'll betray you if used carelessly.
These functions solve a fundamental problem: databases store timestamps as absolute points in time (often UTC), but humans think in relative terms like 'yesterday' or 'last quarter.' Without explicit date arithmetic and truncation, queries like WHERE order_date BETWEEN '2024-01-01' AND '2024-01-31' silently drop orders placed at 2024-01-31 23:59:59. The fix isn't to avoid BETWEEN entirely — it's to understand that BETWEEN is inclusive on both ends, so you must either use a half-open interval (>= and <) or truncate timestamps to midnight.
This article walks through each function category with concrete SQL examples for PostgreSQL, MySQL, and BigQuery, showing exactly how to avoid the $1M bug.
In the ecosystem, SQL date functions are your only option for server-side temporal logic — application-level date handling introduces timezone drift and latency. But they're not a silver bullet: if you need complex calendar logic (e.g., business days, holiday adjustments), you're better off with a dedicated time-series database like TimescaleDB or a library like Python's pandas.
For 95% of reporting queries, however, mastering DATE_TRUNC, INTERVAL, and proper range filters eliminates the most common class of data integrity bugs. This article gives you the patterns to stop writing wrong queries for 'last month' and start trusting your reports.
Plain-English First
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.
Why SQL Date Functions Are the Root of a $1M Reporting Bug
SQL date/time functions let you manipulate, compare, and extract parts of temporal data — but they're not magic. The core mechanic is that every date/time value is stored as an internal numeric representation (e.g., days since epoch, seconds since 1970-01-01). Functions like DATE(), EXTRACT(), or DATE_TRUNC() convert, truncate, or decompose these values. The trap: implicit type coercion and timezone handling silently change what you're comparing.
In practice, BETWEEN is inclusive on both ends, so '2023-01-01' BETWEEN '2023-01-01' AND '2023-01-02' returns true. But if your column is a TIMESTAMP with a time component, '2023-01-01 00:00:00' is not the same as '2023-01-01'. The query WHERE order_date BETWEEN '2023-01-01' AND '2023-01-02' misses orders placed on 2023-01-02 at any time after midnight because the upper bound is midnight of that day, not the end of the day.
Use date functions when you need to group by day, filter by month, or join on date parts. But never use BETWEEN with TIMESTAMP columns for date-range filtering — it's a bug factory. Instead, use >= start_date AND < end_date + INTERVAL '1 day' to get a true day range. This pattern prevents off-by-one errors that silently drop thousands of rows.
⚠ BETWEEN Is Not Your Friend for Dates
BETWEEN with TIMESTAMP columns excludes the end-of-day boundary. Always use half-open intervals: >= start AND < end + 1 day.
📊 Production Insight
E-commerce reporting dashboard showed 1,847 fewer orders than actual for a 30-day window. Root cause: BETWEEN '2023-01-01' AND '2023-01-30' excluded all orders on Jan 30 after 00:00:00. Symptom: daily totals matched for days 1-29, but the last day's count was zero. Rule: for date-only ranges, cast to DATE or use half-open intervals.
🎯 Key Takeaway
Never use BETWEEN with TIMESTAMP columns for date-range filtering — it's an off-by-one bug.
Always prefer half-open intervals: >= start AND < end + INTERVAL '1 day'.
Cast to DATE explicitly if you only care about the date part, not the time.
thecodeforge.io
Sql Date Time Functions
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- ============================================================-- Demonstrates retrieving the current date and time in MySQL.-- Run this to understand what each function actually returns.-- ============================================================-- Full timestamp: date + time togetherSELECTNOW() AS current_datetime; -- e.g. 2024-03-15 09:42:11-- Date only — no time portionSELECTCURDATE() AS today_date; -- e.g. 2024-03-15-- Time only — no date portionSELECTCURTIME() AS current_time; -- e.g. 09:42:11-- Practical use: stamping a new user registration-- The created_at column is set to RIGHT NOW automaticallyINSERTINTOuser_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 applesSELECT username, email, created_at
FROM user_accounts
WHEREDATE(created_at) = CURDATE(); -- DATE() strips time from the stored timestamp
In 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.
📊 Production Insight
NOW() and CURRENT_TIMESTAMP capture the transaction start time in PostgreSQL — the same timestamp for all statements in one transaction.
CLOCK_TIMESTAMP() captures the current wall clock time — different for each call within a transaction.
For audit timestamps that must be consistent within a transaction, use NOW(); for elapsed time measurement, use CLOCK_TIMESTAMP().
🎯 Key Takeaway
NOW() returns the transaction start time — consistent within a multi-statement transaction.
For current wall clock time inside a transaction, use CLOCK_TIMESTAMP() (PostgreSQL).
Always store timestamps in UTC and convert to local time at the application layer.
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
-- ============================================================-- 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 expiredFROM subscriptions
WHEREDATEDIFF(trial_end, CURDATE()) BETWEEN0AND7ORDERBY 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 numberFROM invoices i
JOIN customers c ON c.id = i.customer_id
WHERE i.paid_at ISNULL-- not yet paidAND i.due_date < CURDATE() -- past the due dateORDERBY 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 leftoverFROM user_accounts
ORDERBY 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;
MySQL'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.
📊 Production Insight
Date arithmetic syntax varies significantly across databases: DATEADD (SQL Server), DATE_ADD (MySQL), + INTERVAL (PostgreSQL).
For portable date arithmetic, consider computing date ranges in application code and passing them as parameters — avoids dialect-specific SQL.
AGE() in PostgreSQL computes human-readable intervals (2 years 3 months 12 days) — more readable than raw DATEDIFF for display purposes.
🎯 Key Takeaway
Date arithmetic syntax is not portable across databases — use parameterized date ranges from application code for portability.
DATEDIFF returns an integer difference in days by default — for business day calculations, you need a calendar table.
For subscription billing, use ADD_MONTHS or interval arithmetic to avoid month-length bugs.
thecodeforge.io
Sql Date Time Functions
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
-- ============================================================-- 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 reportsCOUNT(*) AS new_signups,
SUM(CASEWHEN plan_type = 'paid' THEN1ELSE0END) AS paid_signups -- break out paid vs freeFROM user_accounts
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL12MONTH) -- rolling 12-month windowGROUPBY DATE_FORMAT(created_at, '%Y-%m-01') -- group by the normalised bucketORDERBY 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-- -------------------------------------------------------SELECTEXTRACT(HOURFROM order_placed_at) AS hour_of_day, -- 0–23DAYNAME(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(), INTERVAL90DAY)
GROUPBYEXTRACT(HOURFROM order_placed_at), DAYNAME(order_placed_at)
ORDERBY total_orders DESCLIMIT10; -- top 10 busiest hour/day combinations
DATE_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.
📊 Production Insight
DATE_TRUNC('month', ts) groups all timestamps in a month to the month start — the standard pattern for monthly trend reports.
EXTRACT(YEAR FROM ts) = 2026 disables index usage on ts — replace with a range predicate.
For weekly reporting, DATE_TRUNC('week', ts) uses ISO week start (Monday); DATEPART(weekday) uses Sunday in SQL Server — verify calendar convention.
🎯 Key Takeaway
DATE_TRUNC is the correct grouping function for time-series reports — use it in GROUP BY for monthly/weekly aggregations.
EXTRACT in WHERE disables index usage — always use range predicates (>= and <) on the raw timestamp column.
For consistent weekly reports, explicit DATE_TRUNC('week', ts) is more reliable than DATEPART/DAYOFWEEK which varies by database.
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.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
-- ============================================================-- 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-0100:00:00' -- first moment of MarchAND order_placed_at < '2024-04-0100:00:00' -- stops BEFORE April (half-open)ORDERBY 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(), INTERVAL30DAY) -- 30 days ago to nowGROUPBY customer_id
HAVINGCOUNT(*) >= 3-- customers with at least 3 orders — loyalty segmentORDERBY 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_atSELECTCOUNT(*) AS signups_today
FROM user_accounts
WHERE created_at >= CURDATE() -- start of today (midnight)AND created_at < DATE_ADD(CURDATE(), INTERVAL1DAY); -- start of tomorrow-- --------------------------------------------------------- PATTERN 4: Year-to-date (Jan 1 of current year to now)-- -------------------------------------------------------SELECTMONTHNAME(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 yearAND order_placed_at < NOW() -- up to this momentGROUPBYMONTH(order_placed_at), MONTHNAME(order_placed_at)
ORDERBYMONTH(order_placed_at) ASC;
⚠ Watch Out: Wrapping Columns in Functions Kills Performance
WHERE 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.
📊 Production Insight
BETWEEN with TIMESTAMP columns is a production bug waiting to happen — the upper bound is treated as midnight, missing the rest of that day.
The correct pattern: WHERE ts >= '2026-01-01' AND ts < '2026-02-01' — >= lower bound, < exclusive upper bound.
Test every date range query by checking SELECT MIN(ts), MAX(ts) to verify the bounds capture exactly the expected period.
🎯 Key Takeaway
Never use BETWEEN with TIMESTAMP columns — the upper bound excludes all times after midnight.
Correct date range pattern: WHERE ts >= start_date AND ts < day_after_end_date.
Test date range queries with MIN/MAX to verify you're capturing the intended period.
Date Arithmetic with INTERVAL — Stop Writing Wrong Queries for 'Last Month'
You've seen it a hundred times:WHERE placed_at >= NOW() - INTERVAL 30 DAY. Works until months with 31 days break your report. Then someone 'fixes' it with DATE_SUB(NOW(), INTERVAL 1 MONTH). That's better. But do you know why?
INTERVAL isn't magic syntax sugar. It's the database telling you: 'I will handle calendar math correctly.' Adding 1 month to January 31st doesn't give you February 31st — the engine knows February has 28 or 29 days. It clamps to the last valid day. That's the entire point.
The mistake? Using INTERVAL with CURRENT_DATE when you meant NOW(). CURRENT_DATE strips time. So CURRENT_DATE - INTERVAL 1 DAY gives you midnight of yesterday, not '24 hours ago.' If your report runs at 3 PM, you just excluded 15 hours of data. Use NOW() for rolling windows, CURRENT_DATE for calendar-day boundaries.
And for the love of god, don't do WHERE DATEDIFF(NOW(), placed_at) < 30. That's a full table scan every time. Use a sargable filter with INTERVAL and an index on placed_at.
RollingWindowQuery.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// io.thecodeforge — database tutorial
-- Correct: sargable, uses index on placed_atSELECT order_id, placed_at, total_amount
FROM orders
WHERE placed_at >= NOW() - INTERVAL'30 days'AND placed_at < NOW();
-- Wrong: full scan, breaks on month boundariesSELECT order_id, placed_at, total_amount
FROM orders
WHEREDATEDIFF(NOW(), placed_at) < 30;
-- Never: assumes 30 days is 'last month'SELECT order_id, placed_at, total_amount
FROM orders
WHEREEXTRACT(MONTHFROM placed_at) = EXTRACT(MONTHFROMNOW()) - 1;
-- Correct calendar month: use INTERVAL 1 MONTHSELECT order_id, placed_at, total_amount
FROM orders
WHERE placed_at >= DATE_TRUNC('month', NOW()) - INTERVAL'1 month'AND placed_at < DATE_TRUNC('month', NOW());
Output
order_id | placed_at | total_amount
--------|-------------------|-------------
1042 | 2024-05-14 08:23:00 | 142.50
1047 | 2024-05-22 14:11:00 | 89.99
1053 | 2024-05-28 09:45:00 | 210.00
⚠ Production Trap:
PostgreSQL INTERVAL syntax requires single quotes: INTERVAL '1 month'. MySQL allows both INTERVAL 1 MONTH (no quotes) and INTERVAL '1' MONTH. Pick one per database and stick to it. Mixing them causes silent failures.
🎯 Key Takeaway
Always use sargable date filters with INTERVAL and indexed timestamp columns. Never filter by EXTRACT or DATEDIFF in WHERE clauses.
Extracting Parts of a Date — When You Need the Month, Not the Moment
Reporting by quarter? Grouping revenue by month? You need to rip the date apart. EXTRACT is your standard SQL tool for this. EXTRACT(YEAR FROM placed_at) returns 2024. EXTRACT(MONTH FROM placed_at) returns 5 for May. It's clean, portable, and works everywhere.
But here's where PostgreSQL flexes:DATE_PART('month', placed_at) does the same thing. Why two functions for the same job? Legacy. DATE_PART is older, EXTRACT is standard. Use EXTRACT. Your future self won't have to explain to a MySQL dev what DATE_PART does.
MySQL people get MONTH(placed_at), YEAR(placed_at), DAY(placed_at). Shorter, yes. But they also get DATE_FORMAT for string output, which PostgreSQL handles with TO_CHAR. The portability cost is real.
The rookie mistake? Grouping by EXTRACT(MONTH FROM placed_at) but forgetting to also group by the year. Congratulations, your January 2023 and January 2024 revenue are now merged into one bucket. Always extract year and month together, or use DATE_TRUNC('month', placed_at) which preserves the timestamp type and sorts correctly across year boundaries.
MonthlyRevenueReport.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// io.thecodeforge — database tutorial
-- WRONG: no year → merges January across yearsSELECTEXTRACT(MONTHFROM placed_at) AS month_num,
SUM(total_amount) AS revenue
FROM orders
GROUPBYEXTRACT(MONTHFROM placed_at);
-- CORRECT: group by truncated monthSELECT
DATE_TRUNC('month', placed_at) AS month_start,
SUM(total_amount) AS revenue
FROM orders
WHERE placed_at >= '2024-01-01'AND placed_at < '2025-01-01'GROUPBY DATE_TRUNC('month', placed_at)
ORDERBY month_start;
Output
month_start | revenue
--------------------|---------
2024-01-01 00:00:00 | 45230.00
2024-02-01 00:00:00 | 38910.50
2024-03-01 00:00:00 | 50120.75
2024-04-01 00:00:00 | 47800.00
💡Senior Shortcut:
Use DATE_TRUNC for grouping, never EXTRACT. DATE_TRUNC returns a timestamp that sorts and compares correctly. EXTRACT returns an integer — useless for ORDER BY across year boundaries without extra concatenation.
🎯 Key Takeaway
Group time-series data with DATE_TRUNC, not EXTRACT. Always include year when extracting month parts to avoid cross-year data merges.
8. DATE_FORMAT() — Why Your Reports Look Like Timestamps Instead of Dates
Raw timestamps are for machines. Humans need 'Mar 15, 2024' or '2024-Q1'. DATE_FORMAT() is the only portable way to transform a datetime into any string shape your business requires. Understand WHY: CAST(date AS VARCHAR) is database-specific and loses timezone awareness. DATE_FORMAT() gives you explicit control over each component—month name, zero-padded day, 12-hour clock—and works identically across MySQL, MariaDB, and many other SQL engines. The first argument is your date column; the second is a format string built from specifiers like %Y (four-digit year), %b (abbreviated month), and %H (24-hour hour). Missing this function forces you to write convoluted CONCAT expressions that break when daylight saving time shifts. Use DATE_FORMAT(billing_date, '%Y-%m-%d') for ISO compliance, or DATE_FORMAT(created_at, '%M %D, %Y') for executive summaries.
Example.sqlSQL
1
2
3
4
5
6
7
8
9
// io.thecodeforge — database tutorial
SELECT
id,
DATE_FORMAT(submitted_at, '%W, %M %e, %Y') AS human_date
FROM orders
WHERE status = 'shipped'LIMIT5;
Output
+----+----------------------+
| id | human_date |
+----+----------------------+
| 1 | Monday, March 4, 2024 |
| 2 | Tuesday, March 5, 2024|
| 3 | Wednesday, March 6, 2024|
| 4 | Thursday, March 7, 2024|
| 5 | Friday, March 8, 2024 |
+----+----------------------+
⚠ Production Trap:
%i is minutes, %m is months—one wrong letter silently corrupts your entire report.
🎯 Key Takeaway
Always use DATE_FORMAT() for human-readable dates; never concat date parts manually.
9. Datatypes — A Date Column That Is Not a Date Will Break Every Function You Write
Every SQL date function—DATEDIFF, DATE_ADD, EXTRACT—silently depends on the underlying column being a true date/datetime type. WHY this matters: many legacy systems store '2024-03-15' as a VARCHAR or TEXT. These strings pass equality checks but fail on comparisons (December 9 > January 10? No, '12' < '2' lexicographically). Sorting breaks. INTERVAL arithmetic throws errors or returns garbage. The three standard temporal types are DATE (no time), DATETIME (date + time), and TIMESTAMP (timezone-aware, usually stored as UTC). Always verify your schema with SHOW COLUMNS or INFORMATION_SCHEMA.COLUMNS to confirm the datatype before writing any filtering logic. If you inherit a VARCHAR date column, use STR_TO_DATE() to cast it to a proper type before applying date functions—otherwise your 'saved $1M' query is a time bomb.
Example.sqlSQL
1
2
3
4
5
6
7
8
9
// io.thecodeforge — database tutorial
SELECT
order_date_raw,
STR_TO_DATE(order_date_raw, '%Y-%m-%d') AS proper_date
FROM orders
WHERE STR_TO_DATE(order_date_raw, '%Y-%m-%d') > '2024-01-01'LIMIT3;
Output
+---------------+-------------+
| order_date_raw| proper_date |
+---------------+-------------+
| 2024-02-15 | 2024-02-15 |
| 2024-03-01 | 2024-03-01 |
| 2024-04-10 | 2024-04-10 |
+---------------+-------------+
⚠ Production Trap:
A VARCHAR date column passes WHERE equality but fails ORDER BY—disaster for paginated reports.
🎯 Key Takeaway
Never trust a date column without verifying its native datatype in your database schema.
AT TIME ZONE: Time Zone Conversion Across Databases
Time zone mismatches are a common source of data corruption in global applications. The AT TIME ZONE clause provides a standardized way to convert timestamps between time zones, but its implementation varies across databases. In PostgreSQL, SELECT timestamp_column AT TIME ZONE 'America/New_York' converts a timestamp without time zone to a timestamp with time zone in the specified zone. For MySQL, use CONVERT_TZ(timestamp_column, 'UTC', 'America/New_York'). SQL Server offers AT TIME ZONE as well, e.g., SELECT timestamp_column AT TIME ZONE 'Eastern Standard Time'. Always store timestamps in UTC and convert at query time to avoid confusion. For example, to find orders placed between 8 AM and 5 PM in the local time of the user, you would convert the stored UTC timestamp to the user's time zone before filtering. This prevents off-by-hour errors that can cause missed or double-counted orders. Remember that AT TIME ZONE does not change the underlying stored value; it only affects the display and comparison context. Use it consistently in reporting queries to ensure accurate time windows across different regions.
timezone_conversion.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- PostgreSQL: Convert UTC timestamp to Eastern TimeSELECT order_id, order_time ATTIMEZONE'America/New_York'AS local_time
FROM orders;
-- MySQL: Convert UTC to Eastern TimeSELECT order_id, CONVERT_TZ(order_time, 'UTC', 'America/New_York') AS local_time
FROM orders;
-- SQL Server: Convert UTC to Eastern TimeSELECT order_id, order_time ATTIMEZONE'UTC'ATTIMEZONE'Eastern Standard Time'AS local_time
FROM orders;
-- Filtering by local time windowSELECT order_id, order_time
FROM orders
WHERE order_time ATTIMEZONE'America/New_York'BETWEEN'2023-01-01 08:00:00'AND'2023-01-01 17:00:00';
⚠ Time Zone Pitfall
📊 Production Insight
In production, standardize on UTC for storage and apply time zone conversion at the application or query layer. This avoids data inconsistency when users in different time zones access the same records.
🎯 Key Takeaway
Use AT TIME ZONE (or equivalent) to convert timestamps to the desired time zone for accurate filtering and reporting across global applications.
Date/time truncation functions round a timestamp down to a specified precision, such as year, month, day, hour, or minute. This is essential for grouping data into consistent time buckets without worrying about varying seconds or milliseconds. PostgreSQL uses DATE_TRUNC('month', timestamp_column) to truncate to the first day of the month at midnight. In SQL Server, you can use DATETRUNC(month, timestamp_column) (available from SQL Server 2022) or manually construct the truncated date with DATEFROMPARTS(YEAR(date), MONTH(date), 1). MySQL offers DATE_FORMAT(timestamp, '%Y-%m-01') or DATE(timestamp) for day-level truncation. Oracle provides TRUNC(timestamp, 'MM'). Truncation is particularly useful for generating monthly or daily reports. For example, to count orders per month, you would truncate the order timestamp to month and then GROUP BY. This avoids the common mistake of grouping by the full timestamp, which would create a separate bucket for each second. Always use truncation to ensure consistent time buckets, especially when dealing with high-frequency data. Note that truncation does not change the data type; it simply sets the less significant parts to zero or the start of the period.
date_truncation.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- PostgreSQL: Truncate to monthSELECT DATE_TRUNC('month', order_time) AS month_start, COUNT(*) AS order_count
FROM orders
GROUPBY month_start
ORDERBY month_start;
-- SQL Server (2022+): Truncate to monthSELECTDATETRUNC(month, order_time) AS month_start, COUNT(*) AS order_count
FROM orders
GROUPBYDATETRUNC(month, order_time)
ORDERBY month_start;
-- MySQL: Truncate to month using DATE_FORMATSELECT DATE_FORMAT(order_time, '%Y-%m-01') AS month_start, COUNT(*) AS order_count
FROM orders
GROUPBY month_start
ORDERBY month_start;
-- Oracle: Truncate to monthSELECTTRUNC(order_time, 'MM') AS month_start, COUNT(*) AS order_count
FROM orders
GROUPBYTRUNC(order_time, 'MM')
ORDERBY month_start;
💡Truncation for Clean Grouping
📊 Production Insight
In production, pre-truncate timestamps in ETL pipelines or use indexed computed columns to speed up queries that group by truncated dates.
🎯 Key Takeaway
Use DATE_TRUNC (or equivalent) to round timestamps to a consistent time bucket for accurate grouping and reporting.
Interval Arithmetic and Business Date Calculations
Interval arithmetic allows you to add or subtract durations from dates and timestamps, enabling calculations like 'last month', 'next quarter', or 'previous business day'. In PostgreSQL, use INTERVAL '1 month' or INTERVAL '3 days'. SQL Server uses DATEADD(month, -1, date_column). MySQL offers DATE_ADD(date, INTERVAL 1 MONTH). Oracle uses date_column + INTERVAL '1' MONTH. Business date calculations often require skipping weekends or holidays. For example, to find the previous business day, you can use a CASE statement with EXTRACT(DOW FROM date) to adjust for weekends. More complex logic may involve a calendar table that marks holidays. A common mistake is using BETWEEN for date ranges without considering time components; instead, use >= and < with interval arithmetic. For instance, to get orders from last month, use WHERE order_date >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL '1 month' AND order_date < DATE_TRUNC('month', CURRENT_DATE). This avoids the off-by-one errors that plague BETWEEN with timestamps. Interval arithmetic is also crucial for calculating due dates, subscription renewals, and aging reports. Always test edge cases like month boundaries and leap years.
interval_arithmetic.sqlSQL
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- PostgreSQL: Orders from last monthSELECT *
FROM orders
WHERE order_date >= DATE_TRUNC('month', CURRENT_DATE) - INTERVAL'1 month'AND order_date < DATE_TRUNC('month', CURRENT_DATE);
-- SQL Server: Orders from last monthSELECT *
FROM orders
WHERE order_date >= DATEADD(month, -1, DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1))
AND order_date < DATEFROMPARTS(YEAR(GETDATE()), MONTH(GETDATE()), 1);
-- MySQL: Orders from last monthSELECT *
FROM orders
WHERE order_date >= DATE_FORMAT(CURDATE(), '%Y-%m-01') - INTERVAL1MONTHAND order_date < DATE_FORMAT(CURDATE(), '%Y-%m-01');
-- Previous business day (skip weekends)SELECTCASEWHENEXTRACT(DOWFROM CURRENT_DATE) = 1THEN CURRENT_DATE - INTERVAL '3 days' -- Monday -> FridayWHENEXTRACT(DOWFROM CURRENT_DATE) = 0THEN CURRENT_DATE - INTERVAL '2 days' -- Sunday -> FridayELSE CURRENT_DATE - INTERVAL'1 day'ENDAS previous_business_day;
🔥Avoid BETWEEN for Date Ranges
📊 Production Insight
In production, create a calendar table with business day flags to simplify complex date calculations and improve query performance.
🎯 Key Takeaway
Use interval arithmetic with >= and < for precise date range queries, and handle business days with conditional logic or a calendar table.
● Production incidentPOST-MORTEMseverity: high
BETWEEN Date Filter Missed 1,847 Orders on the Last Day of Each Month
Symptom
Monthly totals were consistently $18,000-$22,000 lower than the finance team's manual counts. The discrepancy was always concentrated on end-of-month orders.
Assumption
The query used BETWEEN '2026-01-01' AND '2026-01-31' — the developer assumed this captured all January orders.
Root cause
created_at is a TIMESTAMP column with time components. BETWEEN '2026-01-31' AND '2026-01-31' captures only 2026-01-31 00:00:00.000. Orders placed at 2026-01-31 15:30:00 had timestamps greater than '2026-01-31' (which is interpreted as '2026-01-31 00:00:00') and were excluded.
Fix
Changed to: WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01'. The open-ended upper bound captures everything up to but not including February 1st regardless of time component.
Key lesson
Never use BETWEEN with TIMESTAMP columns — the upper bound is exclusive at the date's start, not the date's end
Always use >= lower bound and < exclusive upper bound for date range filtering: WHERE ts >= '2026-01-01' AND ts < '2026-02-01'
Test date range queries with SELECT MIN(created_at), MAX(created_at) to verify the bounds capture the expected range
Production debug guideDiagnosing timezone issues, missing records, and report grouping errors3 entries
Symptom · 01
Date range query misses records at the end of the range
→
Fix
You are using BETWEEN with a TIMESTAMP column. BETWEEN '2026-01-31' implicitly means '2026-01-31 00:00:00' — timestamps later that day are excluded. Use: WHERE ts >= '2026-01-01' AND ts < '2026-02-01'.
Symptom · 02
Reports group records into wrong months or days
→
Fix
Timezone mismatch — the database stores UTC but the report groups by UTC timestamps instead of local time. Use CONVERT_TZ (MySQL) or AT TIME ZONE (PostgreSQL): ts AT TIME ZONE 'UTC' AT TIME ZONE 'America/New_York' before truncating to month.
Symptom · 03
EXTRACT or YEAR() on an indexed timestamp column causes a full scan
→
Fix
EXTRACT(YEAR FROM ts) = 2026 disables the index on ts. Use a range instead: WHERE ts >= '2026-01-01' AND ts < '2027-01-01'. Or create a functional index: CREATE INDEX idx_year ON orders (EXTRACT(YEAR FROM created_at)).
Function / Feature
MySQL
PostgreSQL
SQL Server
Current timestamp
NOW()
NOW() / CURRENT_TIMESTAMP
GETDATE() / SYSDATETIME()
Current date only
CURDATE()
CURRENT_DATE
CAST(GETDATE() AS DATE)
Difference in days
DATEDIFF(end, start)
end_date - start_date (returns interval)
DATEDIFF(day, start, end)
Difference with unit
TIMESTAMPDIFF(unit, start, end)
EXTRACT(unit FROM end - start)
DATEDIFF(unit, start, end)
Truncate to period
DATE_FORMAT(col, '%Y-%m-01')
DATE_TRUNC('month', col)
DATETRUNC('month', col) (SQL Server 2022+)
Extract component
EXTRACT(YEAR FROM col) / YEAR(col)
EXTRACT(YEAR FROM col)
DATEPART(year, col)
Add/subtract interval
DATE_ADD(col, INTERVAL 7 DAY)
col + INTERVAL '7 days'
DATEADD(day, 7, col)
Format for display
DATE_FORMAT(col, '%d/%m/%Y')
TO_CHAR(col, 'DD/MM/YYYY')
FORMAT(col, 'dd/MM/yyyy')
Argument order trap
DATEDIFF(end, start)
N/A (uses subtraction)
DATEDIFF(unit, start, end) — reversed!
⚙ Quick Reference
10 commands from this guide
File
Command / Code
Purpose
current_datetime_basics.sql
SELECT NOW() AS current_datetime; -- e.g. 2024-03-15 09:42:11
Getting the Current Date and Time
date_difference_calculations.sql
SELECT
Calculating Date Differences
date_grouping_and_reporting.sql
SELECT
Extracting and Truncating Dates
date_range_filtering.sql
SELECT
Filtering by Date Ranges
RollingWindowQuery.sql
SELECT order_id, placed_at, total_amount
Date Arithmetic with INTERVAL
MonthlyRevenueReport.sql
SELECT
Extracting Parts of a Date
Example.sql
SELECT
8. DATE_FORMAT()
timezone_conversion.sql
SELECT order_id, order_time AT TIME ZONE 'America/New_York' AS local_time
AT TIME ZONE
date_truncation.sql
SELECT DATE_TRUNC('month', order_time) AS month_start, COUNT(*) AS order_count
Date/Time Truncation
interval_arithmetic.sql
SELECT *
Interval Arithmetic and Business Date Calculations
Key takeaways
1
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.
2
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.
3
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.
4
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.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Why is BETWEEN problematic for filtering TIMESTAMP columns and what shou...
Q02JUNIOR
How would you write a query to group orders by month and show the total ...
Q03SENIOR
What is the difference between NOW() and CURRENT_TIMESTAMP, and when wou...
Q01 of 03SENIOR
Why is BETWEEN problematic for filtering TIMESTAMP columns and what should you use instead?
ANSWER
BETWEEN is inclusive on both ends, but when used with a date string like '2026-01-31', the database interprets it as '2026-01-31 00:00:00.000000'. So WHERE ts BETWEEN '2026-01-01' AND '2026-01-31' captures all records up to January 31st midnight, but misses any record timestamped January 31st after midnight (e.g., 2026-01-31 14:30:00). The correct pattern uses >= for the lower bound and < (exclusive) for the upper bound: WHERE ts >= '2026-01-01' AND ts < '2026-02-01'. This captures everything from January 1st through January 31st 23:59:59.999 regardless of the time component.
Q02 of 03JUNIOR
How would you write a query to group orders by month and show the total revenue per month?
ANSWER
In PostgreSQL: SELECT DATE_TRUNC('month', created_at) AS month, COUNT() AS orders, SUM(total) AS revenue FROM orders GROUP BY DATE_TRUNC('month', created_at) ORDER BY month. In MySQL: SELECT DATE_FORMAT(created_at, '%Y-%m-01') AS month, COUNT(), SUM(total) FROM orders GROUP BY DATE_FORMAT(created_at, '%Y-%m-01') ORDER BY month. DATE_TRUNC truncates all timestamps in a month to the month's start date (2026-01-01 00:00:00), allowing GROUP BY to aggregate all January records together. Always include ORDER BY month for consistent report output.
Q03 of 03SENIOR
What is the difference between NOW() and CURRENT_TIMESTAMP, and when would you use CLOCK_TIMESTAMP()?
ANSWER
In PostgreSQL, NOW() and CURRENT_TIMESTAMP are equivalent and both return the start time of the current transaction. If a transaction begins at 14:00:00 and runs for 30 seconds, every call to NOW() within that transaction returns 14:00:00 — the timestamp is stable throughout the transaction. CLOCK_TIMESTAMP() returns the actual current wall clock time at the moment of each call — it can return different values for multiple calls within the same transaction. Use NOW()/CURRENT_TIMESTAMP for audit timestamps where consistency within a transaction is important (all rows in a batch insert get the same timestamp). Use CLOCK_TIMESTAMP() when measuring elapsed time or needing the true current time at each step.
01
Why is BETWEEN problematic for filtering TIMESTAMP columns and what should you use instead?
SENIOR
02
How would you write a query to group orders by month and show the total revenue per month?
JUNIOR
03
What is the difference between NOW() and CURRENT_TIMESTAMP, and when would you use CLOCK_TIMESTAMP()?
SENIOR
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
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.
Was this helpful?
02
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.
Was this helpful?
03
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.