Dates and Time Zones in Rails

2014-04-18

I work on a video education app that needs to report how many lessons were assigned each day. I have a timetracking app that needs to invoice based on when one month starts and another ends. And I’ve got another app for watching cronjobs, that counts how many failures happened in a given day. All these apps share a problem: if they get time zones wrong, they will give incorrect results. For instance, if a video lesson was assigned on Tuesday after 5pm PDT, its UTC time will be Wednesday. Even though all three apps only show dates, not times, time zones still matter. So here are some lessons I’ve learned dealing with Rails time zones, both programming myself and leading a team.

But first, here are two articles that lay some groundwork:

Both those posts describe some nice methods provided by ActiveSupport/Rails, as well as why you’d want to use them instead of built-in Ruby methods. For instance, saying Time.now will get you the time in your OS’s default time zone, whereas Time.zone.now will get you the time in whatever timezone you put in config/application.rb. It’s a good idea to use the Rails timezone, since you have more control over that and it’s part of your source control.

If you want to explore, take a look at ActiveSupport::TimeZone. You can get a specific time zone like this:

tz = ActiveSupport::TimeZone['Pacific Time (US & Canada)']

That tz will be the start of just about everything you do with time. An ActiveSupport::TimeZone is also what you get from Time.zone.

Those other articles are great, but this post talks about time zones from a higher level, aspiring to share best practices rather than helpful methods. In my examples I’ll assume a Postgres database, although there’s nothing here that won’t work on MySQL as well.

Scope Your Time Zones

The first thing to remember is that all times are relative to somebody. Probably that’s a user, but maybe it’s something else. For my video education app, sometimes it’s a teacher and sometimes it’s a student. For my cronjob app, it’s a job (and sometimes a user). But there is always a reference point. In that sense, the articles above that recommend Time.zone.now are wrong. Time.zone.now is no better than Time.now if you need many time zones—and who doesn’t? So it should really be user.time_zone.now. I’d recommend adding this method to your User class right from the beginning, so that even if you haven’t yet implemented user-specific time zones, you are still writing the rest of your app correctly:

def time_zone
  ActiveSupport::TimeZone['Pacific Time (US & Canada)']
end

Ignore Time Zones as Much as Possible

Now that you’ve got time zones everwhere, the next step is to get rid of them. Thinking about time zones is hard, and finding time zone bugs is hard. You want to avoid them as much as possible!

It’s been said that in C, if you start thinking about big endian vs little endian, you’re probably doing things wrong. Oftentimes when programmers half-understand something, they do more than they should, and that’s very true with time zones. Your code will be a lot easier to understand if you tackle time zones with a few well-placed strokes, rather than lots of fiddling all over the place.

One thing to remember is that 07:05:00 PDT and 14:05:00 UTC are the same instant. If you converted both to seconds since the epoch, you’d get the same number. So in that sense, changing the time zone doesn’t change anything: it’s just a bit of extra metadata hanging onto the time representing someone’s perspective. Knowing that x PDT and y UTC are the same instant is really helpful when you feel the urge to fiddle with timezones. Is your fiddling just a noop?

By default, Rails comes configured with its default time zone as UTC. Leave it that way! Since every time needs a reference point anyway, your code shouldn’t care about the Rails-wide setting. UTC is a good neutral choice. For one thing, it doesn’t have daylight savings time. And if you see it, you know you’re dealing with a time-zone-less value.

You should also leave your OS time zone as UTC, if possible. Keeping it consistent with Rails will remove one chance for abiguiuty. And again, it’s a good neutral.

You also want UTC in your database. If you use a migration to create columns with t.timestamps or t.datetime :foo, Rails will make a TIMESTAMP WITHOUT TIME ZONE column. You can think of this as a time in UTC if you like. Really it’s an int of (micro)seconds since the epoch. Whenever you give a time to ActiveRecord, it will convert it to UTC before it hits Postgres. Or more correctly, it will strip off the timezone part and give Postgres the int. Remember, it’s the same instant! But it’s nice to imagine the column as UTC. If you’re in psql and type SELECT created_at FROM lessons, that’s what you’re seeing.

When everything in your stack is UTC, it’s like looking through nice clear glass. You don’t have to think about conversions at each layer.

You should also strive to make your app code as time-zone-less as possible. The big principle here is to handle time zones once, hopefully at the beginning of the HTTP request, when you decide the correct reference point for all times. Usually that’s current_user.time_zone.

The second article above suggests you add this to your controller:

around_filter :user_time_zone, if: :current_user

def user_time_zone(&block)
  Time.use_zone(current_user.time_zone, &block)
end

That makes me pretty uncomfortable. The idea is that you can say Time.zone everywhere else in your app and always get the current user’s time zone. But I’d rather be explicit about where the time zone is coming from. (Also see below for why use_time doesn’t help at all for parsing times.) Instead, write code to take a tz argument if necessary (emphasis on the if necessary). This will make your code less surprising, less coupled, and easier to test.

Even better is to write your code to take just a time. Almost always that’s really what you want. Remember that no matter the time zone, it’s all the same instant. Usually a single time is sufficient, because it can be a reference point for creating other times, using t + 1.day or t + 2.weeks or whatever. Use a (user|cronjob|foo)-scoped time zone to get your first time, and then forget about time zones for the rest of the stack. If you implemented the User#time_zone method above, your time zone code is already well-encapsulated, so there’s no need for an around_filter to further abbreviate things.

Here is another approach I don’t like. This blog post suggests you deal with time zones in Postgres like so:

SELECT created_at AT TIME ZONE 'UTC' AT TIME ZONE 'US/Pacific'

Does that look strange to you? What’s happening is that you start with a TIMESTAMP WITHOUT TIME ZONE, so first you tack on a time zone (just metadata), then you convert it to Pacific time. In other words, you’re doing this:

