Representations of time without timezone in Ruby and Rails
It finally happened. I had to model time and date with no time zone information, yet Active Record assumes all times timestamps have it. Not long after, requirements to work with several time-of-day points of data followed, and questions quickly arose of how best to work with them.
While these two needs differ, they are bitten by the same Active Record feature. Times are shifted automatically to Time.zone
, if configured (also set via Rails.application.config.time_zone
).
-- Postgres
show timezone;
TimeZone
------------
US/Pacific
# This is 2020-04-22 21:10:13 in the database as a `timestamp without time zone`
MyModel.last.some_time_without_zone
# => ActiveSupport::TimeWithZone of Wed, 22 Apr 2020 14:10:13 PDT -07:00
# This is 08:00 in the database as a `time without time zone`
MyModel.last.some_time_only
# => ActiveSupport::TimeWithZone of Sat, 01 Jan 2000 00:00:00 PST -08:00
A common mistake in the RoR ecosystem is failing to understand how time information flows from database to rendered view. Doing so is critical to properly addressing these requirements.
Before copping out and turning to simple strings to represent this data, which sacrifices handy native time API, a solution exists that rests only on convention and stock functionality.
Despite the lack of explicit support, there's nothing wrong with adding zone-less datatypes to your Ruby codebase as long as you continuously remember to ask "is this relative to a point in time?" and "does my app need to consider client timezones?" More often than not, I find the answer is yes, this timestamp really should have both a time of day and a timezone. But there are exceptions.
Is it relative?
The stock time zone behavior seems to originate from a series of design decisions, and not because someone explicitly decided against implementing a bunch of types without them. One related discussion I discovered suggests there's a line of thinking in Rails that closely ties time zones with any kind of timestamp as often best practice.
A heated exchange in this RuboCop Github issue from around 2015 explores why Rubocop wants to bring in the current timezone into a Date-only calculation.
I think there are two questions here: first, is this a relative point in time? And, does that that matter to my application? In that issue, the author instantiates a date relative to the current exact time. Despite a timezone factoring into this, they have no need for timezone shifting and happily work with the implied timezone of the code's environment.
Rubocop's intention is good: we should always understand how a timezone shift affects time math even if the particular use case won't shift hours. When you use a method such as Date#today
, the resulting Date
object knows nothing about zone but is indeed still relative to the timezone of whatever computer runs it.
Another personal example I recently encountered was modeling a cutoff date which despite looking like a date and no time of day, it turned out the actual point of time was at the end of that day, in Pacific time. The time zone did matter.
Considering that practice, Ruby's one zone-less representation, Date, then only makes sense to use if you really are working entirely with a date. A year, month, and a day, and that's it. It seems obvious, but so often time is incorrectly modeled.
A Date, A Time, but no timezone
If you're booking a flight to Chicago, you pass a date and time of when you are ready to board at. What timezone is this in? CST, Chicago's timezone.
Our team encountered this situation when we integrated with a third party whose API took this full timestamp of when we're boarding the plane, but without a timezone specified. It was implied that it was in the origin airport's timezone.
This initially unsettled us, but because we weren't doing any math on the time, this became technically sound. If you think of the usability of this API, it would be weird to require the caller to look up the timezone of the departing location, Chicago, to avoid a conversion on the API side. The ISO8601 spec does seem to allow timestamps without a zone.
We also had to store the boarding time. We used a timestamp without a zone, and via code convention we treat it as linked to the origin. Should we ever need to do math on it, we could look up the timezone of this origin and add it to the timestamp manually.
Just a time, a time-of-day
I also ran into a need to store a time-of-day without any other information. As with zoneless timestamps, databases support this, but it butts heads with Rails.
ActiveModel implements a Time
class to reflect the database type of time
, but because there is no pure time-of-day class in Ruby or Rails, 2000-01-01 is filled in as the associated date.
This adaptation works just fine. You still format this data with ordinary Time formatting, strftime
or I18n.l
, and can manipulate it via full Time API.
Implementation
The key to reading and writing zone-less points of data with Active Record is to configure the attributes to skip any time zone handling:
class MyModel
self.skip_time_zone_conversion_for_attributes = [:some_time_without_zone, :some_time_only]
end
MyModel.last.some_time_without_zone
# => Time such as 2020-04-22 21:10:13 UTC
MyModel.last.some_time_only
# => Time such as 2000-01-01 08:00:00 UTC
This disables the automatic shift of time from the database's timezone to that of your application. If it's PST, and the database zone is UTC (a good convention), the time zone will be UTC represented by Time
Ruby objects.
That allows you to work with database types of time without time zone
nicely with vanilla Rails. The object has, of course, a full set of date and timezone information, but you can treat it as not there.
It allows you also to work with database types of timestamp without time zone
, though they would also technically have a timezone of UTC. Again, it's your convention of ignoring it.