Timezone handling pitfalls I
Short intro of the domain
Recently I have the pleasure to work as 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.
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 marked 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 lets 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 couple of possibilities, lets examine the first one
Changeing the server time, e.g. use
datetime.now() instead of
datetime.utcnow()(in Python terms)
Easy, and simple, works as 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 stuf? 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 the 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 to 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 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 largest possible time shifts.
Above solution is very easy to implement, works fantastic with mobile apps - always adjusting to 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 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
Further reading / sources
- How to save datetimes for future events - (when UTC is not the right answer)
- Postgres documentation 188.8.131.52 timestamp tz-aware
- StackOverflow - Good starting point for learning about timezone handling pitfalls
- Part II of this series.