Django and time zone-aware date fields

[Django][django] makes it inordinately complicated to support time zone-aware dates and times because it has so far simply ignored the problem (so far being [Django 1.0.2][django102]).

This is understandable given the database-agnostic nature of the Django ORM: although [PostgreSQL 8.3][postgres83] supports a datetime type which is time zone-aware, [MySQL 5.1 does not][mysql51] (I have no idea what [SQLite][sqlite] does about time zones). By ignoring time zones, Django works with the lowest common denominator.

Given time zone support in Postgres, there is a chunk of work to write a variation of [`models.DateTimeField`][datetimefield] which can handle time zone-wise datetimes. Python 2.5 does not help things – [Python’s native datetime module][datetime] is similarly agnostic about time zones, the standard library does not include a module for handling wise datetimes.

(If regular datetime instances are *naive* then datetime instances that honour time zones are *wise*.)

Django does make it pretty easy to [write a custom field class][customfields], which means it shouldn’t be too difficult to write a custom datetime field class that is time zone-wise. As ever it is the Django project’s regard for documentation that transforms *that which is possible* into *that which is practical*.

Given your backend database has a time zone-wise datetime type (i.e. PostgreSQL), what input values does one need to handle in a time zone-wise custom field class?

* value set to None
* value set to a naive datetime instance
* value set to a wise datetime instance
* value set to a naive datetime string
* value set to a wise datetime string

Now the essence of a custom field in Django is two methods: `to_python` and `get_db_prep_value`. If the custom field defines

__metaclass__ = models.SubfieldBase

then the `to_python` method will be called any time a value is assigned to the field, and we can make sure that a suitable type is returned before the model object is saved. Because Postgres [supports time zone-wise datetimes][postgresdt] and if we take care to return a wise datetime instance we can ignore `get_db_prep_value`.

When Django reads a record from the database it strips the time zone information, effectively giving your custom field a naive datetime string that belongs to the same time zone as the database connection object. (At least this seems to be true for Postgres and [the psycopg2 adaptor][psycopg2].) And since the database connection sets the time zone to be the same as set by [`settings.TIME_ZONE`][settingstz] your custom class needs to treat any naive datetime strings as belonging to the time zone set with `settings.TIME_ZONE`.

So this leads to the important behaviour for a time zone-wise `DateTimeField` sub-class: always convert naive datetimes to the time zone set in `settings.TIME_ZONE`.

For convenience my custom field class, the `TZDateTimeField`, returns a sub-class of Python’s `datetime` which has an extra method that converts the datetime to the zone defined by the project’s time zone. Therefore whether the field value has been set from a naive or wise datetime instance, or a naive or wise date string you will end up with a time zone-wise value and you can get the value converted to the project’s time zone. This extra method is intended for use in a Django template.

What I was hoping was that the backend would store the datetime as a datetime in an arbitrary zone, potentially a different time zone from one record to the next for the same field. That behaviour would allow one to infer that one datetime value was created in this time zone while another datetime value was created in that time zone. Instead all datetime values are effectively normalized to your Django project’s time zone.

So here is an example of a model class that uses my time zone-aware datetime field. It ought to work just like a regular `DateTimeField` but always stores a time zone-aware datetime instance:

from django.db import models
from timezones.fields import TZDateTimeField
from datetime import datetime

class Article(models.Model):
pub_date = TZDateTimeField(default=datetime.now)

And below is my custom field definition, which has a dependency on [the pytz module][pytz] to handle all the difficult stuff. [You can grab the complete module over here][timezones], including tests in [doctest format][doctest]. The tests are intended to be run by Django’s `manage.py` test management command, and so one needs to add the module to [the list of installed apps][installedapps].

“””A time zone-aware DateTime field.

When saving, naive datetime objects are assumed to belong to the local time
zone and are converted to UTC. When loading from the database the naive datetime
objects are converted to UTC.

These field types require database support. MySQL 5 will not work.
“””
from datetime import datetime, tzinfo, timedelta
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
import pytz
import re