time without zone -> assumed to be UTC -> converted to Pacific Time

It’s good to know that this is how to get a Postgres timestamp converted to whatever time zone you want, but I wouldn’t recommend it in a Rails app (which is the article’s context). For one thing, Rails and Postgres don’t use the same names for all timezones, so you have to maintain your own mapping between the two. This is one of those chores that will keep nagging you for the life of your app, so I’d rather just avoid it. But more important, this approach means you have to pass your timezone all the way down to the database layer. I’d rather deal with time zones early, get to a TimeWithZone (or even an int), and then forget about time zones for the rest of the code.

Remember Daylight Savings Time

This is a small tip, but be careful about DST. You should avoid ever writing a fixed offset or a string like “PDT”. This is wrong:

Time.new(2014, 4, 8, 3, 15, 0, '-07:00')

If you were after PDT, your code is broken during PST. If you were after MST, your code is broken during MDT. Similarly in SQL if you say AT TIME ZONE 'PDT', you’ve broken PST.

If you stick with the ActiveSupport::TimeZone instances, you can forget about DST, and things will just work. That’s why there is no such thing as this:

ActiveSupport::TimeZone['PDT']

only this:

ActiveSupport::TimeZone['Pacific Time (US & Canada)']

Even Dates are Times

When you think you’re just dealing with dates, time zones probably still matter. Was the lesson assigned on Tuesday or Wednesday? It depends. From the teacher’s perspective? The student’s? The principal’s? Unless you’re really sure, I’d recommend always storing a full date+time in your database. Also, in Ruby avoid converting things to Date. Of course if you read the articles above you know you don’t want this:

Date.today

But this isn’t any better:

Time.zone.today

Or even this:

current_user.time_zone.today

Here is a query I’ve seen:

Lesson.where("date(created_at) >= ?", tz.today - 3.days)

But that’s wrong, because you’re stripping off all the time zone information. It’s going to report Tuesday’s lessons in Wednesday. (It also means you need a database index on the expression date(created_at), which is probably less often useful than a normal index on just the column.)

To improve this query, I’d write it like this:

Lesson.where("created_at >= ?", tz.now.midnight - 3.days)

If today is Wednesday (for you), that will give all the lessons created since the beginning of Sunday. Because we’re carrying a full time all the way down to the database query, we don’t have problems with the definition of “today.”

If you are ever tempted to use today or to_date, you probably want these methods instead:

tz.now.midnight     # the start of today, 00:00:00
tz.now.end_of_day   # 23:59:59

Parsing times

Now that you’ve got a per-user time zone, you want to use that to parse date/time inputs from that user. If you user types “3:15”, you want to interpret that as 3:15 in the user’s time zone.

There is a parse method on ActiveSupport::TimeZone, but unfortunately no strptime. If you say this, you get the wrong result:

Time.strptime("2014-04-08 03:15", "%Y-%m-%d %H:%M").in_time_zone(tz)

That’s because strptime assumes the time is in the default time zone (hopefully UTC), and in_time_zone does not change the “instant” the time represents, only the perspective used to view it. If you’re lucky to have a standardish format, this will work:

tz.parse("2014-04-08 03:15")

If your format is something weirder, or you just don’t trust parse with its heuristic-based approach, then you’re probably out of luck. A previous version of this article recommended Time.use_zone(tz) { Time.strptime(...) }, but that doesn’t work, because use_time only changes Time.zone. It doesn’t change Time.strptime.

Another approach with strptime that doesn’t work is to concatenate the time zone to your string and use %Z (and friends) in your format, like this:

Time.strptime('2014-04-08 03:15' + ' ' + tz.formatted_offset, "%Y-%m-%d %H:%M %:z")

The problem here is that tz.formatted_offset really depends on the time of year because of Daylight Savings Time. But you don’t know that until you parse the string. You could pass the timezone abbreviation instead, like PDT, but that has the same problem. And %Z doesn’t understand long names like Pacific Time (US & Canada).

The only approach I know that works is to parse the time with your default time zone, then feed the bits into tz.local, like so:

t = Time.strptime("2014-04-08 03:15", "%Y-%m-%d %H:%M")
tz.local(t.year, t.month, t.mday, t.hour, t.min)

Sorry, that’s the best I can do!

UPDATE: I added a strptime method to the TimeZone class (my first Rails code contribution), so hopefully you’ll start seeing it in future versions!

Displaying times

So much for times as inputs. Times as outputs is a lot easier. Your rule should be to ignore time zones until the last minute, when you actually format the value for rendering. So it should go right into your view:

= lesson.created_at.in_time_zone(current_user.time_zone).strftime("%A, %B %-d, %Y")

You might want a helper for this though, something like:

def format_time(time, tz, format)
  time.in_time_zone(tz).strftime(format)
end

Then your Haml can be:

= format_time(lesson.created_at, current_user.time_zone, "%A, %B %-d, %Y")

Or de-parameterize that as much as you like:

def format_time(time)
  time.in_time_zone(current_user.time_zone).strftime("%A, %B %-d, %Y")
end

and:

= format_time(lesson.created_at)

Conclusion

So in general, my principles for handling times in Rails are:

  • Even dates are times.
  • Set “global” time zones to UTC everywhere you can.
  • Don’t ever use global time zones; scope it to a user or whatever is appropriate.
  • To most of your code, a time is just an instant.
  • Push time zone inputs to the very beginning of your request, to the top of your stack.
  • Push time zone outputs to the very end of your request, when rendering the view.

Good luck!

blog comments powered by Disqus Prev: Flash movie (.swf) won't load in Rails 4 Next: Basics of Web Architecture