How do you make a Django appointments site show and accept times in each signed-in user's own time zone?
answer
- no header to detect it
- store the zone on the user
- a middleware calling activate()
- templates and forms convert automatically
basics
~10 sStore each user's zone name, then in a middleware call timezone.activate(ZoneInfo(name)), or deactivate() when unknown. Django then renders aware datetimes and parses form input in that zone, while storage stays UTC.
solid answer
~40 sDjango can't detect a visitor's time zone, since there is no header like `Accept-Language` for it, so I store a zone name on the user or in the session and activate it in a small middleware placed after authentication: `timezone.activate(zoneinfo.ZoneInfo(name))`, else `timezone.deactivate()` so nothing leaks between requests on a reused thread. From then on templates render aware datetimes in that zone, `forms.DateTimeField` parses input as the user's wall time, and `localtime()` and `__date` lookups use it; `timezone.now()` stays UTC. For exceptions I use the `tz` template library: `{% timezone %}` blocks, `{% localtime off %}` and the `localtime`, `utc` and `timezone` filters. Outside requests I wrap rendering in `timezone.override()`.
code
django · 6 lines{% load tz %}
<p>Your appointment: {{ appt.starts_at }}</p>
{% timezone appt.clinic.timezone %}
<p>Clinic local time: {{ appt.starts_at }}</p>
{% endtimezone %}
<p>Stored value: {{ appt.starts_at|utc }}</p>go deeper
Recall that timezone.activate() sets the zone templates render in and that you call it per request from a stored preference.
Explain the middleware pattern with deactivate(), what converts automatically, and the tz library's timezone, localtime and filters.
Cover the edge cases: DST-invalid input, background jobs without middleware, cached pages per zone, and threads reused across users.
Decide where a user's zone is captured and trusted, and how clinics, users and schedules with different zones are modelled.
## The problem An appointments site serves patients and clinicians in several time zones. The database holds UTC instants, and each person must see and enter times on their own wall clock. Django converts at the edges, but only into the **current time zone**, and it has no way to discover that zone by itself: browsers send `Accept-Language`, not a time zone header. ## The pieces Django provides - `django.utils.timezone.activate(tz)` sets the current zone for the running thread or async context; it accepts a `tzinfo` such as `zoneinfo.ZoneInfo("Europe/Lisbon")` or a zone name string. - `timezone.deactivate()` removes it so the default `TIME_ZONE` applies again. - `timezone.override(tz)` is the scoped version, a context manager and decorator that restores the previous zone on exit. - `timezone.get_current_timezone()` reads it back. ## Step by step 1. **Store the zone.** Add a zone-name field to the user model or profile and let users pick from `zoneinfo.available_timezones()` or a short list of likely locations. For anonymous visitors, use the audience's main zone or UTC, or keep a choice in the session. 2. **Activate it per request** in a small middleware placed after authentication, so `request.user` is available: 3. **Deactivate otherwise**, so a zone from a previous request on the same worker thread never leaks into the next one. 4. **Let the edges convert.** Templates render aware values in the current zone, and form fields parse input in it. ```python import zoneinfo from django.utils import timezone class UserTimezoneMiddleware: def __init__(self, get_response): self.get_response = get_response def __call__(self, request): tzname = getattr(request.user, "timezone", None) if tzname: timezone.activate(zoneinfo.ZoneInfo(tzname)) else: timezone.deactivate() return self.get_response(request) ``` ## Validating the stored zone The middleware above trusts the stored name. `zoneinfo.ZoneInfo()` raises `ZoneInfoNotFoundError` for an unknown key, which would turn one bad profile row into a 500 on every page that user opens. So: - offer a `ChoiceField` built from `zoneinfo.available_timezones()` (or a curated subset) rather than free text; - validate on save, and keep a safe fallback in the middleware, deactivating when the name does not load; - for anonymous visitors, a small script can read the browser's zone and post it to a view that stores it in the session; Django itself never sees it otherwise. ## What then converts automatically | Place | Behaviour with a zone activated | |---|---| | `{{ appt.starts_at }}` in a template | converted to the current zone, then formatted | | `forms.DateTimeField` input | parsed as wall time in the current zone, made aware | | `timezone.localtime(value)` / `localdate()` | converted to the current zone | | `__date`, `__hour`, `TruncDay` in queries | evaluated in the current zone | | `timezone.now()` | still UTC; it never depends on the current zone | ## Template tools for exceptions Load the `tz` library with `{% load tz %}`: - `{% timezone "Asia/Singapore" %} … {% endtimezone %}` renders a block in a named zone, for example to show the clinic's local time next to the patient's; `{% timezone None %}` uses the default zone. - `{% localtime off %} … {% endlocaltime %}` disables conversion inside the block, which shows values in their stored UTC form. - Filters: `{{ value|localtime }}`, `{{ value|utc }}` and `{{ value|timezone:"Europe/Lisbon" }}` convert a single value. - `{% get_current_timezone as TIME_ZONE %}` exposes the active zone's name, for example to label times. ## Edge cases worth naming - **Nonexistent and ambiguous input.** When a patient types a time that falls in a DST gap or overlap for their zone, `forms.DateTimeField` raises a validation error (code `ambiguous_timezone`) instead of guessing. - **Background work.** Middleware never runs for jobs or management commands; wrap per-recipient rendering in `timezone.override(user_tz)`. - **Caching.** Pages rendered in the user's zone differ per user; cache keys must include the zone. - **The locale is separate.** Language and time zone are independent: a French speaker may live in Montreal. Language is picked by `LocaleMiddleware`; time zone by your middleware.
- Why must the middleware call timezone.deactivate() when a user has no zone?`activate()` stores the zone for the current thread or async context, and worker threads serve many requests. Without deactivating, an anonymous visitor can inherit the zone of the previous user on that thread and see times shifted by hours.
- What does Django do when a user submits a time that falls in their zone's spring-forward gap?`forms.DateTimeField` refuses to guess: making the naive input aware in the current zone detects that the wall time is nonexistent or ambiguous and raises a `ValidationError` with code `ambiguous_timezone`, which the form shows as a field error.
saying these in an interview costs you the question
- LocaleMiddleware also activates the user's time zone
- Browsers send the time zone, so Django activates it automatically
- Activating a zone changes what timezone.now() returns
- Stored datetimes must be converted to the user's zone before saving
- timezone.activate() is global to the process