# 2009-06-04 12:00:00+01:00 or 2009-06-04 12:00:00 +0100
TZ_OFFSET = re.compile(r’^(.*?)\s?([-\+])(\d\d):?(\d\d)$’)

class TZDatetime(datetime):
def aslocaltimezone(self):
“””Returns the datetime in the local time zone.”””
tz = pytz.timezone(settings.TIME_ZONE)
return self.astimezone(tz)

class TZDateTimeField(models.DateTimeField):
“””A DateTimeField that treats naive datetimes as local time zone.”””
__metaclass__ = models.SubfieldBase

def to_python(self, value):
“””Returns a time zone-aware datetime object.

A naive datetime is assigned the time zone from settings.TIME_ZONE.
This should be the same as the database session time zone.
A wise datetime is left as-is. A string with a time zone offset is
assigned to UTC.
“””
try:
value = super(TZDateTimeField, self).to_python(value)
except ValidationError:
match = TZ_OFFSET.search(value)
if match:
value, op, hours, minutes = match.groups()
value = super(TZDateTimeField, self).to_python(value)
value = value – timedelta(hours=int(op + hours), minutes=int(op + minutes))
value = value.replace(tzinfo=pytz.utc)
else:
raise

if value is None:
return value

# Only force zone if the datetime has no tzinfo
if (value.tzinfo is None) or (value.tzinfo.utcoffset(value) is None):
value = force_tz(value, settings.TIME_ZONE)
return TZDatetime(value.year, value.month, value.day, value.hour,
value.minute, value.second, value.microsecond, tzinfo=value.tzinfo)

def force_tz(obj, tz):
“””Converts a datetime to the given timezone.

The tz argument can be an instance of tzinfo or a string such as
‘Europe/London’ that will be passed to pytz.timezone. Naive datetimes are
forced to the timezone. Wise datetimes are converted.
“””
if not isinstance(tz, tzinfo):
tz = pytz.timezone(tz)

if (obj.tzinfo is None) or (obj.tzinfo.utcoffset(obj) is None):
return tz.localize(obj)
else:
return obj.astimezone(tz)

[django]: http://www.djangoproject.com/
[django102]: http://docs.djangoproject.com/en/dev/releases/1.0.2/
[postgres83]: http://www.postgresql.org/docs/8.3/
[mysql51]: http://dev.mysql.com/doc/refman/5.1/en/
[sqlite]: http://www.sqlite.org/
[datetimefield]: http://docs.djangoproject.com/en/dev/ref/models/fields/#datetimefield
[datetime]: http://docs.python.org/library/datetime.html
[customfields]: http://docs.djangoproject.com/en/dev/howto/custom-model-fields/
[postgresdt]: http://developer.postgresql.org/pgdocs/postgres/functions-datetime.html
[psycopg2]: http://initd.org/pub/software/psycopg/
[settingstz]: http://docs.djangoproject.com/en/dev/ref/settings/#time-zone
[pytz]: http://pytz.sourceforge.net/
[doctest]: http://docs.python.org/library/doctest.html
[timezones]: http://reliablybroken.com/b/wp-content/uploads/2009/06/timezones.zip
[installedapps]: http://docs.djangoproject.com/en/dev/ref/settings/#installed-apps

5 thoughts on “Django and time zone-aware date fields

  1. guaq

    This lack of an essential feature is really a pain. One has to write down the actual timezone calculations if there ever is a need to output something in UTC (like RSS feeds). Are you planning to try to get this code into Django?

  2. david Post author

    No plans to get this into Django. For one thing I haven’t posted the complete package I ended up with that includes a forms.Field sub-class for processing time zone strings in form inputs.

    Django’s datetime model field and its datetime form field each has its own string parsing routine, which is odd. My approach tries to use a single routine for both cases. If you think you could use it let me know and I’ll put the whole package up here.

  3. Pingback: Reliably Broken » Django and time zone-aware date fields (redux)

  4. Pingback: django in june

Leave a Reply

Your email address will not be published. Required fields are marked *