Working with dates on Ruby on Rails
Read in 8 minutes
Working with dates can be hard. You need to consider time zones, understand how to store dates in your database, parse strings into dates or even format dates and display them to the user. And there’s the daylight saving time.
In this article we’ll see how to use the utilities provided by Rails, so that your system can handle dates correctly.
How Ruby works
Ruby has basically two different classes for handling dates: Date and Time1. You can generate dates by parsing strings or using individual attributes that represent the year, hour, month, and so on.
require "time"
Time.parse("Dec 8 2015 10:19")
#=> 2015-12-08 10:19:00 -0200
Date.parse("Dec 8 2015")
#=> #<Date: 2015-12-08>
Time.new(2015, 12, 8, 10, 19)
#=> 2015-12-08 10:19:00 -0200
Date.new(2015, 12, 8)
#=> #<Date: 2015-12-08>
Every date calculation must be performed manually. Let’s say you want to advance one hour; all you have to do is add 3600 seconds to the date.
time = Time.now
#=> 2015-12-08 10:26:40 -0200
time + 3600
#=> 2015-12-08 11:26:40 -0200
Most calculations are easy to implement, but things get complex when you have to consider time zones.
Working with time zones
To set the time zone in a Ruby script, you have to set the TZ environment variable. This variable is not defined in most systems, but that will depend on how your server is configured. Older POSIX systems used this variable but this done by /etc/localtime now.
This is how Ruby behaves with and without the TZ variable.
ENV["TZ"]
#=> nil
Time.now
#=> 2015-12-08 10:30:00 -0200
ENV["TZ"] = "America/Los_Angeles"
#=> "America/Los_Angeles"
Time.now
#=> 2015-12-08 04:30:14 -0800
The date command, available in *nix systems, also use the TZ variable, changing how the date is presented in a user session.
$ date
Tue Dec 8 09:37:12 BRST 2015
$ export TZ=America/Los_Angeles
$ date
Tue Dec 8 03:37:27 PST 2015
The problem is that not every software out there will use this variable automatically (or use it at all). PostgreSQL won’t use the TZ variable, preferring the timezone configuration2.
SELECT current_setting('timezone');
#=> Brazil/East
You’re better off avoiding the time zone in all pieces of your infrastructure; instead of using a specific time zone, just go with Etc/UTC. This will free you from problems related to DST and cron jobs. It will also be easier to define how different systems will work with dates and communicate. The time zone presentation should be the application’s responsibility.
How Ruby on Rails works
To define the time zone in Ruby on Rails application use the environment configuration file or create an initializer.
# config/initializers/time_zone.rb
Time.zone = "America/Sao_Paulo"
Ruby on Rails can have a time zone configuration per application, totally ignoring the TZ environment variable, but this behavior has some implications.
First, Ruby won’t consider Rails’ time zone configuration. This means that if your system is using the America/Sao_Paulo time zone and your application is using America/Los_Angeles, Ruby will still consider the former configuration.
Time.now.zone
#=> "BRST"
Time.zone = "America/Los_Angeles"
#=> "America/Los_Angeles"
Time.now.zone
#=> "BRST"
To generate dates that knows about the time zone, you should use methods defined by the ActiveSupport library. These methods will generate objects from the ActiveSupport::TimeWithZone3 class. There are a large number of methods and their usage will depend on what you want to accomplish.
Time.zone.now
#=> Tue, 08 Dec 2015 03:37:57 PST -08:00
Time.zone.today
#=> Tue, 08 Dec 2015
Time.current
#=> Tue, 08 Dec 2015 03:38:17 PST -08:00
1.hour.ago
#=> Tue, 08 Dec 2015 02:38:28 PST -08:00
1.day.from_now
#=> Wed, 09 Dec 2015 03:38:36 PST -08:00
Date.yesterday
#=> Mon, 07 Dec 2015
Date.tomorrow
#=> Wed, 09 Dec 2015
The general guidelines are:
- Use
Time.currentinstead ofTime.now. - Use
Date.currentinstead ofDate.today.
Notice that Time.current will ignore the time zone information if your application doesn’t set it; the same will happen to Date.current, as you can see in ActiveSupport’s implementation:
# activesupport-4.2.5/lib/active_support/core_ext/time/calculations.rb
# line 30
class Time
def current
::Time.zone ? ::Time.zone.now : ::Time.now
end
end
# activesupport-4.2.5/lib/active_support/core_ext/date/calculations.rb
# line 46
class Date
def current
::Time.zone ? ::Time.zone.today : ::Date.today
end
end
You can always use Time.zone.today and Time.zone.now for a deterministic result.
Dealing with Daylight Saving Time
ActiveSupport uses the TZInfo gem so it can know about time zones. This gem will load all this information from your operating system; in Ubuntu, this information comes from the tzdata package.
This means that keeping your server up-to-date will provide fresh information about DST and all time zones from all around the world.
The code below shows how the dates know about DST, considering Brazil’s DST for 2016.
Time.zone.parse('2015-10-17').dst?
#=> false
Time.zone.parse('2015-10-18').dst?
#=> true
Time.zone.parse('2016-02-20').dst?
#=> true
Time.zone.parse('2016-02-21').dst?
#=> false
Sometimes you have to parse dates and DST can bring unexpected consequences. Recently I had to integrate some user-provided dates in calendar and the biggest challenge was displaying future dates, considering time zones and DST.
Turns out the solution is simple; when parsing dates, ignore the time zone information from the string and use the Time.use_zone.
Time.current
#=> Mon, 07 Dec 2015 18:57:51 BRST -02:00
Time.use_zone("America/Sao_Paulo") do
starts_at = Time.zone.parse("2016-03-05 10:00")
#=> Sat, 05 Mar 2016 10:00:00 BRT -03:00
ends_at = Time.zone.parse("2016-03-05 17:00")
#=> Sat, 05 Mar 2016 17:00:00 BRT -03:00
end
How dates are persisted
ActiveRecord will persist dates in UTC (Coordinated Universal Time). This behavior is defined by the ActiveRecord::Base.default_timezone and ActiveRecord::Base.time_zone_aware_attributes properties.
ActiveRecord::Base.default_timezone
#=> :utc
ActiveRecord::Base.time_zone_aware_attributes
#=> true
Every time you persist an date attribute to the database, ActiveRecord will convert it to UTC. And after loading a record, ActiveRecord will do the other way around, converting the date back to the time zone defined in your application4.
Notice that dates can be off by some hours if you use Date.today or Time.now.
Time.zone = "America/Los_Angeles"
#=> "America/Los_Angeles"
Time.now.beginning_of_day.utc
#=> 2015-12-08 02:00:00 UTC
Time.current.beginning_of_day.utc
#=> 2015-12-08 08:00:00 UTC
This happens because Time.now ignores the time zone defined by the application and will use the system’s one (in this case BRST-0200). That’s why is extremely important for you to use ActiveSupport methods, like I said before.
Since ActiveRecord assumes that all dates are persisted as UTC, you can also end up with wrongs dates if you update database records from outside the application. PostgreSQL has a column type that knows about time zones and will convert dates before persisting it. Unfortunately Rails doesn’t support time with time zone, but you can easily add it to your application by creating an initializer with the following code:
# config/initializers/active_record.rb
ActiveRecord::ConnectionAdapters::PostgreSQLAdapter::
NATIVE_DATABASE_TYPES[:datetime] = {name: "timestamp with time zone"}
Querying your database
We already know that ActiveRecord assumes your dates are stored as UTC. To make your queries work as expected, all you have to do is use dates that have this time zone knowledge (like Time.current) and the library will do the right thing.
Article.where("published_at >= ?", Time.current)
Remember that dates generated by 1.day.ago and Date.current.beginning_of_month will consider the time zone, so feel free to use them.
Parsing dates sent by users
When parsing dates you must also consider time zones. Ruby has the methods Time.parse and Date.parse, but they ignore this information. In this case you should use Time.zone.parse or your dates can be off by a few hours.
Time.parse("8:47am Dec 7th, 2015").utc
#=> 2015-12-07 10:47:00 UTC
Time.zone.parse("8:47am Dec 7th, 2015").utc
#=> 2015-12-07 16:47:00 UTC
If you have numbers that represent the date, use Time.zone.local instead of Time.new.
Time.new(2015, 12, 7, 8, 47, 0).utc
#=> 2015-12-07 10:47:00 UTC
Time.zone.local(2015, 12, 7, 8, 47, 0).utc
#=> 2015-12-07 16:47:00 UTC
Finally, use Time.zone.at if you have a Unix timestamp.
Time.zone.at(1449506820)
#=> 2015-12-07 16:47:00 UTC
To set the time zone for an execution context, use the method Time.use_zone, which sets the specified time zone only while running the block.
Time.zone = "America/Sao_Paulo"
#=> "America/Sao_Paulo"
now_in_sao_paulo = Time.current
#=> Tue, 08 Dec 2015 09:40:31 BRST -02:00
now_in_los_angeles = Time.use_zone("America/Los_Angeles") do
Time.current
end
#=> Tue, 08 Dec 2015 03:40:31 PST -08:00
now_in_sao_paulo.utc
#=> 2015-12-08 11:40:31 UTC
now_in_los_angeles.utc
#=> 2015-12-08 11:40:31 UTC
now_in_sao_paulo.to_i == now_in_los_angeles.to_i
#=> true
You can use this method on your controller so you can set the user’s time zone.
class ApplicationController < ActionController::Base
around_action :set_timezone, if: :logged_in?
private
def set_timezone(&action)
Time.use_zone(current_user.time_zone, &action)
end
end
Working with dates in JavaScript
You may need to communicate with your Rails application from JavaScript. The best way of dealing with dates is by using moment.js.
Use the Time#iso8601 method to send formatted dates to JavaScript; this will generate something like 2015-12-07T19:25:45Z. You can then use moment.js to convert this string into a JavaScript Date object.
var date = moment("2015-12-07T19:25:45Z");
//=> Mon Dec 07 2015 17:25:45 GMT-0200 (BRST)
date.format("MMM DD, YYYY - h:mma");
//=> Dec 07, 2015 - 5:25pm
Notice that moment.js will use the browser’s time zone (BRST-0200 in my case); if you need to convert the date to a different time zone, use momentjs-timezone.
Wrapping up
If you application deals with dates, most notably date calculations, understand how Rails’ date handling works is essential. To make your life easier, just follow these rules:
- Use
Etc/UTCas your server’s time zone. - Always define the time zone in your Rails application, even if it’s
Etc/UTC. - Deal with time zone presentation on your application’s layer.
- Use
Time.currentandDate.currentto retrieve the current date. - Use
Time.zone.parseto convert string into dates. - Convert
DateemActiveSupport::TimeWithZonefor comparison, likedate.in_time_zone == Time.current.beginning_of_day.
Resources
- Dealing With Time Zones Using Rails and Postgres
- Dealing with timezones effectively in Rails
- Handling Dates & Timezones in Ruby & Rails
- Handling Time Zones in Rails
- How Rails and MySQL are handling time zones
- The Exhaustive Guide to Rails Time Zones
- The Worst Server Setup Mistake You Can Make
- Working with time zones in Ruby on Rails
There’s another class called
DateTime, which is just aDatesubclass that knows about the time part. TheTimeclass had limitations on older Ruby versions running in 32-bit systems; in this case you should useDateTimeinstead. Ruby 1.9.2 fixed this problem and you can just useDateandTimeclasses, which now have similar performance. ↩PostgreSQL will only use the
TZenvironment variable if thetimezoneconfiguration is not specified on yourpostgresql.conffile. Since this configuration always have a default value, is unlikely that PostgreSQL will useTZunless you really want it to. ↩Methods like
Time.zone.today,Date.yesterdayandDate.tomorrowreturn objects from theDateclass and don’t know about time zone. Convert theDateobject into aActiveSupport::TimeWithZoneinstance withdate.in_time_zone. ↩The
ActiveRecord::Base.time_zone_aware_attributesconfiguration is disabled if you’re using ActiveRecord outside Rails. ↩