Investigating a Rails Time Zone Error
Recently my coworker Henry and I ran into the old issue of tests failing because the CI server’s time zone was different from ours. But we weren’t sure why the tests were failing: we thought we had set up the tests to avoid time zone issues. Let’s walk through the process we used to find a fix–and more importantly, find out why the fix works.
The failing test was for a date formatting method on a Rails Active Record model named
Event; here’s what it looks like:
event = Event.new event.start_time = Time.parse('2000-06-01 18:00') expect(event.pretty_start_time).to eq('6:00 p.m.')
We start with a time string with
18:00—that is, 6 pm. We parse it into a time and assign it to a field on an
Event record. Then we call a method that formats the time and confirm it’s formatted as “6:00 p.m.”
This works fine for us locally, but fails on CI—the result is “2:00 p.m.” instead. So that certainly sounds like a time zone issue. But how can it be? If the CI server is in the UTC time zone, it seems like it would parse the string as 6 PM UTC, then when formatting it it’d still be 6 PM. The internal representation would have a different time zone, but the formatted output would be the same. But that doesn’t seem to be what’s happening.
We can reproduce this problem locally by changing our development machine’s time zone to GMT (aka UTC)—on macOS, open the Date & Time settings then click somewhere in the western part of Africa and that should set it for you.
When we set our development machine to UTC, we get “2 p.m.” as the formatted output. Why does this happen?
In Rails apps, there’s a difference between your system’s time and your Rails application’s time. System time respects whatever time zone the current machine is set to. Application time is aligned to a time zone that can be set at runtime or configured. In our case, in
config/application.rb, it was set to Eastern:
config.time_zone = 'Eastern Time (US & Canada)'
So this explains the difference. The system time is UTC, and the input “18:00” is parsed as 18:00 UTC. Then the output attempts to output it using the application time zone of Eastern, 18:00 UTC is 14:00 Eastern, or “2:00 p.m.”
That is a step in the right direction, but why is system time used for parsing and application time used for formatting? And what can we do to fix it in the test? Rails’
Time.zone method can help us–let’s see how and why by pulling up the Rails console.
Time.parse as we do in the test:
> Time.parse('2000-06-01 18:00') => 2000-06-01 18:00:00 +0000 > Time.parse('2000-06-01 18:00').class => Time
Time.parse returns a plain Ruby
Time instance, which of course doesn’t know about Rails’ time zone configuration. So it’s using the system time. What if we call
Time.zone.parse instead, with our system time zone set to UTC?
> Time.zone.parse('2000-06-01 18:00') => Thu, 01 Jun 2000 18:00:00 EDT -04:00 > Time.zone.parse('2000-06-01 18:00').class => ActiveSupport::TimeWithZone
Time.zone.parse returns an
ActiveSupport::TimeWithZone instance. From the return value, we can see that the instance has its time zone set to the Rails application time zone of
EDT. And it interprets the parsed “18:00” as 6 pm in that time zone.
With this knowledge, let’s look back at the code we’re currently using in the failing test. We’re assigning a plain Ruby
Time to an Active Record instance. What happens then?
> event = Event.new *snip* > event.start_time = Time.parse('2000-06-01 18:00') => 2000-06-01 18:00:00 +0000 > event.start_time => Thu, 01 Jun 2000 14:00:00 EDT -04:00 > event.start_time.class => ActiveSupport::TimeWithZone
We can tell from the outputted result values that we are assigning a plain Ruby
Time to the
start_time field, but when we retrieve it back, it’s an
ActiveSupport::TimeWithZone with the Eastern time zone. What’s more, the original 18:00 UTC has been converted to 14:00 Eastern.
So this is where the disconnect is happening. A system time is coming in and an application time is coming out. It makes sense that a Rails-managed Active Record instance would be standardized to use Rails’
ActiveSupport::TimeWithZoneclass, which is aware of the Rails application time zone configuration.
Now we know all we need to know to fix the bug. If we start with an application time in the first place by using
Time.zone, it should stay the same all the way through:
> event.start_time = Time.zone.parse('2000-06-01 18:00') => Thu, 01 Jun 2000 18:00:00 EDT -04:00 > event.start_time => Thu, 01 Jun 2000 18:00:00 EDT -04:00
And since it’s 18:00 in the Eastern time zone, the formatting method returns what we expect:
> event.pretty_start_time => "6:00 p.m."
Interestingly, we can even just assign the string to the field and let Rails automatically handle parsing it into an
> event.start_time = '2000-06-01 18:00' => "2000-06-01 18:00" > event.start_time => Thu, 01 Jun 2000 18:00:00 EDT -04:00 > event.start_time.class => ActiveSupport::TimeWithZone
This behavior makes sense because of how form submission usually works. When a user enters a time in a form we aren’t explicitly parsing that string value; Rails is handling it for us in that case as well.
When we change our test to use
Time.zone.parse, it passes even with local or CI server time set to UTC:
event = Event.new event.start_time = Time.zone.parse('2000-06-01 18:00') expect(event.pretty_start_time).to eq('6:00 p.m.')
So there’s the takeaway: because Rails operates on application time, it’s best for you to consistently use
Time.zone in the console and tests so that expectations don’t get mismatched. Or maybe even just work with strings directly!
In this post we’re not getting into whether it’s a good thing to set the application time zone to a specific time zone, or to UTC, or to let users set their time zone as a preference. Whichever approach you take, using
Time.zone consistently should prevent mismatches between times you create and times Rails creates for you.
Big thanks to Henry Harris for all his help troubleshooting this issue!