← Back to blog

Why Timestamps Break Hash Equality in Ruby Tests

· Lachlan Young

You assert that two hashes are equal in a test. You read them side by side. Every key matches, every value matches, the timestamps are the same right down to the second. The test fails anyway. If you have written Rails for any length of time, a created_at or updated_at has done exactly this to you.

The culprit is almost always a Time value, and the reason is that Ruby compares time at a resolution your terminal does not bother to print.

Ruby time is more precise than it looks

a = Time.now
b = Time.now

a == b     # => false
a.to_s     # => "2026-07-10 10:24:15 +1000"
b.to_s     # => "2026-07-10 10:24:15 +1000"

Two times that print identically with to_s, yet == says they differ. Ruby’s Time carries fractional seconds all the way down to nanoseconds, and Time#== compares that full precision. to_s throws the fractional part away when it renders, so the two values look the same on screen while being different objects standing for different instants.

inspect does show the fractional part, and that matters, because inspect is what test frameworks use to print a failure:

a.inspect  # => "2026-07-10 10:24:15.488213729 +1000"
b.inspect  # => "2026-07-10 10:24:15.502841004 +1000"

Minitest and RSpec dump the failing values with inspect, so the difference is technically on your screen. It is just buried in nine digits of nanoseconds that your eye slides straight past, especially when the rest of the hash is large.

The database makes it worse

The sharpest version of this bug shows up the moment a record makes a round trip through the database. You create a record, hold the in-memory time, then compare it against the same record read back from the database:

user = User.create!(name: "Alice")

in_memory = user.created_at
from_db   = User.find(user.id).created_at

in_memory                 # => 2026-07-10 10:24:15.488213729 +1000
from_db                   # => 2026-07-10 10:24:15.488214000 +1000
in_memory == from_db      # => false

On create, Rails sets created_at from the clock in memory at full nanosecond precision and inserts the row. It does not re-read the value afterwards, so the in-memory object keeps the original. Postgres, meanwhile, stores timestamps at microsecond precision, six fractional digits, and drops everything finer. Read the row back and you get the truncated version. The two are equal to the microsecond and unequal below it, which is enough for == to return false and enough to sink an assert_equal on the whole attributes hash.

MySQL is worse still. A plain datetime column stores no fractional seconds at all unless you declare datetime(6). Reload a record there and the sub-second part is simply gone, so an in-memory time with any fractional component will never match its reloaded copy.

This is why the reflex of “freeze the clock and compare” does not fix it on its own. Freezing time stops the clock from moving, but it does not make the database store the same precision your process is holding in memory.

The other cause: it is not the same kind of time

The second common reason is a type mismatch that also survives an eyeball check. Date, Time, DateTime, and ActiveSupport::TimeWithZone are four different classes, and they do not all agree about equality:

Date.new(2026, 7, 10) == Time.new(2026, 7, 10)   # => false

They render almost the same in a hash dump, but a Date on one side and a Time on the other will never be ==. This turns up when one side of your comparison arrived from a form or a fixture as a Date and the other came out of the database as a time. Ruby’s Time#== compares instants, so a Time and a TimeWithZone for the same moment usually are equal, but drop a bare Date into the mix and the comparison quietly returns false.

How to actually fix it

Pick the fix that matches how much precision you genuinely care about, which for most assertions is “to the second, not the nanosecond”.

Normalize precision on both sides. change(usec: 0) or round strips the sub-second part so the comparison says what you actually mean:

assert_equal in_memory.change(usec: 0), from_db.change(usec: 0)

Compare within a tolerance. When you do not control both times, assert closeness instead of identity:

# Minitest
assert_in_delta in_memory.to_f, from_db.to_f, 1.0

# RSpec
expect(from_db).to be_within(1.second).of(in_memory)

Compare a serialized form. If you are asserting on a whole hash, convert the times to strings at a fixed precision first, so the assertion compares text rather than Time internals:

normalize = ->(h) { h.transform_values { |v| v.is_a?(Time) ? v.iso8601(3) : v } }

assert_equal normalize.call(expected), normalize.call(actual)

Match the class. If the failure is a Date versus Time mismatch, coerce one side with date.to_time, time.to_date, or value.in_time_zone, depending on which representation the assertion is really about.

Seeing the real difference

The maddening part of this whole category of bug is the diagnosis, not the fix. The fix is a one-liner once you know which of the two causes you are looking at. Getting there means reading two long hashes where the timestamps differ in the ninth decimal place, or differ only in their class, and both of those are close to invisible in a terminal.

This is one of the cases the RubyHash diff viewer is built for. Paste both hashes, the expected and the actual, and it lays the timestamps out side by side and highlights exactly which digits differ, so a nanosecond mismatch stops hiding at the end of a long number. When the difference is a class change rather than a value change, a Time that became a Date, or a time that got serialized to a string, it flags that explicitly instead of letting two near-identical renderings pass for equal. That is usually all you need to tell whether you are fighting precision or a type mismatch, which is the only thing you have to know to pick the right fix above.

Timestamps breaking hash equality feels like Ruby being pedantic, but it is being precise. Those times really are different, just below the resolution that anything bothered to print. Once you learn to suspect the clock, the mystery failures turn back into one-line fixes.


Staring at two hashes whose timestamps look identical but will not compare equal? Try RubyHash, paste both and see exactly which value or type changed.

Enjoyed this post?

Subscribe to get notified when we publish more Ruby and Rails content.