Ruby: is Time to talk about Time Zones

Any sufficiently advanced bug is indistinguishable from a feature.
― Rich Kulawiec

TL;DR

Always use the Time class and correctly stores the time zone information.

Time

  • Based on floating-point second intervals from unix epoch (1970-01-01);
  • Has date and time attributes (year, month, day, hour, min, sec, subsec);
  • Natively works in either UTC or “local” (aka support to time zones);
  • Can handle negative times before unix epoch;
  • Can handle time arithmetic in units of seconds;
  • Is faster than Date or DateTime implementation;
utc   = Time.utc(2016, 10, 15)   # => 2016-10-15 00:00:00 UTC
utc.zone                         # "UTC"
utc.utc_offset                   # 0

local = Time.local(2016, 10, 15) # => 2016-10-15 00:00:00 -0300
local.zone                       # "BRST"
local.utc_offset                 # -10800

time  = Time.new(2016,10,15,14,35,42,"-03:00") # 2016-10-15 14:35:42 -0300
time.zone                                      # nil
time.utc_offset                                # -10800

now   = Time.now             # 2016-05-16 04:10:23 -0300
unix  = now.to_i             # 146338262
time  = Time.at(unix)        # 2016-05-16 04:10:23 -0300
day   = (24 * 60 * 60)       # 86400
time  = Time.at(unix + day)  # 2016-05-17 04:10:23 -0300

Note: there’s a Time class one that is part of core Ruby and there is an additional Time class that is part of the standard library. The standard library Time class extends the core Time class by adding some methods. See the documentation of both classes for more details.

Date

  • Based on integer whole-day intervals from an arbitrary “day zero” (-4712-01-01);
  • Has date attributes only (year, month, day);
  • Can handle date arithmetic in units of whole days;
  • Can convert between dates in the ancient Julian calendar to modern Gregorian;
  • Don’t have support to time zones;
  • Is slower than Time implementation;
require "date"

today = Date.today    # #<Date: 2016-06-19 ((2457559j,0s,0n),+0s,2299161j)>
today.to_time         # 2016-06-19 00:00:00 -0300

time = "2016-06-19 23:11:07 -0300"
date = Date.parse(time) # #<Date: 2016-06-19 ((2457559j,0s,0n),+0s,2299161j)>
date.to_time            # 2016-06-19 00:00:00 -0300

DateTime

  • Subclass of Date, so whatever you can do with Date can be done with DateTime;
  • Based on fractions of whole-day intervals from an arbitrary “day zero” (-4712-01-01);
  • Has date and time attributes (year, month, day, hour, min, sec);
  • Can handle date arithmetic in units of whole days or fractions;
  • Is slower than Time and Date implementation;
require "date"

now = DateTime.now
=> #<DateTime: 2016-06-19T23:11:07-03:00 ((2457560j,7867s,653070000n),-10800s,2299161j)>
now.to_time        # 2016-06-19 23:11:07 -0300

time = "2016-06-19 23:11:07 -0300"
date = DateTime.parse(time)
=> #<DateTime: 2016-06-19T23:11:07-03:00 ((2457560j,7867s,0n),-10800s,2299161j)>
date.to_time       # 2016-06-19 23:11:07 -0300

Further, it has a meaningless “zone” attribute and a hidden usec after turning it into a Time instance:

dt = DateTime.new(2012, 12, 6, 1, 0, 0, "-07:00", 123456)
dt.zone       # => "-07:00"
dt.utc?       # => NoMethodError: undefined method `utc?'
dt.utc_offset # => NoMethodError: undefined method `utc_offset'

dt = DateTime.parse("2016-06-19 23:11:07.456789 -0300")
dt.usec         # => NoMethodError: undefined method `usec'
dt.to_time.usec # => 456789

In short, unless you’re dealing with astronomical events in the ancient past and need to convert the Julian date (with time of day) to a modern calendar, you don’t need to use DateTime.

Benchmark

require 'benchmark'
require 'date'

Benchmark.bm(10) do |x|
  x.report('date')     { 100000.times { Date.today   } }
  x.report('datetime') { 100000.times { DateTime.now } }
  x.report('time')     { 100000.times { Time.now     } }
end

Result:

                user     system      total        real
date        1.250000   0.270000   1.520000 (  1.799531)
datetime    6.660000   0.360000   7.020000 (  7.690016)
time        0.140000   0.030000   0.170000 (  0.200738)

As shown before, the Time class are faster and should fits the most usage scenarios.

Time Zone

Knowing that Time has total support to time zones, you just remember this mantra: “Aways handle time with the correct time zone”.

