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 Time
1. 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::TimeWithZone
3 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.current
instead ofTime.now
. - Use
Date.current
instead 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/UTC
as 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.current
andDate.current
to retrieve the current date. - Use
Time.zone.parse
to convert string into dates. - Convert
Date
emActiveSupport::TimeWithZone
for 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 aDate
subclass that knows about the time part. TheTime
class had limitations on older Ruby versions running in 32-bit systems; in this case you should useDateTime
instead. Ruby 1.9.2 fixed this problem and you can just useDate
andTime
classes, which now have similar performance. ↩PostgreSQL will only use the
TZ
environment variable if thetimezone
configuration is not specified on yourpostgresql.conf
file. Since this configuration always have a default value, is unlikely that PostgreSQL will useTZ
unless you really want it to. ↩Methods like
Time.zone.today
,Date.yesterday
andDate.tomorrow
return objects from theDate
class and don’t know about time zone. Convert theDate
object into aActiveSupport::TimeWithZone
instance withdate.in_time_zone
. ↩The
ActiveRecord::Base.time_zone_aware_attributes
configuration is disabled if you’re using ActiveRecord outside Rails. ↩