Handling Midnight Crossings in Database Queries
Debug off-by-one errors in database filters that span midnight or cross time zones. Learn to write correct date range queries in 2026.
Midnight crossing database queries occur when your WHERE clause filters records based on a date range, but the cutoff time falls at different moments in different time zones—or when you convert the timestamp in SELECT instead of WHERE and accidentally include (or exclude) rows you didn't mean to. A record created at 23:50 UTC on October 15 is still October 15 in London but already October 16 in Tokyo; if your query assumes "all October 15 records" without anchoring to a specific zone, you'll get surprises at scale.
Why This Matters (And Why Your Last Attempt Probably Failed)
You've probably encountered this already. A report says "October 15 had 500 orders," but when you query WHERE order_date = '2026-10-15' against a column that stores 2026-10-15 23:50:00, your filter catches it. Then you add a timezone-aware layer—converting to the user's local time in the application—and the same order vanishes from the midnight-to-noon query. Or worse: your batch job counts records twice because it ran at 23:55 UTC, applied a time zone offset, and now thinks the record belongs in tomorrow's bucket.
Remote teams make this worse. A manager in San Francisco (PDT, UTC−7) and a developer in Berlin (CEST, UTC+2) are looking at the same database. When they say "October 15," they mean different UTC windows—a 9-hour gap. The database never knows which interpretation the client intended unless the query explicitly says so.
This article is narrower than general date-logic testing. It focuses on the patterns that fail in production—WHERE clause construction, UTC storage, index efficiency, and the midnight boundary itself—with real audit trails to verify your fix works.
The Midnight Boundary Problem: Why Naive Filters Fail
A typical mistake looks like this:
-- WRONG
SELECT * FROM orders
WHERE order_date = '2026-10-15'
If order_date is a TIMESTAMP column (not a DATE), this query fails silently. PostgreSQL (and MySQL) interpret '2026-10-15' as 2026-10-15 00:00:00 and use implicit equality, which only matches records with that exact timestamp—almost none in practice. You get zero results and assume the table is empty.
Add a midnight boundary:
-- ALSO WRONG (catches one second too many)
SELECT * FROM orders
WHERE order_date >= '2026-10-15 00:00:00'
AND order_date <= '2026-10-15 23:59:59'
This includes records up to and including 23:59:59.999999, which is not midnight on October 16. The correct cutoff is 2026-10-16 00:00:00. This is an off-by-one error date query in the millisecond range—invisible in most reporting but catastrophic if you're reconciling records or running a batch job twice.
The root cause: you're thinking in human time (a calendar day) but the database thinks in Unix timestamps (a continuous number line). A midnight boundary is arbitrary—it only makes sense if you lock it to a timezone AND express it in UTC inside the WHERE clause.
Time Zone Conversion in WHERE Clauses: The Common Mistakes
Here's where most teams go sideways.
Mistake 1: Convert in SELECT, not WHERE.
-- WRONG
SELECT order_id,
AT_TIME_ZONE(created_at, 'America/Los_Angeles') AS local_time
FROM orders
WHERE created_at >= '2026-10-15'::TIMESTAMP
You're filtering UTC records, then displaying them in LA time. The filter is still timezone-unaware. If you have 1,000 records from Oct 15–16 UTC, you'll display them in LA time (Oct 15–16 PDT = Oct 15–16 at 00:00 to 23:59 PDT), but your filter already selected records from UTC 00:00 to 23:59, which is 7 hours earlier. The boundaries don't align.
Mistake 2: Store local time in the database.
-- WRONG
INSERT INTO orders (customer_id, created_at)
VALUES (42, '2026-10-15 14:30:00')
Without a timezone specification, PostgreSQL stores this as 2026-10-15 14:30:00+00 (UTC assumed) by default—but your application passed local time. When a developer in Tokyo queries the same record, they see 2026-10-16 04:30:00+09. Two representations, same moment, but two interpretations of "what day was this created."
Mistake 3: Forget that midnight in one zone is already tomorrow in another.
A batch job for "October 15 transactions" starts at 2026-10-15 23:00:00 UTC. That's:
- 16:00 (4 PM) October 15 in Los Angeles
- 07:00 (7 AM) October 16 in Tokyo
- 01:00 (1 AM) October 16 in Berlin
If your query filter is WHERE created_at >= '2026-10-15 00:00:00 UTC' AND created_at < '2026-10-16 00:00:00 UTC', you're correct. But if your codebase does WHERE local_date = '2026-10-15' without specifying a zone, every timezone-aware layer in the stack has to guess—and they won't guess the same way.
Storing and Querying in UTC: The Only Right Answer
Lay down a rule: all timestamps in the database are UTC, always.
The practice of storing dates in UTC isn't a suggestion; it's the only way to make distributed queries deterministic. Here's why:
- UTC has no daylight savings time.
- UTC is monotonic (time moves forward, never repeats).
- UTC is universal—no ambiguity about "which zone does the database belong to."
In PostgreSQL, use TIMESTAMP WITH TIME ZONE (aka TIMESTAMPTZ):
CREATE TABLE orders (
order_id BIGSERIAL PRIMARY KEY,
customer_id INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- CURRENT_TIMESTAMP is UTC by default
updated_at TIMESTAMPTZ
);
TIMESTAMPTZ always stores UTC internally and displays based on your connection's TimeZone setting. When you insert a value, PostgreSQL normalizes it to UTC. When you query, it respects your session timezone for display only—the filter is always UTC-based.
In MySQL, use TIMESTAMP (not DATETIME):
CREATE TABLE orders (
order_id BIGINT AUTO_INCREMENT PRIMARY KEY,
customer_id INT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NULL ON UPDATE CURRENT_TIMESTAMP
);
MySQL's TIMESTAMP is always UTC and converts to the client's timezone for display. DATETIME is naive (no timezone info) and should be avoided for timestamp fields.
Both databases will then let you filter unambiguously:
-- PostgreSQL - CORRECT
SELECT * FROM orders
WHERE created_at >= '2026-10-15 00:00:00 UTC'::TIMESTAMPTZ
AND created_at < '2026-10-16 00:00:00 UTC'::TIMESTAMPTZ;
-- MySQL - CORRECT
SELECT * FROM orders
WHERE created_at >= CONVERT_TZ('2026-10-15 00:00:00', '+00:00', @@session.time_zone)
AND created_at < CONVERT_TZ('2026-10-16 00:00:00', '+00:00', @@session.time_zone);
Actually, in MySQL, the simpler version works:
-- MySQL (simpler, still correct)
SELECT * FROM orders
WHERE created_at >= '2026-10-15 00:00:00'
AND created_at < '2026-10-16 00:00:00';
Because MySQL's TIMESTAMP is UTC, it interprets these string literals as UTC times. No conversion needed in the query itself.
Building Correct Date Range Filters: Inclusive vs Exclusive
A date range inclusive exclusive query is the standard: start of the range is inclusive, end is exclusive. This avoids the millisecond boundary problem.
Inclusive start, exclusive end:
WHERE created_at >= '2026-10-15 00:00:00 UTC'::TIMESTAMPTZ
AND created_at < '2026-10-16 00:00:00 UTC'::TIMESTAMPTZ
This captures every moment from 00:00:00.000000 on Oct 15 up to (but not including) 00:00:00.000000 on Oct 16. A record at 2026-10-15 23:59:59.999999 UTC is included; a record at 2026-10-16 00:00:00.000000 UTC is excluded. Clean. No overlaps. No gaps.
Why not inclusive on both ends?
-- AVOID THIS
WHERE created_at >= '2026-10-15 00:00:00 UTC'::TIMESTAMPTZ
AND created_at <= '2026-10-15 23:59:59.999999 UTC'::TIMESTAMPTZ
You have to hardcode the nanosecond boundary. If you later refactor or combine ranges, you'll make a mistake. Exclusive end is simpler: just add one day to the start.
For a custom time zone, convert the boundaries first, then use them in the WHERE clause:
-- PostgreSQL: filter for "October 15 in Los Angeles"
-- LA is UTC-7 or UTC-8 depending on DST
-- October 15 00:00:00 LA = October 15 07:00:00 UTC (EDT, Oct is still daylight)
-- October 16 00:00:00 LA = October 16 07:00:00 UTC
SELECT * FROM orders
WHERE created_at >= '2026-10-15 07:00:00 UTC'::TIMESTAMPTZ
AND created_at < '2026-10-16 07:00:00 UTC'::TIMESTAMPTZ;
But how do you know LA is UTC-7 on October 15? You don't hard-code it. Use the database's timezone library:
-- PostgreSQL (CORRECT)
SELECT * FROM orders
WHERE created_at >= '2026-10-15 00:00:00'::TIMESTAMP AT TIME ZONE 'America/Los_Angeles' AT TIME ZONE 'UTC'
AND created_at < '2026-10-16 00:00:00'::TIMESTAMP AT TIME ZONE 'America/Los_Angeles' AT TIME ZONE 'UTC';
This is verbose, but it's right. The database knows DST rules and applies them. (In practice, you'd wrap this in a view or function to avoid repetition.)
Midnight in Different Zones: A Practical Example with Real Offsets
Let's say you have a record created at 2026-10-15 18:00:00 UTC (6 PM UTC, October 15).
In different zones, this is:
| Zone | Local Time | Date |
|---|---|---|
| UTC | 2026-10-15 18:00 | Oct 15 |
| America/New_York (EDT, UTC-4) | 2026-10-15 14:00 | Oct 15 |
| Europe/London (BST, UTC+1) | 2026-10-15 19:00 | Oct 15 |
| Europe/Berlin (CEST, UTC+2) | 2026-10-15 20:00 | Oct 15 |
| Asia/Tokyo (JST, UTC+9) | 2026-10-16 03:00 | Oct 16 |
Now you query for "all orders on October 15 in Tokyo":
-- WRONG - doesn't exist in the database because we're not converting
SELECT * FROM orders WHERE DATE(created_at) = '2026-10-15'
This filters UTC times, finds no October 15 in the Tokyo midnight. Wrong.
Correct: Convert the Tokyo midnight boundaries to UTC, then filter:
-- PostgreSQL - CORRECT
SELECT * FROM orders
WHERE created_at >= '2026-10-15 00:00:00'::TIMESTAMP AT TIME ZONE 'Asia/Tokyo' AT TIME ZONE 'UTC'
AND created_at < '2026-10-16 00:00:00'::TIMESTAMP AT TIME ZONE 'Asia/Tokyo' AT TIME ZONE 'UTC';
-- Expands to:
-- created_at >= '2026-10-14 15:00:00 UTC' (Oct 15 00:00 JST = Oct 14 15:00 UTC)
-- created_at < '2026-10-15 15:00:00 UTC' (Oct 16 00:00 JST = Oct 15 15:00 UTC)
This correctly captures the record at 2026-10-15 18:00:00 UTC because it falls within the Oct 14 15:00 to Oct 15 15:00 UTC window (the time window during which "October 15" occurs in Tokyo).
Use the IANA time zone database zone names. PostgreSQL and MySQL both rely on it. Never hard-code an offset like UTC+9 because DST rules change and differ by region. Asia/Tokyo is stable and always correct.
PostgreSQL and MySQL: Zone-Aware Query Patterns That Work
PostgreSQL has the most elegant timezone support. Use AT TIME ZONE:
-- Display a UTC timestamp in Tokyo time
SELECT created_at AT TIME ZONE 'Asia/Tokyo' AS tokyo_time
FROM orders
WHERE created_at >= '2026-10-15 00:00:00 UTC'::TIMESTAMPTZ;
For a database date range query UTC, always cast literals to TIMESTAMPTZ:
SELECT * FROM orders
WHERE created_at >= '2026-10-15'::DATE::TIMESTAMPTZ AT TIME ZONE 'UTC'
AND created_at < '2026-10-16'::DATE::TIMESTAMPTZ AT TIME ZONE 'UTC';
Or use an explicit UTC timestamp:
SELECT * FROM orders
WHERE created_at >= '2026-10-15 00:00:00+00'::TIMESTAMPTZ
AND created_at < '2026-10-16 00:00:00+00'::TIMESTAMPTZ;
MySQL requires explicit conversion for timezone-aware logic:
-- MySQL - convert UTC midnight to the session timezone
SELECT * FROM orders
WHERE created_at >= UTC_TIMESTAMP('2026-10-15 00:00:00')
AND created_at < UTC_TIMESTAMP('2026-10-16 00:00:00');
Or, if you want to filter for a specific zone:
-- MySQL - "October 15 in America/Denver"
SET SESSION time_zone = 'America/Denver';
SELECT * FROM orders
WHERE created_at >= CONVERT_TZ('2026-10-15 00:00:00', @@session.time_zone, '+00:00')
AND created_at < CONVERT_TZ('2026-10-16 00:00:00', @@session.time_zone, '+00:00');
In both databases, a PostgreSQL timezone aware query and a MySQL date range UTC conversion follow the same logic: boundaries in UTC, always, no exceptions.
DST Transitions and Midnight: The April 2026 Edge Case
Daylight Saving Time transitions create a gotcha. In 2026 (Northern Hemisphere), clocks spring forward at 2 AM UTC on March 29 (some regions differ). For a moment, the local time skips from 01:59:59 to 03:00:00. There is no 02:00:00 AM local time on that night.
Similarly, clocks fall back on October 25, 2026. At 2 AM UTC, clocks move to 01:00:00 AM—the clock runs backward, and times from 01:00:00 to 01:59:59 occur twice.
If you query for "all records created on March 29, 2026 at 02:30 AM in America/New_York," you'll get an error or an unexpected result because 02:30 AM doesn't exist that night (it's skipped during spring-forward).
The database handles this—if you use DST transitions and midnight correctly in your timezone library:
-- PostgreSQL - correct, respects DST rules
SELECT * FROM orders
WHERE created_at >= '2026-03-29 00:00:00'::TIMESTAMP AT TIME ZONE 'America/New_York' AT TIME ZONE 'UTC'
AND created_at < '2026-03-30 00:00:00'::TIMESTAMP AT TIME ZONE 'America/New_York' AT TIME ZONE 'UTC';
PostgreSQL's AT TIME ZONE operator uses the IANA database and correctly interprets the missing hour. The query doesn't try to select 02:30 AM; it just defines the range as "midnight to midnight on March 29 in New York," which the database resolves without ambiguity.
The danger: if you hard-code an offset (e.g., '2026-03-29 02:30:00-04:00'), you're bypassing DST logic and getting undefined behavior.
Testing Date Range Queries: Assertions That Catch Off-by-One Errors
Write tests. Obvious, but the details matter.
Test 1: Midnight boundary.
# Pseudocode for a test
def test_midnight_boundary_inclusive_exclusive():
# Insert a record at exactly 2026-10-15 23:59:59.999999 UTC
insert_order(created_at='2026-10-15 23:59:59.999999+00')
# Insert a record at exactly 2026-10-16 00:00:00.000000 UTC
insert_order(created_at='2026-10-16 00:00:00.000000+00')
# Query October 15 (inclusive start, exclusive end)
results = query_orders(
start='2026-10-15 00:00:00+00',
end='2026-10-16 00:00:00+00'
)
# Should return exactly one record (the first one)
assert len(results) == 1
assert results[0].created_at == '2026-10-15 23:59:59.999999+00'
Test 2: Timezone conversion.
def test_tokyo_midnight_boundary():
# Insert at 2026-10-15 18:00:00 UTC
# This is 2026-10-16 03:00:00 JST (Asia/Tokyo)
insert_order(created_at='2026-10-15 18:00:00+00')
# Query for October 15 in Tokyo time
results = query_orders_in_zone(
start='2026-10-15 00:00:00',
end='2026-10-16 00:00:00',
zone='Asia/Tokyo'
)
# Should return the record because it's Oct 16 in Tokyo,
# so it's NOT in Oct 15 Tokyo time
# Actually, should NOT return it
assert len(results) == 0
Wait, let me recalculate. October 15 18:00 UTC:
- Oct 15 18:00 UTC = Oct 16 03:00 JST (next day)
So querying for "Oct 15 in Tokyo" should NOT include this record. This test catches off-by-one errors.
Test 3: DST transition.
def test_dst_spring_forward_march_29_2026():
# March 29, 2026, 02:30 AM doesn't exist in America/New_York (skipped)
# Query for March 29 midnight to midnight in New York time
results = query_orders_in_zone(
start='2026-03-29 00:00:00',
end='2026-03-30 00:00:00',
zone='America/New_York'
)
# Should return records that fall within the 25-hour window
# (23 normal hours + 1 hour skip = 24 hours of UTC time)
# This test verifies the DST skip is handled correctly
assert all(result.created_at >= '2026-03-29 04:00:00+00'
and result.created_at < '2026-03-30 04:00:00+00'
for result in results)
Run these tests in CI/CD every time you change timezone logic. A query optimization date boundary also worth testing: ensure your indexes are actually used when filtering by timestamp.
Performance: Indexing on UTC Timestamps Without Conversion Overhead
A B-tree index on a TIMESTAMPTZ column is efficient only if you query it directly in the WHERE clause without function calls:
-- INDEX USED (efficient)
SELECT * FROM orders
WHERE created_at >= '2026-10-15 00:00:00+00'::TIMESTAMPTZ
AND created_at < '2026-10-16 00:00:00+00'::TIMESTAMPTZ;
-- INDEX NOT USED (expensive, full table scan)
SELECT * FROM orders
WHERE AT_TIME_ZONE(created_at, 'America/Los_Angeles')::DATE = '2026-10-15'::DATE;
The second query forces the database to convert every row before comparing. On a table with millions of records, this is a full table scan. Never do this in a WHERE clause; do timezone conversion after filtering:
-- INDEX USED, then convert
SELECT
order_id,
AT_TIME_ZONE(created_at, 'America/Los_Angeles') AS local_time
FROM orders
WHERE created_at >= '2026-10-15 07:00:00+00'::TIMESTAMPTZ
AND created_at < '2026-10-16 07:00:00+00'::TIMESTAMPTZ;
You compute the UTC boundaries once (Oct 15 00:00 LA = Oct 15 07:00 UTC), use them in the WHERE clause, and the index kicks in. The SELECT then converts the result set for display.
For frequently queried date ranges (e.g., "last 7 days", "this month"), create a database stored procedure timezone that encapsulates the boundary logic:
-- PostgreSQL stored procedure
CREATE FUNCTION orders_in_zone_range(
start_date DATE,
end_date DATE,
tz TEXT
) RETURNS TABLE (order_id BIGINT, created_at TIMESTAMPTZ) AS $
BEGIN
RETURN QUERY
SELECT o.order_id, o.created_at
FROM orders o
WHERE o.created_at >= start_date::TIMESTAMP AT TIME ZONE tz AT TIME ZONE 'UTC'
AND o.created_at < end_date::TIMESTAMP AT TIME ZONE tz AT TIME ZONE 'UTC';
END;
$ LANGUAGE plpgsql;
-- Call it
SELECT * FROM orders_in_zone_range('2026-10-15', '2026-10-16', 'America/Los_Angeles');
The procedure abstracts the conversion, keeps the query readable, and reduces the chance of a developer hard-coding a wrong offset.
Also, for a transaction timestamp midnight edge case: if your batch jobs run near midnight UTC and reference CURRENT_TIMESTAMP, the timestamp is captured once at the job start and reused throughout. This prevents the job from accidentally spanning a midnight boundary mid-execution.
-- PostgreSQL job: capture time once
DO $
DECLARE
job_start TIMESTAMPTZ := CURRENT_TIMESTAMP;
BEGIN
UPDATE orders
SET processed = true
WHERE created_at >= (job_start - INTERVAL '1 day')
AND created_at < job_start;
END $;
Frequently Asked Questions
How do I query for "all records in the past 24 hours" without worrying about midnight?
Use CURRENT_TIMESTAMP or NOW() and subtract 1 day. The database handles the boundary automatically:
SELECT * FROM orders
WHERE created_at >= CURRENT_TIMESTAMP - INTERVAL '1 day'
AND created_at < CURRENT_TIMESTAMP;
This is midnight-agnostic because it's relative, not absolute. No matter when the query runs, it grabs the last 24 hours.
What if my database doesn't support AT TIME ZONE?
If you're stuck with an older database, convert the boundaries in your application code, before sending the query:
from datetime import datetime, timezone, timedelta
import pytz
la_tz = pytz.timezone('America/Los_Angeles')
start_dt = la_tz.localize(datetime(2026, 10, 15, 0, 0, 0)).astimezone(pytz.UTC)
end_dt = la_tz.localize(datetime(2026, 10, 16, 0, 0, 0)).astimezone(pytz.UTC)
query = f"""
SELECT * FROM orders
WHERE created_at >= '{start_dt.isoformat()}'
AND created_at < '{end_dt.isoformat()}'
"""
Then pass the UTC-normalized boundaries to the database. This works everywhere, but it moves the burden to the application layer, which is less testable and more error-prone.
Should I use DATE columns instead of TIMESTAMP?
No. DATE columns store only the date (no time component) and are ambiguous across timezones. If you need to store just a date (e.g., a birth date), use DATE. For transaction timestamps, audit trails, or any event that happened at a specific moment, use TIMESTAMPTZ (PostgreSQL) or TIMESTAMP (MySQL), both UTC-normalized.
How do I handle "end of day" queries where "end of day" means 23:59:59 in the user's timezone?
Convert "23:59:59" in the user's timezone to UTC and use it as the exclusive boundary:
-- User wants "end of Oct 15" in their timezone
-- In LA, that's Oct 15 23:59:59 PDT = Oct 16 06:59:59 UTC
SELECT * FROM orders
WHERE created_at >= '2026-10-15 00:00:00'::TIMESTAMP AT TIME ZONE 'America/Los_Angeles' AT TIME ZONE 'UTC'
AND created_at < '2026-10-16 00:00:00'::TIMESTAMP AT TIME ZONE 'America/Los_Angeles' AT TIME ZONE 'UTC';
This includes all moments from the user's Oct 15 00:00 to Oct 16 00:00 in their local time, which is the entire calendar day in their zone.
Can I rely on the ORM to handle timezone conversions?
Partially. Most ORMs (SQLAlchemy, Hibernate, Django ORM) support TIMESTAMPTZ and can convert for display. But they rarely handle the WHERE clause timezone conversion automatically. You'll still need to write the boundary-conversion logic or use a raw query. Test your ORM's timezone behavior with the tests mentioned earlier.
What happens if a record is updated across a midnight boundary?
If a record is created at 2026-10-15 18:00:00 UTC and updated at 2026-10-16 02:00:00 UTC, a query for "Oct 15" using the created_at column will include it, but a query using the updated_at column will not. Use the right timestamp column for your filter. If you need "records created or updated on Oct 15," use OR logic carefully:
SELECT * FROM orders
WHERE (created_at >= '2026-10-15 00:00:00+00' AND created_at < '2026-10-16 00:00:00+00')
OR (updated_at >= '2026-10-15 00:00:00+00' AND updated_at < '2026-10-16 00:00:00+00');
Bottom Line
Midnight-crossing database queries fail because they conflate timezone-aware calendar days (human time) with UTC timestamps (machine time). The fix is strict: store all timestamps in UTC, never convert in the WHERE clause (do it outside the query or in application logic), and always use inclusive-exclusive boundaries. Test at the midnight edge, especially around DST transitions, and profile your indexes to ensure the database uses them. Use ISO 8601 timestamp format and IANA zone names to eliminate ambiguity. When coordinating across distributed teams, reference UTC always—let time zone math for distributed teams happen at the query boundary, not inside the database.
We build practical, free time and date tools at epochcalc.com — every calculation runs in your browser using IANA tzdb via Luxon, so DST and zone math are correct by construction.