Why time zone is important?

  • If you application are deployed in the cloud (which probably was), you can’t guarantee the time zone of the server running you application;
  • Your application database probably will be located on different server and again, you can’t guarantee the server time zone configuration;
  • Since you application is on Internet, your users came from all corners of world, that have 24 different time zones;

Getting current time with Time.now actually gets the current process time zone, which is defined by the TZ variable:

$ date
Sun Jul  3 18:42:33 BRT 2016

$ ruby -e "puts Time.now"
2016-07-03 18:42:33 -0300

$ TZ="America/Vancouver" ruby -e "puts Time.now"
2016-07-03 14:42:33 -0700

Getting the current time

Your application need to know the “base time zone”, which means that all different time zones should be converted to this base time zone to be handled correctly.

Consider that you application time zone is -03:00 (BRT). In order to ensure that you always are getting the correct time, you could do this:

TZ = "-03:00"

server_time = Time.now                   # 2016-06-18 05:30:22 +0200
server_time.utc_offset                   # 7200

current_time = server_time.getlocal(TZ)  # 2016-06-18 00:30:22 -0300
current_time.utc_offset                  # -10800

That’s it. Don’t trust on the time zone of Time.now and always convert to your local time zone.

You can ease this repetitive task extending the Time class this way:

TZ = "-03:00"

module TimeExtensions
  def current
    now.getlocal(TZ)
  end
end
Time.extend(TimeExtensions)

Time.now        # 2016-06-18 05:30:22 +0200
Time.current    # 2016-06-18 00:30:22 -0300

Also, is a good idea to always parse time with the correct time zone:

TZ = "-03:00"

require "time"

Time.parse("2016-06-18 15:33:44")        # 2016-06-18 15:33:44 +0200
Time.parse("2016-06-18 15:33:44 #{TZ}")  # 2016-06-18 15:33:44 -0300

This way, you guarantee that the input time, read from a form or a file, always will be handled with the correct time zone (if it wasn’t supplied).

Serialization

In order to ensure that the time zone information will not be lost on time serialization, is a good practice to use the ISO 8601 standard:

require "time"

now = Time.current  # 2016-06-18 20:30:22 -0300
now.iso8601         # "2016-06-18T20:30:22-03:00"

For parsing, the iso8601 also handle the time zone and is a very fast alternative to Time.parse method:

require "time"

data = "2016-06-18T20:30:22-03:00"

Time.iso8601(data) # 2016-06-18 20:30:22 -0300
Time.parse(data)   # 2016-06-18 20:30:22 -0300

On APIs, you should use the ISO 8601 standard to exchange date and time values. Period.

Using TZInfo

TZInfo is a gem that allow to use named time zones in order to obtain and convert time instances, also providing daylight saving information.

In the context of getting the right time zone, you can use TZInfo to get the utc_offset, also considering the dst, of a time zone without need to hard coded it.

For example, instead of hard code the -03:00, you can obtain this offset this way:

require "tzinfo"

tzinfo = TZInfo::Timezone.get("America/Sao_Paulo")
period = info.current_period

offset = period.offset.utc_total_offset
=> -10800   # 3 hours

And if you are in daylight saving time, this will be considered:

require "tzinfo"

tzinfo = TZInfo::Timezone.get("America/Sao_Paulo")
period = info.current_period

period.dst?
=> true

offset = period.offset.utc_total_offset
=> -7200    # 2 hours

This way, just need to encapsulate this on a TimeExtensions class to ease its usage:

module TimeExtensions
  require "tzinfo"

  def tzinfo
    TZInfo::Timezone.get(TZ)
  end

  def utc_offset
    period = tzinfo.current_period
    period.offset.utc_total_offset
  end

  def current
    now.getlocal(utc_offset)
  end
end
Time.extend(TimeExtensions)

Time.tzinfo     # #<TZInfo::DataTimezone: America/Sao_Paulo>
Time.utc_offset # -10800
Time.current    # 2016-06-18 00:30:22 -0300

Storing the time zone on database

Many guides and best practices articles say to “always store the time in UTC time zone into database”. I think that this is partially true. And I say this: “always store the time on the same time zone with a data type that supports time zone”.

With time zone support

If you are using PostgreSQL, for example, just use the timestamptz or timetz data types to store time values and you are good to go.

Your database adapter should be able to handle this data type and made the conversions to the correct time zone.

For example, consider the following posts table with the modified_at column as timestamp and the published_at column as timestamptz:

    Column    |            Type             | Modifiers
--------------+-----------------------------+-----------
 title        | text                        |
 modified_at  | timestamp without time zone |
 published_at | timestamp with time zone    |

And the Sequel model Post, with the databased configured to UTC as default time zone:

Sequel.database_timezone    = :utc
Sequel.application_timezone = :local

