Working with time zones in Ruby on Rails
Rails provides great tools for working with time zones but there's still a lot of things that can go wrong. This blog post aims to shed some light on these gotchas and provide solutions to the most common problems.
The one that probably has tricked me the most times is the fact that Rails fools you to believe it got you all covered all the time (pardon the pun). Don't get me wrong. I want Rails to do as much work for me as possible. But I've learnt the hard way that I can't get away with not knowing when and how Rails is helping me. Another gotcha is the fact that you have more time zones in play than you might first believe. Consider the following: db, server, dev machine, system configured, user specific configured and the browser.
Configure your Rails app
So what tools do we have at our disposal as Rails developers? The most important one is the
config.time_zone configuration in your config/application.rb file. ActiveRecord will help you convert from and to (which the documentation fails to explain) UTC and the time zone of your choice. This means that if all you're doing is having users post times through a form and use Active Record to persist it you're good to go.
Processing time information
So what about actually doing something with the time information before persisting it? That's when it becomes tricky.
When parsing time information it's important to never do it without specifying the time zone. The best way to do this is to use
Time.zone.parse (which will use the time zone specified in
config.time_zone) instead of just
Time.parse (which will use the computer's time zone).
Work with Numerical and ActiveRecord attributes
Method calls like
2.hours.ago uses the time zone you've configured, so use these if you can! The same thing is true for time attributes on ActiveRecord models.
post = Post.first post.published_at #=> Thu, 22 Mar 2012 00:00:00 CDT -05:00
ActiveRecord fetches the UTC time from the database and converts it to the time zone in
config.time_zone for you.
Date vs Time
Time has date information but Date does NOT have time information. Even if you don't think you care you might realize that you do sooner then later. Be safe and use Time (or DateTime if you need support for times very far from the present).
But let's say you're stuck with a Date that you need to treat as a Time, at least make sure to convert it to your configured time zone:
1.day.from_now # => Fri, 03 Mar 2012 22:04:47 JST +09:00 Date.today.to_time_in_current_zone # => Fri, 02 Mar 2012 00:00:00 JST +09:00
Date.today.to_time # => 2012-03-02 00:00:00 +0100
Since Rails know that your time information is stored as UTC in the database it will convert any time you give it to UTC.
Post.where(["posts.publised_at > ?", Time.zone.now])
Just be sure to never construct the query string by hand and always use Time.zone.now as the base and you should be safe.
Working with APIs
Building a web API for others to consume? Make sure to always send all time data as UTC (and specify that this is the case).
Time.zone.now.utc.iso8601 #=> "2012-03-16T14:55:33Z"
Read more about why iso8601 is advisable here: http://devblog.avdi.org/2009/10/25/iso8601-dates-in-ruby/
When you get the time information from an external API which you don't have control over you simply need to figure out the format and time zone it's sent to you with. Because Time.zone.parse might not work with the format you receive you might need to use:
Why there's no #strptime method on Time.zone when there's a #parse beats me. However don't forget to call in_time_zone(Time.zone) on your result!
Working with multiple user time zones
Many systems needs to support users entering and viewing time information in a variety of time zones. To achieve this you need to store each user's time zone (probably just one of the time zone string names found in
rake time:zones:all). Then to actually use that time zone the most common pattern is to simply create a private method in your ActionController and run it as an around filter.
around_filter :user_time_zone, :if => :current_user def user_time_zone(&block) Time.use_zone(current_user.time_zone, &block) end
This will do the same thing as
config.time_zone but on a per request basis. I still recommend to change the default
config.time_zone to a time zone that is a good default for your users. (Thank you Matt Bridges for pointing out the potential problems with using a before_filter instead of an around_filter.)
All the above is something that your tests should catch for you. The problem is that you as the user and your computer as the development server happen to reside in the same time zone. This is rarely the case once you push things to production.
Highgroove just released Zonebie, a gem that helps you deal with this. I haven't had time to try it out myself yet, but it looks very promising. If you find this to be overkill, at least make sure that your tests run with
Time.zone set to another time zone than the one your development machine is in!
Bug in Time.zone.parse
Jarkko Laine (@jarkko) pointed out that there's currently a bug in Rails that can have
Time.zone.parse lose an hour when your system time is in DST (daylight saving time) and your configured time zone isn't. Jarkko has posted an issue on Rails' issue tracker and written a patch to correct the bug. Until the patch has been accepted or if you're running with older versions of Rails the only safe way to avoid this bug is to either monkey patch Rails in your app with Jarkko's fix or use:
# use ActiveSupport::TimeWithZone.new(nil, Time.zone, DateTime.parse("2012-03-25 03:29")) # => Sun, 25 Mar 2012 03:29:00 PDT -07:00 # or if possible pass the time zone in the string Time.zone.parse("2012-03-25 03:29 PDT") # => Sun, 25 Mar 2012 03:29:00 PDT -07:00 # instead of Time.zone.parse("2012-03-25 03:29") # => Sun, 25 Mar 2012 04:29:00 PDT -07:00
It should however be mentioned that it's pretty rare that this bug surfaces and when it does it can only lose you one hour. If you can live with that you probably do best by just waiting for the patch to be accepted.
2.hours.ago # => Fri, 02 Mar 2012 20:04:47 JST +09:00 1.day.from_now # => Fri, 03 Mar 2012 22:04:47 JST +09:00 Date.today.to_time_in_current_zone # => Fri, 02 Mar 2012 22:04:47 JST +09:00 Date.current # => Fri, 02 Mar Time.zone.parse("2012-03-02 16:05:37") # => Fri, 02 Mar 2012 16:05:37 JST +09:00 Time.zone.now # => Fri, 02 Mar 2012 22:04:47 JST +09:00 Time.current # Same thing but shorter. (Thank you Lukas Sarnacki pointing this out.) Time.zone.today # If you really can't have a Time or DateTime for some reason Time.zone.now.utc.iso8601 # When supliyng an API (you can actually skip .zone here, but I find it better to always use it, than miss it when it's needed) Time.strptime(time_string, '%Y-%m-%dT%H:%M:%S%z').in_time_zone(Time.zone) # If you can't use Time#parse
Time.now # => Returns system time and ignores your configured time zone. Time.parse("2012-03-02 16:05:37") # => Will assume time string given is in the system's time zone. Time.strptime(time_string, '%Y-%m-%dT%H:%M:%S%z') # Same problem as with Time#parse. Date.today # This could be yesterday or tomorrow depending on the machine's time zone. Date.today.to_time # => # Still not the configured time zone.
I hope you've learned something from this post. I sure did while writing it! If you have any feedback on how it can be improved, or if you spot any errors, please let me know by posting a comment below!