skip to content

A Django clinic dashboard filters today's appointments with naive midnight bounds and misses early bookings for Singapore users; what went wrong and how do you fix it?

level: seniorimportance: should knowfreq 38%

answer

  1. which zone the guess uses
  2. default zone, not current zone
  3. whose 'today' is it
  4. localdate() and aware bounds

basics

~20 s

Naive bounds in a DateTimeField lookup trigger a RuntimeWarning and are interpreted in TIME_ZONE, not the user's activated zone, and date.today() is the server's date. Build aware bounds from timezone.localdate(), or filter with __date, which uses the current zone.

solid answer

~40 s

With `USE_TZ` on, a naive datetime passed to a `DateTimeField` lookup is made aware in the **default** zone, `TIME_ZONE`, after a `RuntimeWarning`; it is not interpreted in the zone the request activated. So with `TIME_ZONE = "UTC"` midnight-to-midnight became UTC midnight, 08:00 in Singapore, and `date.today()` added the server's date on top. The fix is to compute the user's day with `timezone.localdate()` and build aware bounds with `timezone.make_aware(datetime.combine(day, time.min))`, which uses the current zone, or to filter `starts_at__date=day`, since date-part lookups and `TruncDate` convert to the current zone. Background jobs have no active zone, so they need `timezone.override()` or an explicit `tzinfo`. I also make that warning an error in tests.

code

python · 11 lines
python
from datetime import datetime, time, timedelta

from django.utils import timezone

from appointments.models import Appointment

day = timezone.localdate()
start = timezone.make_aware(datetime.combine(day, time.min))
end = timezone.make_aware(datetime.combine(day + timedelta(days=1), time.min))
todays = Appointment.objects.filter(starts_at__gte=start, starts_at__lt=end)
# or: Appointment.objects.filter(starts_at__date=day)

go deeper

for a junior

Recall that naive datetimes in queries trigger a RuntimeWarning and that timezone.localdate() gives today in the current zone.

for a middle

Explain the difference between the default-zone fallback for naive values and the current zone used by make_aware(), localdate() and __date lookups.

for a senior

Diagnose off-by-hours report bugs, fix jobs that run without an active zone, weigh range filters against __date, and fail tests on naive values.

for a principal

Decide how multi-zone reports define a day, per clinic or per user, and make that choice explicit in code rather than inherited from TIME_ZONE.

## The symptom A clinic dashboard lists "today's appointments". Clinicians in Singapore report that early-morning bookings are missing and late-evening ones from yesterday appear. The code looked innocent: ```python from datetime import date, datetime, timedelta today = date.today() start = datetime.combine(today, datetime.min.time()) # naive midnight appointments = Appointment.objects.filter(starts_at__gte=start, starts_at__lt=start + timedelta(days=1)) ``` ## What Django did with it With `USE_TZ = True`, a naive datetime (or a `date`) used against a `DateTimeField` lookup goes through the field's preparation: 1. Django emits `RuntimeWarning: DateTimeField Appointment.starts_at received a naive datetime (...) while time zone support is active.` 2. It attaches the **default time zone**, `TIME_ZONE`, with `make_aware()`, not the clinician's activated zone. 3. The bounds become midnight-to-midnight **in `TIME_ZONE`** (UTC in most projects), which is 08:00-to-08:00 in Singapore. `date.today()` adds a second error: it is the server process's date, which can already be tomorrow or still yesterday for the clinician. ## The correct versions | Approach | Code shape | Day boundary used | |---|---|---| | Aware bounds | `make_aware(datetime.combine(localdate(), time.min))` | current zone | | `__date` lookup | `filter(starts_at__date=timezone.localdate())` | current zone | | Explicit zone | `TruncDate("starts_at", tzinfo=clinic_tz)` | the given zone | - `timezone.localdate()` returns today's date **in the current zone**. - `timezone.make_aware(naive)` with no zone argument uses the **current** zone, unlike the naive-value fallback, which uses the default. - **Date-part lookups** (`__date`, `__year`, `__hour`) and the `Trunc`/`Extract` functions convert the stored UTC value to the **current** zone before taking the date part, or to the `tzinfo` you pass. ```python from datetime import datetime, time, timedelta from django.utils import timezone day = timezone.localdate() # clinician's today start = timezone.make_aware(datetime.combine(day, time.min)) # clinician's midnight todays = Appointment.objects.filter(starts_at__gte=start, starts_at__lt=start + timedelta(days=1)) ``` The range form lets the database use an ordinary index on `starts_at`; a `__date` filter is shorter and reads better. Adding `timedelta(days=1)` to an aware local midnight moves the wall clock by one day, so the upper bound is the next local midnight even across a DST change. ## The background-job trap A nightly report that groups appointments by `TruncDate("starts_at")` runs with no zone activated, so it groups by `TIME_ZONE`'s days. For a clinic in Singapore that means grouping by UTC days. Either pass `tzinfo=ZoneInfo(clinic.timezone)` to the function or wrap the query in `timezone.override(clinic.timezone)`. ## Why it hides in development - Developers often work near UTC and keep `TIME_ZONE = "UTC"`, so the default and the current zone agree. - Test data is usually created at convenient hours, far from midnight, where every zone agrees on the date. - The warning is printed once per call site and drowned in other output. The bug only shows for users far from `TIME_ZONE`, for records close to their midnight. A quick map of which zone each tool uses makes the review systematic: | Tool | Zone used | |---|---| | naive value reaching a `DateTimeField` | default (`TIME_ZONE`) | | `timezone.make_aware(v)` without a zone | current | | `timezone.localtime()` / `localdate()` | current | | `__date`, `TruncDate` without `tzinfo` | current | | `timezone.now()` | UTC | ## Making the mistake impossible to miss - Turn the warning into an error in tests, so any naive value fails the suite: ```python import warnings warnings.filterwarnings( "error", r"DateTimeField .* received a naive datetime", RuntimeWarning, r"django\.db\.models\.fields", ) ``` - Grep for `datetime.now(`, `date.today(` and `datetime.combine(` in query code. - Review raw SQL separately: date arithmetic there sees UTC values and no Django conversion. ## Checklist - Use aware values in every datetime lookup. - Compute "today" with `timezone.localdate()` under the right active zone. - Give reports and jobs an explicit zone rather than relying on the default. - Keep `TIME_ZONE = "UTC"` so that any accidental fallback is at least predictable.

  • Which zone does timezone.make_aware() use when no zone argument is given?
    The current time zone, from `get_current_timezone()`, which is the activated zone or else `TIME_ZONE`. That differs from the fallback Django applies to naive values reaching a `DateTimeField`, which always uses the default zone.
  • Why can a __date filter be slower than an equivalent range filter?
    The `__date` lookup converts each stored value to the current zone and casts it to a date in SQL, so a plain index on the column usually cannot serve it. A range on aware bounds compares the raw column, which an ordinary index supports.

saying these in an interview costs you the question

  • Naive values are interpreted in the zone the request activated
  • __date lookups always compare UTC dates
  • date.today() gives the user's date once a zone is activated
  • The RuntimeWarning is harmless noise and can be silenced
  • Background jobs automatically use the user's time zone