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 ActiveSupport::TimeZone
, ActiveSupport::TimeWithZone
, DateTime
, 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 created_at
, updated_at
and 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:
ActiveSupport::TimeZone.all.map(&:name)
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
timestamptz
by default - Store the
time_zone
with the resource that it belongs to - Have a sensible default case
Thank you for reading.
Top comments (0)