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..
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
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.
>= start AND < end + 1 day.>= start AND < end + INTERVAL '1 day'.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.
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.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.NOW(); for elapsed time measurement, use CLOCK_TIMESTAMP().NOW() returns the transaction start time — consistent within a multi-statement transaction.CLOCK_TIMESTAMP() (PostgreSQL).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.
AGE() in PostgreSQL computes human-readable intervals (2 years 3 months 12 days) — more readable than raw DATEDIFF for display purposes.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.
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.
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.Date Arithmetic with INTERVAL — Stop Writing Wrong Queries for 'Last Month'
You've seen it a hundred times: WHERE placed_at >= . Works until months with 31 days break your report. Then someone 'fixes' it with NOW() - INTERVAL 30 DAYDATE_SUB(. That's better. But do you know why?NOW(), INTERVAL 1 MONTH)
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 N. OW()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 N for rolling windows, OW()CURRENT_DATE for calendar-day boundaries.
And for the love of god, don't do WHERE DATEDIFF(. That's a full table scan every time. Use a sargable filter with NOW(), placed_at) < 30INTERVAL and an index on placed_at.
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.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.
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'. D is the only portable way to transform a datetime into any string shape your business requires. Understand WHY: ATE_FORMAT()CAST(date AS VARCHAR) is database-specific and loses timezone awareness. D 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 ATE_FORMAT()%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.
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.
BETWEEN Date Filter Missed 1,847 Orders on the Last Day of Each Month
- 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
YEAR() on an indexed timestamp column causes a full scanKey takeaways
NOW() over SYSDATE() for batch insertsNOW() captures the statement start time so all rows in a batch get an identical, consistent timestamp instead of microsecond noise.Common mistakes to avoid
3 patternsUsing BETWEEN with a TIMESTAMP or DATETIME column
Applying YEAR() or EXTRACT() to indexed datetime columns in WHERE
Ignoring timezone differences between application and database
Interview Questions on This Topic
Why is BETWEEN problematic for filtering TIMESTAMP columns and what should you use instead?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
That's SQL Basics. Mark it forged?
8 min read · try the examples if you haven't