Fix Wrong Times in Django Production: TIME_ZONE and USE_TZ
Fix Django showing times hours off in production: understand USE_TZ and UTC storage, set TIME_ZONE correctly, activate per-user time zones, render local times in templates, and stop cron and Celery schedules from drifting.
Problem statement
Order timestamps in the admin are two hours off. A report "for today" misses everything after 10 p.m. A Celery schedule set for 9:00 fires at 7:00. Locally everything looked right, because your laptop, your settings, and your expectations all shared one time zone. On the server, the operating system, the database session, Django, and the user's browser can each assume a different one.
- Keep
USE_TZ = True. Django then stores aware datetimes in UTC. - Set
TIME_ZONEto your main audience, such as"Europe/Berlin". It controls display and form input, not storage. - For users in several zones, activate their zone per request with
django.utils.timezone.activate(). - Compute "today" with
timezone.localdate(), notdate.today()ordatetime.now(). - Set Celery's
CELERY_TIMEZONEand cron entries deliberately; servers usually run in UTC.
Step-by-step solution
1. Check the four clocks
timedatectl | grep "Time zone"
python manage.py shell -c "from django.conf import settings; from django.utils import timezone; print(settings.USE_TZ, settings.TIME_ZONE, timezone.now(), timezone.localtime())"
psql "$DATABASE_URL" -c "SHOW timezone;"
The server is often Etc/UTC. With USE_TZ = True, timezone.now() is UTC and timezone.localtime() converts it to TIME_ZONE. If localtime() shows the wrong hour, TIME_ZONE is wrong. If stored values look shifted, something wrote naive local times.
2. Store UTC, display local
USE_TZ = True
TIME_ZONE = "Europe/Berlin"
Django converts to UTC on save and back to the current time zone when templates and forms render values. In templates, values are converted automatically; in Python code you get UTC unless you call localtime(). Check that nothing in your code uses datetime.now() or datetime.utcnow(); both return naive values. Use timezone.now().
3. Activate per-user time zones
TIME_ZONE is one zone for everyone. If users span regions, store each user's zone and activate it in middleware:
import zoneinfo
from django.utils import timezone
class UserTimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tz = getattr(getattr(request, "user", None), "timezone", None)
if tz:
timezone.activate(zoneinfo.ZoneInfo(tz))
else:
timezone.deactivate()
return self.get_response(request)
Templates and forms then render and parse times in that user's zone.
4. Compute dates in the right zone
"Orders today" filtered with created__date=date.today() uses the server's date and compares it with the UTC date of each row. Use the local date and an aware range instead:
from datetime import datetime, time, timedelta
from django.utils import timezone
today = timezone.localdate()
start = timezone.make_aware(datetime.combine(today, time.min))
end = start + timedelta(days=1)
Order.objects.filter(created__gte=start, created__lt=end)
Lookups such as created__date use the current time zone when USE_TZ = True, but explicit ranges are clearer and use indexes.
5. Align schedules
Celery Beat interprets crontab schedules in CELERY_TIMEZONE, which defaults to UTC. Set it to the zone your schedule is written in:
CELERY_TIMEZONE = "Europe/Berlin"
CELERY_ENABLE_UTC = True
System cron uses the server's zone, usually UTC. Either write cron times in UTC or set CRON_TZ=Europe/Berlin at the top of the crontab where supported.
Explanation
With USE_TZ = True, every datetime Django handles is aware, and PostgreSQL stores timestamp with time zone values as UTC instants. Nothing about storage depends on TIME_ZONE. That setting is only the default current time zone used when rendering and parsing. So a "wrong time" is almost always a rendering or computation problem, not corrupted data, which means it can be fixed without migrating rows.
Changing the server's system time zone to local time seems tempting but makes things worse: logs, cron, and backups shift twice a year with daylight saving time, and UTC-based tools no longer line up.
Edge cases / notes
- USE_TZ = False projects. Old projects store naive local times. Switching on
USE_TZreinterprets existing rows as UTC; convert data deliberately first. - MySQL. Converting time zones in queries requires the MySQL time zone tables. See Fix MySQL Time Zone Definitions for Django.
- Daylight saving gaps.
make_awarecan raise for times that do not exist in a zone. Validate user-entered times near DST changes. - APIs. Django REST Framework renders datetimes in the current zone with offsets. Clients should parse the offset rather than assume UTC.
Next steps
- Clean up naive values with Fix Django Naive Datetime Warnings.
- If scheduled tasks do not fire at all, see Fix Celery Beat Not Sending Tasks.
- Confirm time settings with the Django Production Settings Checklist.
FAQ
Should TIME_ZONE be UTC?
It can be, if you display times through per-user activation or on the client. For a single-region site, setting your audience's zone makes the admin and emails readable.
Is my data wrong if the admin shows the wrong time?
Usually not. With USE_TZ = True the stored instants are correct UTC; only the display zone is wrong.
Why does the date lookup return different rows on the server?
date.today() uses the server clock, often UTC, while users think in local dates. Use timezone.localdate().
Should I change the server's system time zone?
No. Keep servers on UTC and handle zones in Django and Celery settings.