The missing guide to handling timezones in Rails and PostgreSQL.
I wanted to write this guide to be your new default practice to handling time zones in Ruby on Rails. This was a hard-won lesson I learned from working a calendar scheduling app a few years back. I want you to stop converting time in your application. This is already a solved problem, no need for Rails devs to do it!! I know you have the sneaky timezone converter code, and it needs to go!
I always read on the internet that your application should use UTC for all the database related time zone information, but there is some setup that is needed in order to realize the effectiveness of this practice.
First things first, postgres itself recommends that you not store UTC timestamps with a
timestamp type. Instead you should use a
timestamptz type. See: https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use_timestamp_.28without_time_zone.29
Now that rails has finally set up a configurable option to generate migrations with the appropriate types (See: https://github.com/rails/rails/pull/41084, https://github.com/rails/rails/pull/41395)
These posts outline the core of the problem. We want to use timezones, and we want
Time to all work together as they are supposed to.
If you read the above referenced links, you'll see that this subtly and changes the types and some of the behaviors of the time zones. These are all unfortunate results of the mess we have made. Since it's impossible for Rails to know what time zone you intended. Keep this in mind as you upgrade your timezones columns.
When you store regional timestamps without the
timestamptz information, it forces you as an application developer to coerce this type into the appropriate zone for the user. What this essentially means is that in application code, you're going to have something that perhaps calculates the difference of hours between your client's timezone and your database default of
UTC time. This is the normal default response, but there's a better way.
Instead do this. Set a few fallbacks for where your default application timezones should live. For example you can detect the location of the client ip address and use that to determine their default time zone. Here I just pick my own timezone to be the default.
Then you store all of your
whatever_at time information inside of a the appropriate
timestamptz, and you allow the timestamp to come from where it really belongs.
- default application time zone (Maybe your HQ time zone)
- User defined timezone (This is a string column named timezone on your user model)
- Resource defined timezone (This is a string column named 'timezone' on your non-user model)
Here's a helper method that can pretty easily be generalized or converted into a mixin/module as needed:
class Event def started_at super.in_time_zone(time_zone) end def finished_at super.in_time_zone(time_zone) end def time_zone ActiveSupport::TimeZone[super] || default_timezone end def default_time_zone ActiveSupport::TimeZone["Pacific Time (US & Canada)"] end end class User def time_zone ActiveSupport::TimeZone[super] || default_time_zone end def default_time_zone ActiveSupport::TimeZone["Eastern Time (US & Canada)"] end end
Note that by going through
ActiveSupport::TimeZone#, I can always ensure that I am dealing with a valid time zone. It's free tz validation!
List available timezones:
Now when I have an event provided by
Event.first.started_at, I will always have an event that's in the correct time zone for the event, and in my view code I can simply override per user/guest with:
# Handles if the user is a guest event.started_at.in_time_zone(current_user&.time_zone || event.time_zone)
By doing it this way, I always store data the way postgres recommends, I only work with time zone aware data types, and convert between time zone aware types, all while at the time time respecting the time zone of whoever created the original resource. For these cases, you have options when setting what the
time_zone should be:
- Ask the user what time_zone they want the Event/resource to be
- Sensibly assume the resource is in the same time zone as the user manually set in their profile
- Guess time zone based on ip geolocation
- Fallback to some application default
Either way, you follow the best practices:
- Store your time information in
- Store the
time_zonewith the resource that it belongs to
- Have a sensible default case
Thank you for reading.