The Mysterious Time-to-DateTime Conversion: Unraveling Ruby’s Unexpected Behavior

Evgeniy Demin
Better Programming
Published in
3 min readMay 22, 2023

--

Unexpected conversion from Time to DateTimef

I recently stumbled upon a perplexing discrepancy while converting Time to DateTime using the #to_datetime method. Intrigued by this unexpected behavior, I delved into an investigation to unravel its root cause. I’ll share my findings in this blog post with you, as I believe it’s worth your attention.

Introduction

I’m currently engaged in the challenging task of upgrading a massive Ruby on Rails monolith. This unique experience has allowed me to dive into unfamiliar territory, learn new things, and engage with numerous stakeholders.

Amid fixing a failing spec on the new Rails version, I stumbled upon the following behavior:

# ruby 2.7.7p221 (2022-11-24 revision 168ec2b1e5) [arm64-darwin22]

time = Time.new(1,1,1,0)
=> 0001-01-01 00:00:00 +0100

time.to_datetime
=> #<DateTime: 0001-01-03T00:00:00+01:00 ((1721425j,82800s,0n),+3600s,2299161j)>

I couldn’t help but wonder why there was a 2-day shift when converting Time to DateTime. Although I found a quick fix for my specs, the behavior continued to intrigue me, compelling me to dig deeper.

Investigation

Initially, I wasn’t sure where to begin. I started by refreshing my knowledge of the differences between Time and DateTime in Ruby’s documentation:

Time

Time is an abstraction of dates and times. Time is stored internally as the number of seconds with fraction since the Epoch, January 1, 1970 00:00 UTC.

Since Ruby 1.9.2, Time implementation uses a signed 63 bit integer, Bignum or Rational. The integer is a number of nanoseconds since the Epoch which can represent 1823-11-12 to 2116-02-20. When Bignum or Rational is used (before 1823, after 2116, under nanosecond), Time works slower as when integer is used.

DateTime

A subclass of Date that easily handles date, hour, minute, second, and offset. DateTime does not consider any leap seconds, does not track any summer time rules.

I quickly scrolled those documentations, then googled a bit, but, unfortunately, I couldn’t find anything related to the situation.

Therefore, I decided to go the “hard way” and opened a Ruby source code.

static VALUE
time_to_datetime(VALUE self)
{
VALUE y, sf, nth, ret;
int ry, m, d, h, min, s, of;

y = f_year(self);
m = FIX2INT(f_mon(self));
d = FIX2INT(f_mday(self));

h = FIX2INT(f_hour(self));
min = FIX2INT(f_min(self));
s = FIX2INT(f_sec(self));
if (s == 60)
s = 59;

sf = sec_to_ns(f_subsec(self));
of = FIX2INT(f_utc_offset(self));

decode_year(y, -1, &nth, &ry);

ret = d_complex_new_internal(cDateTime,
nth, 0,
0, sf,
of, GREGORIAN,
ry, m, d,
h, min, s,
HAVE_CIVIL | HAVE_TIME);
{
get_d1(ret);
set_sg(dat, DEFAULT_SG);
}
return ret;
}

From top to bottom, a few things got my attention:

  • decode_year — It deals with year-related operations differently from other attributes like month and day, although our issue pertains more to the “day” aspect.
  • GREGORIAN — This keyword provides a clue for the following line of investigation.
  • DEFAULT_SG — It is defined as #define DEFAULT_SG ITALY.

Armed with these findings, I revisited the Time and DateTime documentation and finally uncovered an explanation that shed light on the reason behind the behavior.

An optional argument, the day of calendar reform (start), denotes a Julian day number, which should be 2298874 to 2426355 or negative/positive infinity. The default value is Date::ITALY (2299161=1582-10-15).

Since Ruby’s Time class implements a proleptic Gregorian calendar and has no concept of calendar reform, there’s no way to express this with Time objects.

There is also an excellent explanation of when to use what.

P.S. It’s important to say that this paragraph was just a few blocks from the beginning, meaning my eyes simply overlooked it when I was “very quickly” scrolling through the page.

Conclusion

Dealing with time, especially in the context of time zones, is always challenging and prone to bugs. This investigation has highlighted an additional consideration before claiming that our code functions as expected. We can make informed decisions and write more robust code by understanding the intricacies of Ruby’s Time and DateTime conversions.

As for the conversion, it can be done with .gregorian to have the expected value.

require 'time'

time = Time.new(1,1,1,0)
=> 0001-01-01 00:00:00 +0100

time.to_datetime
=> #<DateTime: 0001-01-03T00:00:00+01:00 ((1721425j,82800s,0n),+3600s,2299161j)>

time.to_datetime.gregorian
=> #<DateTime: 0001-01-01T00:00:00+01:00 ((1721425j,82800s,0n),+3600s,-Infj)>

Thanks for reading.

Please consider subscribing!

--

--

Ruby & Golang practitioner. Remote expert. Open-source contributor. Beginner blogger. Join my network to grow your expertise.