Timezone handling pitfalls
Recently I have had the pleasure to work as a technology leader on a project that requires proper implementation of timezone handling, over some time I learned, sometimes the hard way quite a lot on this topic.
Short intro of the domain
Let us introduce an example domain: task scheduling, the user can schedule a task for an arbitrary day in the future, and when that day comes she can mark it as done. Later on, we will add more requirements. For now:
can_mark_as_done IF Task.scheduled_for <= UTC_NOW.date()
A rule of thumb is to use UTC as a default for your application and servers, so that's what we do here.
Now, let's suppose that the user lives in CET timezone which is UTC+1, this will break our logic: the user won't be able to mark the
Task as done between 00:00-01:00 local time because at that time it's still the previous day in the UTC timezone.
How to solve it? There are a couple of possibilities, let's examine the first one
Changing the server time, e.g. use
datetime.now() instead of
datetime.utcnow()(in Python terms)
Easy, and simple, works like a charm until we won't have users from other parts of the world, other than our server's timezone. Won't fix as they say.
Using timezone-aware type for the
Fixes our logic, but has one substantial drawback - databases do not work well with tz-aware types, for example, Postgres stores the timestamp normalized to UTC and does not store the information about the timezone, we would need to use a separate field to store it. So why complicate stuff? Won't fix
Task.scheduled_for value to UTC shifted from the user's local timezone upon object creation.
So instead of saving just the day, for example,
2020-12-12 we would transform it to datetime object
2020-12-12T00:00 and apply the shift resulting in
2020-12-11T23:00. That way our logic would work smoothly around midnight. And if we would need to query for this field the database would use a naive datetime type.
But... isn't this a bit too complex? Let us introduce one more requirement: our users travel a lot, so they do not stick to just one timezone, we would like to allow them to complete the tasks wherever they currently are
Because we saved the time upon creation, we are limited to that timezone, each time the user would travel to a different timezone we would need to adjust the value - that's pointless. So won't fix.
Using simply local time to validate the rules.
Instead of using our server time to validate the logic, why not use user's local time? Something like this:
can_mark_as_done IF Task.scheduled_for <= LOCAL_DATETIME_NOW.date(). In a web application, we can have the local datetime delivered via a header on each request. Obviously, this loosens up the business rule quite substantially, but it actually happened earlier when we allowed our users to travel ;) One way to prevent "cheating" is to validate the incoming datetime if it is plausible - somewhere between -11 and +14 from current UTC time - as these are the largest possible time shifts.
The above solution is very easy to implement, works fantastic with mobile apps - always adjusting to the user's timezone. My team and the client were very happy with this solution. Until one day.
New requirement coming
We would like to introduce Teams, where users could rival on how many Tasks they complete. To make it fair, this time we will stick to one timezone for Tasks which will count towards the score.
Oh my, and our perfect solution just went down the drain. I will get back to this subject in my next post.
Things to remember
- use plain naive types, which work well with databases
- storing UTC works great with dates in the past but for future dates, it becomes unnecessarily complex
- when possible, store naive-local date/datetime and evaluate it "when the time comes" against a local datetime, I call this approach floating timezones