Ruby and Rails provide great tools for working with time and time zones. But my experience shows that Rails developers often pay little attention to how this magic really works, which often results in "ghost" problems that arise in particular configuration and time. The main gotcha for developer is that you can use "wrong" methods in development and fairly often get valid results. But then you'll face with unexpected problems on production.

Historically Ruby provides two classes to manage time: Time and DateTime. They use different approaches internally, which caused different abilities and performance for them. But with Ruby 1.9.3 these differences seem to be vanished and you are free to choose whatever interface you like. But that's in Ruby! When it comes to Rails things get a bit more complicated.

Rails gives your ability to configure application time zone. It's as easy as Time.zone = 'EST'. Setting config.time_zone in your application.rb will eventually do the same. And this is the right thing to do since we don't want to depend on server time zone. But the problem here that now we have 3 (!) different time zones in our application: system time, application time and database time. And if you won't use correct methods to deal with time all them get mixed up.

What's the trick?

Let me give you an example. Let's say you have server in Moscow (GMT+04:00), but most of your customers are from US and you use Eastern Standard Time (GMT-05:00) in your application. For database Rails by default converts all timestamps to GMT. Let's try calling different methods and compare the results.

1
2
3
4
5
6
7
Time.zone = 'EST'
DateTime.now.to_s
# => "2012-07-06T13:30:00+04:00"
Time.now.to_s
# => "2012-07-06 13:30:00 +0400"
Time.zone.now.to_s
# => "2012-07-06 04:30:00 -0500"

You might notice that DateTime.now and Time.now both give you the time in system time zone. And it definitely makes sense since these are Ruby standard library methods that know nothing about Rails time zone configuration. Time.zone.now on the contrary is provided by Rails and respects Time.zone value that we set in Rails.

But you might argue that if we convert all these values to one time zone they will be the same. And you are right, if you try to save this time to ActiveRecord model or compare to another value you will get expected results. Then what's the problem? Let's try some manipulations with time.

1
2
3
4
5
Time.zone = 'EST'
Time.now.end_of_day.to_s
# => "2012-07-06 23:59:59 +0400"
Time.zone.now.end_of_day.to_s
# => "2012-07-06 23:59:59 -0500"

Whoops, by using different time zones in initial time we got completely different results. That means that before we manipulate time we either need to explicitly convert time to required time zone or initially get time in correct time zone. Like this:

1
2
3
4
5
Time.zone = 'EST'
Time.now.in_time_zone.end_of_day.to_s
# => "2012-07-06 23:59:59 -0500"
Time.zone.now.end_of_day.to_s
# => "2012-07-06 23:59:59 -0500"

Here is another trick. Let's time travel to the next morning. What have we got here?

1
2
3
4
5
6
7
Time.zone = 'EST'
Time.now.to_s
# => "2012-07-07 06:30:00 +0400"
Time.zone.now.to_s
# => "2012-07-06 21:30:00 -0500"
Date.today
# => "Fri, 07 Jul 2012"

But wait, application should still find itself in 6th of July since it uses EST time zone. As you might have guessed, Date.today doesn't know anything about Rails time zone too and uses system time to determine current date.

So, when we know some troubles it might cause, let's find out which methods are safe to use.

The Right Way to Go

You might have noticed from given examples that interesting construct Time.zone that seem to produce correct results. So, what is it? Actually above I cheated for simplicity of introduction. It's not Rails responsible for adding time zone, but ActiveSupport. It adds Time.zone= and Time.zone methods to correspondingly set and get current time zone. If you look into source of these methods you'll find that the first one finds requested ActiveSupport::TimeZone object and stores it in the current thread, while the second one just fetches if from there.

So, it's ActiveSupport::TimeZone object where all the magic happens. It defines number of methods that makes its API similar to the one of Time class. That's why from the user point of view it looks like a simple switch from Time.now to Time.zone.now. But what actually happens in Time.zone.now method? Turns out it simply grabs current time and converts it do current time zone (which is self).

1
2
3
4
5
class ActiveSupport::TimeZone
  def now
    Time.now.utc.in_time_zone(self)
  end
end

And here is the last line of defense, in_time_zone method. It is added by ActiveSupport to both Time and DateTime Ruby standard library methods. In both cases it does the same - converts time to ActiveSupport::TimeWithZone object passing specified time zone to it. This class behaves exactly like Ruby Time object, it even redefines class name. So you can manipulate it as you usually do in Ruby.

Now when we know what's under the hood, I will leave you with brief cheat sheet on correct and incorrect methods for dealing with time in Ruby on Rails.

Get Current Time

Correct
Time.zone.now
Acceptable
1
2
Time.now.in_time_zone
DateTime.now.in_time_zone
Wrong
1
2
Time.now
DateTime.now

You should now understand that wrong option will give you time in system time zone, which might cause problems. Second (acceptable) option will work fine as it converts time to Rails time zone. As we have seen above it is exactly what ActiveSupport is doing internally, but there is no need to use it explicitly as there is shorter and more clear option.

Get Day (Today, Yesterday, etc.)

Correct
1
2
Time.zone.today
Time.zone.today - 1.day
Acceptable
1
2
Date.current
Date.yesterday
Wrong
Date.today

ActiveSupport defines Date.current method that proxies call to Time.zone.today and it also redefines Date.yesterday and Date.tomorrow helpers. But I personally find them quite confusing and easier to be replaced with Date.today by mistake. So I prefer to use explicit version, but feel free to disagree here.

Build Time

Correct
Time.zone.local(2012, 6, 10, 12, 00)
Wrong
1
2
Time.new(2012, 6, 10, 12, 00)
DateTime.new(2012, 6, 10, 12, 00)

I believe this one doesn't need any comments as Ruby classes are simply not aware of the time zone in use.

Time From Timestamp

Correct
Time.zone.at(timestamp)
Acceptable
Time.at(timestamp).in_time_zone
Wrong
Time.at(timestamp)

Parse Time (Simple)

Correct
Time.zone.parse(str)
Wrong
Time.parse(str)

Parse Time (With Explicit Format)

Acceptable
DateTime.strptime(str, "%Y-%m-%d %H:%M %Z").in_time_zone
Wrong
DateTime.strptime(str, "%Y-%m-%d %H:%M")

Here I have to make one important note. Correct option will give valid results only when time string (and format of course) explicitly include time zone (note %Z in format string). But there are cases when you need to parse time without specified time zone. In this case you usually need to treat this time in current time zone. Unfortunately ActiveSupport doesn't provide convenient means for this. To fill this gap you can use my micro gem TimeZoneExt that allows you to parse time with or without explicitly specified time zone. This adds one more correct option.

Correct
Time.zone.strptime(str, "%Y-%m-%d %H:%M")

Get Time For Date

Correct
date.beginning_of_day
Acceptable
date.to_time_in_current_zone
Wrong
date.to_time

Again, default to_time method provided by Ruby knows nothing about time zone in use. So you should use one of the methods defined in ActiveSupport. I personally find beginning_of_day more explicit, although to_time_in_current_zone gives the same result.

Conclusion

There is no rocket science in dealing with time in Rails. But it's a good idea to understand it once and always keep in mind that when you build time or date object you should respect current time zone. In most cases it simply means to use Time.zone instead of Time, Date or DateTime.

I do not cover dealing with other than default time zones in Rails. This post has another purpose. But it is possible and quite easy to do. And now you have references to classes to look into.

comments powered by Disqus