Be careful with time durations in Rails

Rails' date and time helpers are great. They save us from duplicating simple add-duration-to-time logic across our applications and make the code more readable. However, complex date manipulations are dangerous places full of edge-cases. This article discusses some of them. 😅

Rails has been described as a dialect of Ruby. If you’ve ever tried to program in pure Ruby after spending most of your time in Rails, you’ll understand why. Rails provides a lot of useful methods in core Ruby classes; for example, in Rails, nil allows you to call .present?, .blank?, .to_i, .to_a, and many more methods, all of which would raise an exception in a pure Ruby file.

Rails does this by "monkey patching" some of Ruby's classes, which refers to adding methods to a class that are not in the original class definition. This is a powerful feature of Ruby and is what allows Rails to create its unique 'dialect'. One of the most well known of these extensions is the date/time helpers that let you write code like 3.days.ago.

Date manipulation is a tricky part of programming, and these helpers are pretty smart, but they can get you in trouble if you're not aware of what they are doing. Take a look at these two lines of code:

Time.current + 1.month + 1.day
Time.current + 1.day + 1.month

Do these lines produce the same results? It depends when you run them. To understand why, we need to understand a bit about how these methods work so that we can use them effectively.

Unix time

In ye olden days of computers, programmers needed a way to manage Datetimes with some degree of accuracy. This lead to what became known as “Unix time”: an integer value holding the number of seconds since midnight January 1st 1970.

This is still used as the underlying way to store Datetimes in many systems.

Ruby lets us convert dates to these values:

> Time.new
=> 2022-08-20 11:36:26.785387828 +1200
> Time.new.to_i
=> 1660952189 # Unix time

If we want to add an amount of time, then we just need to work out how many seconds we need. Therefore, we could add one day’s worth of seconds to change the time to tomorrow:

> time = Time.new
=> 2022-08-20 11:40:27 +1200
> time + 86_400
=> 2022-08-21 11:40:27 +1200
> time + (24 * 60 * 60) # more verbose version
=> 2022-08-21 11:40:27 +1200

The .day and .days ActiveSupport helpers

Instead of developers across the world implementing their own days-to-seconds methods, ActiveSupport helpfully gives us one out-of-the-box in the form of .day and .days methods on Numerics:

> 1.day
=> 1 day
> 1.day.to_i
=> 86400
> (2.7).days.to_i # works with all Numeric types, not just integers
=> 233280
> (2.7 * 24 * 60 * 60).round #equivalent calculation to 2.7 days
=> 233280

ActiveSupport::Duration

Although the value returned by the .day helper does a good job of imitating an integer, it’s not an integer. When you call .day or any of the other calendar-related helpers on Numerics, what you get back is an ActiveSupport::Duration. If we look at some of the other helpers, we can see why this is the case; we’ll choose .month here.

First, unlike day, we can’t have .month just return a fixed integer; because the months have different durations, it could be anywhere from 28 to 31 days. Let’s start with January 30th and add a month to it:

> time = Time.new(2022, 1, 30)
=> 2022-01-30 00:00:00 +1300
> time + 1.month
=> 2022-02-28 00:00:00 +1300

Here we see that the value has been capped to Feb 28th. In most cases, this is probably what we want so that February isn’t skipped. However, this also creates a counter-intuitive situation that could cause problems in a codebase:

Time.new + 1.month + 1.day
Time.new + 1.day + 1.month

These lines look like they should give the same result. However, because of this “capping” behavior, they may not, depending on the time of year these commands are run. Indeed, our CI had failures due to this discrepancy that only showed up in late January.

time = Time.new(2022, 1, 30)
=> 2022-01-30 00:00:00 +1300
> time + 1.month + 1.day
=> 2022-03-01 00:00:00 +1300
> time + 1.day + 1.month
=> 2022-02-28 00:00:00 +1300
> time + (1.day + 1.month) # even adding brackets doesn't change it
=> 2022-02-28 00:00:00 +1300
> time + (1.month + 1.day)
=> 2022-03-01 00:00:00 +1300

The order of operations here will determine which result is returned, and even adding brackets doesn’t help. What’s going on inside ActiveSupport::Duration? How does it know February only has 28 days? Let’s dive in and take a look.

ActiveSupport::Duration source

Looking at the source for Duration, we’ll start with the method for addition, as it should give us some clues to what is going on:

# Adds another Duration or a Numeric to this Duration. Numeric values
# are treated as seconds.
def +(other)
  if Duration === other
    parts = @parts.merge(other._parts) do |_key, value, other_value|
      value + other_value
    end
    Duration.new(value + other.value, parts, @variable || other.variable?)
  else
    seconds = @parts.fetch(:seconds, 0) + other
    Duration.new(value + other, @parts.merge(seconds: seconds), @variable)
  end
end

What is interesting to me here is this @parts variable. It seems that a Duration stores the value in two ways: as the number of seconds and as a parts hash. While some of these are private to the class, fortunately for us, Ruby gives us some tools, such as #instance_variable_get, to see the values being stored here:

> duration = (1.year + 5.months + 1.month + 3.days)
=> 1 year, 6 months, and 3 days
> duration.instance_variable_get :@parts
=> {:years=>1, :months=>6, :days=>3}
> duration.instance_variable_get :@value
=> 47594628

