How to Use Time Zones with Django
Introduction
First of all, why should I use time zones? Perhaps you have users in multiple time zones and would like time values to be presented relative to the user's own location. If your project includes a billing component measured in time, time zones may be critical. And according to Django, using time zones ensures greater accuracy that reduces problems:
When time zone support is enabled, Django uses a more accurate model of local time. This shields you from subtle and unreproducible bugs around daylight saving time (DST) transitions.
So how do you get started with time zones? Well, you're probably already using time zones to an extent because with Django >= 5.0, time zones are enabled by default. If you're unsure if time zones are enabled, especially if you're working with an older version of Django, check that the following has been set in your project settings:
# settings.py
USE_TZ = True
With that out of the way, we can proceed with configuring our project. To do so, we'll need to take a couple of steps. First, we need to have a means of storing a user's preferred time zone. Then we need to set up our project to translate datetime objects from the project time zone to the user's saved time zone (if different). Last, we need to adjust our code to be time zone aware. Let's jump in.
Store the User's Time Zone
There are a couple of ways to go about store the user's time zone. A common approach is to store the user's preferred time zone in the database. This is also the approach I generally take. But first I want to mention alternatives.
Cookies
Instead of using a database approach, you could use a cookie-only approach to storing the preferred time zone provided by the user. For example, let's say we have a Django FormView class based view that collects the user's time zone from a form field user_timezone. We'll grab this value from the form_valid view method and store it in a session cookie. Note, we need to use the view's form_valid method because the request object is needed for this operation. This wouldn't be available to use in the form if we tried to use the form's clean methods, for example.
Here's a simplified example of what this would look like:
# views.py
from django.views.generic import FormView
class SetUserTimeZoneView(FormView):
...
def form_valid(self, form):
timezone = form.cleaned_data.get('user_timezone')
self.request.session['user_timezone'] = timezone
I call this approach a cookie-only approach because I will still use cookies to retain the user's time zone close by even with time zones stored in the database. This way, the time zone is cached and a database hit isn't necessary with each request.
Another reason we may want to start with cookies instead of using the database is we could be bypassing user input altogether and determining the time zone using javascript. Using the javascript internationalization api, we could do:
>>> Intl.DateTimeFormat().resolvedOptions().timeZone
<<< "America/Los_Angeles"
There are also valid approaches using the javascript Date object's getTimezoneOffset prototype method. However, this approach is a bit more tricky and nuanced.
A word of caution regarding these javascript approaches: The time zone string generated with javascript may not map to the time zone string used on the python side! Django (>= 5.0) uses the python zoneinfo module which uses IANA time zone names just as javascript Intl does. But earlier when python time zone choices were produced using pytz, my time zone would resolve to 'US/Pacific' instead of the 'America/Los_Angeles' time zone name you can see in the above javascript snippet.
This difference would likely cause problems for Django. Though I'm not entirely sure because I haven't personally experimented with using both python's pytz and javascript's Intl at the same time. But the point of the Python Enhancement Proposal (PEP) 615 was to add support for IANA, so I imagine problems would occur if using pytz. Just be aware.
Database
Now the database approach. This approach is quite simple. All we need to do is extend the user model with a 'profile' related model and use a CharField to store the value. We'll restrict the field's choices to only those provided by tzinfo. Doing so will also pre-populate our model form with those choices so we can conveniently select from a dropdown. This model could look something like:
# models.py
import zoneinfo
from django.db import models
from django.conf import settings
TIMEZONES = tuple(zip(
zoneinfo.available_timezones(),
zoneinfo.available_timezones()
))
class UserProfile(models.Model):
models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='user_profile')
timezone = models.CharField(max_length=32, choices=TIMEZONES, default='UTC')
Middleware
Next, we need a way to take the time zone value stored in the database and activate the time zone. Django recommends using middleware to accomplish this. The docs provide a simplified snippet:
import zoneinfo
from django.utils import timezone
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
tzname = request.session.get("django_timezone")
if tzname:
timezone.activate(zoneinfo.ZoneInfo(tzname))
else:
timezone.deactivate()
return self.get_response(request)
This snippet retrieves the time zone value from a session cookie with the key 'django_timezone' and then uses the Django time zone activate function. However, the above middleware doesn't retrieve the time zone value from a database. I'll modify the middleware to retrieve the user's preferred timezone from their profile and then activate the time zone if the value is available.
import zoneinfo
from django.utils import timezone
class TimezoneMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
def _activate_tz(tz):
timezone.activate(zoneinfo.ZoneInfo(tz))
tzname = request.session.get("django_timezone", None)
if tzname:
_activate_tz(tzname)
else:
# no time zone stored in session cookie, check to see if available from user profile
user = request.user
if hasattr(user, 'user_profile'):
tzname = user.user_profile.timezone
# set the cookie
request.session['django_timezone'] = tzname
_activate_tz(tzname)
else:
timezone.deactivate()
return self.get_response(request)
We're now ready to start using time zones with our project once this middleware is included in MIDDLEWARE in settings.py.
Now, we'll see our local time in our forms and in our templates. Please note, time zones are still being stored UTC (or whatever your default time zone is set to in your project settings). Instead, the local times you'll see in forms is converted by Django prior to being committed to the database.
Using Time Zones
Now that we're all set for time zones in our project, we need to make sure our existing code supports time zones.
Without time zones being enabled, Django will default to naive time zone objects. For example:
import datetime
now = datetime.datetime.now()
Since our project now uses time zones, we want to make sure we use time zone aware date time objects. Django provides a convenient utility to accomplish this:
from django.utils import timezone
now = timezone.now()
Be sure to use this utility going forward. If you have legacy code that uses the former datetime.now(), Django will provide a soft warning to help you locate code that needs refactoring.
See the docs for more information.
Final Thoughts
Are time zones necessary? Depending on your project, perhaps not. For this particular project, I chose to enable and work with time zones. Since I'm just publishing content at this time, the user experience won't be materially impacted whether or not time zones are enabled. For me personally, however, I want to see my local time when publishing articles with a timestamp. This is more of a preference than a crucial requirement. But if you're managing billing, or other more critical time-based considerations, time zones might be a must.
Either way, time zones are just one of many cool features that Django provides for out of the box and without much configuration.