class Post < Sequel::Model
end

When a model was created, Sequel convert the time zone to match the time zone defined on table schema:

post_time = Time.parse("2016-07-03 20:33:44 -03:00")

Post.create(
  title:        "testing tz is cool",
  modified_at:  post_time,
  published_at: post_time
)

=> #<Post:0x007fb8d0808f78> {
         :title => "testing tz is cool",
   :modified_at => 2016-07-03 20:33:44 -0300,
  :published_at => 2016-07-03 20:33:44 -0300
}

Note that both time values have the right time zone offset, but on the database, they was stored this way:

    title           |     modified_at     |      published_at
--------------------+---------------------+------------------------
 testing tz is cool | 2016-07-03 23:33:44 | 2016-07-03 20:33:44-03

On the column modified_at, which type is timestamp, the value was converted to UTC and stored without time zone information. But the column published_at, which type is timestamptz, the original time zone was stored without conversion.

Only the application know that that the modified_at column should be converted to the local time and the published_at column have the right time zone information.

If other services need to access this database, like a data analytic tool (BI), this information should be shared between consumers. I consider this a data loss since the time zone was lost on the storage. Using data type that supports time zone ensure the consistency of the data.

Without time zone support

If your database don’t have a data type with time zone support storage, you have two alternatives: store time values in UTC or store the time zone offset on a different column.

MySQL, for example, don’t have a specific type to store time zone information. The TIMESTAMP data type can converts the time to UTC for storage and back from UTC to current time zone for retrieval using the time zone setted in the connection, but don’t writes the time zone information on column.

The Rails way

If you are using Ruby on Rails, you don’t need to concern with all this time and time zone related issues. You just need to remember this:

  • Always configure your application time zone on config/application.rb:
config.time_zone = 'Brasilia'
  • Always get the current time via current method:
Time.current
=> Sun, 03 Jul 2016 22:46:35 BRT -03:00
  • Always parse time using the configured time zone:
Time.zone.parse("2016-07-03 22:33:45")
=> Sun, 03 Jul 2016 22:33:45 BRT -03:00
  • And if you are using a database with time zone supports, instead of the classic t.timestamps, do this on database migrations:
create_table :posts do |t|
  # columns definition
end
add_column :posts, :created_at, :timestamptz
add_column :posts, :updated_at, :timestamptz

The non-Rails way

If you are not using Rails, eventually you will need to handle time parsing and/or time zones conversions. You could do this by yourself, implementing these operations on a helper class or extending the Time behavior. You will learn a lot in the process.

But if you are looking for a ready-to-use solution, you could use the CoreExt gem and pick only the Time extension:

require "core_ext/time"

Time.zone = "America/Sao_Paulo"
Time.zone.parse("16:35:42")   # Sun, 03 Jul 2016 16:35:42 BRT -03:00

now = Time.current            # Sun, 03 Jul 2016 20:31:10 BRT -03:00
now + 2.days                  # Tue, 05 Jul 2016 20:31:10 BRT -03:00
now.iso8601                   # "2016-07-03T20:31:10-03:00"

10.days.ago                   # Fri, 23 Jun 2016 20:31:10 BRT -03:00

This gem is a fork of ActiveSupport with many changes to make it more modular and focused only on the extensions of Ruby core classes. You could read more about the motivation and usage examples on my previous post about Hanami migration: From Rails to Hanami (Lotus) Part 3: Sidekiq Workers, Sequel Plugins, I18n, Timezone issues and Core Extensions.

Ruby 2.4 to_time bugfix

Until Ruby 2.3, the methods to_time of Date and DateTime objects did not preserve the time zone:

require 'date'

time = DateTime.strptime('2016-06-21 PST', '%Y-%m-%d %Z')
=> #<DateTime: 2016-06-21T00:00:00-08:00 ((2457561j,28800s,0n),-28800s,2299161j)>

time.zone    # "-08:00"
time.to_time # 2016-06-21 05:00:00 -0300

On Ruby 2.4 the time zone will be preserved:

require 'date'

time = DateTime.strptime('2016-06-21 PST', '%Y-%m-%d %Z')
=> #<DateTime: 2016-06-21T00:00:00-08:00 ((2457561j,28800s,0n),-28800s,2299161j)>

time.zone    # "-08:00"
time.to_time # => 2016-06-21 00:00:00 -0800

Conclusion

Time zones exists in our real world and should be respected on software engineering. Ruby provide a very useful API to handle Time but a little deficient one to handle time zones.

Know the Ruby Time capabilities and always remember to use and store the correct time zone on database.

References

Learn something new about Ruby time or time zones? Consider to share this post with your friends or co-workers. For questions, comments or suggestions, use the comments below. Code hard and success!