Therefore, Duration has more granularity that just X-number-of-seconds. Let’s see what happens when it is added to a Time.

Time

Looking into the source of Rails’ Time calculations, we see that + is actually aliased to this method:

def plus_with_duration(other) # :nodoc:
  if ActiveSupport::Duration === other
    other.since(self)
  else
    plus_without_duration(other)
  end
end
alias_method :plus_without_duration, :+
alias_method :+, :plus_with_duration

We’re only concerned with Duration right now, so it looks like our next stop is Duration#since:

def since(time = ::Time.current)
  sum(1, time)
end

Checking sum in the same class, we find:

def sum(sign, time = ::Time.current)
  unless time.acts_like?(:time) || time.acts_like?(:date)
    raise ::ArgumentError, "expected a time or date, got #{time.inspect}"
  end

  if @parts.empty?
    time.since(sign * value)
  else
    @parts.inject(time) do |t, (type, number)|
      if type == :seconds
        t.since(sign * number)
      elsif type == :minutes
        t.since(sign * number * 60)
      elsif type == :hours
        t.since(sign * number * 3600)
      else
        t.advance(type => sign * number)
      end
    end
  end
end

Now we’re getting somewhere. It seems that for seconds, minutes, and hours, Rails just adds the raw number of seconds to the Time. This makes sense because these values will always be the same regardless of when the code is called. For month and year, though, it uses Time#advance. Looking up this method gives us the following:

# Provides precise Date calculations for years, months, and days. The +options+ parameter takes a hash with
# any of these keys: <tt>:years</tt>, <tt>:months</tt>, <tt>:weeks</tt>, <tt>:days</tt>.
def advance(options)
  d = self

  d = d >> options[:years] * 12 if options[:years]
  d = d >> options[:months] if options[:months]
  d = d + options[:weeks] * 7 if options[:weeks]
  d = d + options[:days] if options[:days]

  d
end

Here, at last, we have our answer. >> and + are Ruby’s native Date methods. >> increments the month, while + increments the day. The Ruby docs for >> state that “When the same day does not exist for the corresponding month, the last day of the month is used instead”.

Conclusion

Rails’ date and time helpers are great. They save us from duplicating simple add-duration-to-time logic across our applications and make the code more readable. However, complex date manipulations are dangerous places full of edge-cases (and I didn’t even mention time zones in this article).

So, what is a Rails’ developer to do? Well, based on what I’ve learned here, this is my personal rule of thumb: use Rails’ helpers for single values (Time.current + 3.days, etc.) or simple ranges (1.month...3.months), but for anything more complicated, particularly calculations that involve mixing units (1.month + 3.days), it is better to use the Date#advance method (time.advance(months: 1, days: 3)). This sacrifices a little on readability but ensures the result is consistent. It also helps to highlight the fact that there is more than just simple mathematical operations going on, hopefully so other developers are more mindful of the way days and months will be treated by this code.

What to do next:
  1. Try Honeybadger for FREE
    Honeybadger helps you find and fix errors before your users can even report them. Get set up in minutes and check monitoring off your to-do list.
    Start free trial
    Easy 5-minute setup — No credit card required
  2. Get the Honeybadger newsletter
    Each month we share news, best practices, and stories from the DevOps & monitoring community—exclusively for developers like you.
    author photo

    Jonathan Miles

    Jonathan began his career as a C/C++ developer but has since transitioned to web development with Ruby on Rails. 3D printing is his main hobby but lately all his spare time is taken up with being a first-time dad to a rambunctious toddler.

    More articles by Jonathan Miles
    Stop wasting time manually checking logs for errors!

    Try the only application health monitoring tool that allows you to track application errors, uptime, and cron jobs in one simple platform.

    • Know when critical errors occur, and which customers are affected.
    • Respond instantly when your systems go down.
    • Improve the health of your systems over time.
    • Fix problems before your customers can report them!

    As developers ourselves, we hated wasting time tracking down errors—so we built the system we always wanted.

    Honeybadger tracks everything you need and nothing you don't, creating one simple solution to keep your application running and error free so you can do what you do best—release new code. Try it free and see for yourself.

    Start free trial
    Simple 5-minute setup — No credit card required

    Learn more

    "We've looked at a lot of error management systems. Honeybadger is head and shoulders above the rest and somehow gets better with every new release."
    — Michael Smith, Cofounder & CTO of YvesBlue

    Honeybadger is trusted by top companies like:

    “Everyone is in love with Honeybadger ... the UI is spot on.”
    Molly Struve, Sr. Site Reliability Engineer, Netflix
    Start free trial
    Are you using Sentry, Rollbar, Bugsnag, or Airbrake for your monitoring? Honeybadger includes error tracking with a whole suite of amazing monitoring tools — all for probably less than you're paying now. Discover why so many companies are switching to Honeybadger here.
    Start free trial
    Stop digging through chat logs to find the bug-fix someone mentioned last month. Honeybadger's built-in issue tracker keeps discussion central to each error, so that if it pops up again you'll be able to pick up right where you left off.
    Start free trial