The Gotcha

After saving an instance of a model to the database, it's Time fields (or ActiveSupport::TimeWithZone fields) may no longer have the same values.

Example

In the following example, the instance fetched from the database does not have the same value for time that create saved to the database:

time = Time.now

original_instance = Model.create(id: 1, time: time)
saved_instance = Model.find_by(id: 1)

original_instance.time == saved_instance.time    # false?!
original_instance.time.eql? saved_instance.time  # false?!

puts original_instance.time
puts saved_instance.time

Even more confusing, the puts output for both times looks exactly the same:

2021-03-24 02:17:23 +0000
2021-03-24 02:17:23 +0000

This is especially mysterious if RSpec tests fail while comparing times that you know should be the same:

expected: 2021-03-24 02:17:23.904144615 +0000
     got: 2021-03-24 02:17:23.904144000 +0000

(compared using eql?)

Diff:
@@ -1 +1 @@
-Wed, 24 Mar 2021 02:17:23.904144615 UTC +00:00
+Wed, 24 Mar 2021 02:17:23.904144000 UTC +00:00

Why it Happens

The database doesn't store the same amont of precision as Ruby.

In the example above, the failing RSpec test reveals that the database only has microsecond precision while Ruby has nanosecond precision. Thus, when the instance is fetched from the database, those three extra digits of precision can't be retrieved because they were never saved to the database in the first place.

The Fix

Solution 1: Ignore excess precision in comparisons

If only the seconds need to match, convert both times to integers and compare those instead. If the microseconds need to match too, convert to float instead:

ruby_time.to_i == database_time.to_i  # true
ruby_time.to_f == database_time.to_f  # true

For an elegant implementation, add a custom matcher to spec_helper.rb. This assumes that the time comparisons are only needed for RSpec tests. It might look like this:

RSpec::Matchers.define :be_same_second_as do |expected|
  match do |actual|
    actual.to_i.eql? expected.to_i
  end
end

###################################################

it "confirms two times represent the same second" do
  expect(database_time).to be_same_second_as(ruby_time)
end

This solution is my favorite. It's just quick and easy.

Solution 2: Use the be_within matcher

If up to one second between times is tolerable, RSpec already has a matcher for the job:

expect(database_time).to be_within(1.second).of(ruby_time)

This solution is clean but doesn't handle windows smaller than one second. Also, it only works for time comparisons in RSpec tests.

Solution 3: Increase the database precision

Change the database time columns to have a nanosecond level of precision.

This is nice if you don't want to continuously have to remember potential issues whenever you're comparing time in Ruby code. Of course, it comes at a high cost. Updating columns requires a database migration for existing tables and will take up more disk space. Also, some databases (e.g. MySQL 5.7) don't support nanosecond levels of precision.

Unless nanosecond granularity is a requirement, this solution is overkill.

Solution 4: Truncate Ruby's precision

If fractional-second precision isn't necessary, completely remove it when a Time instance is created. Here are two ways to truncate:

time = Time.now.round
puts time.nsec  # 0
require 'active_support/time'

time = Time.now.change(nsec: 0)
puts time.nsec  # 0

This solution feels hackier than the others, but it certainly does the job.

Conclusion

I wish these fixes could solve all your time problems, but this is time we're talking about. In fact, while doing research for this article, I learned about another Ruby developer who shared his struggles with time precision differences between operating systems! Go read it. It's fantastic.

So yeah, maybe I only helped solve one time problem. But at least that's one fewer problems to debug!