Handling Timezones in Rails
Tags: rails, postgres, programming, webdev
Published: 2023-06-21 04:10:29

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.

1. default application time zone (Maybe your HQ time zone)
2. User defined timezone (This is a string column named timezone on your user model)
3. 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:

```ruby
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:
```ruby
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:

```ruby
# 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:

1. Ask the user what time_zone they want the Event/resource to be
2. Sensibly assume the resource is in the same time zone as the user manually set in their profile
3. Guess time zone based on ip geolocation
3. Fallback to some application default

Either way, you follow the best practices:

1. Store your time information in `timestamptz` by default
2. Store the `time_zone` with the resource that it belongs to
3. Have a sensible default case

Thank you for reading.