Ruby on Rails month.ago considered harmful

Thirty days hath September,
April, June and November.
All the rest have thirty-one,
Excepting February alone,
Which hath twenty-eight days clear
And twenty-nine in each leap year.

I’m back at work today, and coincidentally found an interesting bug in our Ruby on Rails application. A test that had always worked before was suddenly failing. I happened to discover it first, but it wasn’t working for my colleagues either when they tried it. To add to the puzzle, it had worked perfectly on the continuous integration machine when the last change was checked in yesterday.

The failing component generates a report aggregating data for the last three months. In order to do this, it needs to know when the last three months begin. The obvious way is to use Rails’s time helper methods:

months = [ 
  Time.now.beginning_of_month,
  1.month.ago.beginning_of_month,
  2.months.ago.beginning_of_month
]

so that’s what I had done when writing the report. And, indeed, it works—up until the 31st of the month. Like a werewolf on the full moon, though, the bug rears its ugly head on the 31st day of any month:

[Mon May 01 00:00:00 BST 2006,
Mon May 01 00:00:00 BST 2006,
Sat Apr 01 00:00:00 BST 2006]

Why? Let’s see:

>> 1.month
=> 2592000
>> 30.days
=> 2592000

When you do 1.month.ago in Rails, you don’t get one month ago. You get thirty days ago. Maybe that’s close enough for most of the time, but it handles the edge cases remarkably poorly.

The solution in this case is quite simple:

months = [Time.now.beginning_of_month]
2.times do
  months << (months.last - 15.days).beginning_of_month
end

By subtracting fifteen days from the beginning of any month, we end up in the middle of the previous month. Taking the beginning of that month then gives us a reliable answer that works 365 days a year.

It’s obvious and easy once you realise, but it’s easy to overlook. So be careful when using month.ago in Rails. It doesn’t do what you might expect.

Comments

  1. Flurin Egger

    Wrote at 2006-07-03 11:24 UTC using Mozilla 1.8.0.1 on Mac OS X:

    (Integer).month is very flawed, if you want to do something like 5.months.ago.beginning_of_month, you should use Time.now.months_ago(5).beginning_of_month. (Time).months_ago(xx) correctly uses month numbers and not the standard 30 days.
  2. Paul Battley

    Wrote at 2006-07-03 11:30 UTC using Firefox 1.5.0.4 on Mac OS X:

    Thanks for the hint, Flurin. I’d overlooked that possibility entirely, but it’s a far better solution.
  3. S. Harrison

    Wrote at 2007-02-04 22:53 UTC using Firefox 2.0.0.1 on Windows XP:

    Wouldn’t the following always work?

    Time.now.beginning_of_month
    Time.now.beginning_of_month.months_ago(1)
    Time.now.beginning_of_month.months_ago(2)
  4. Scott Johnson

    Wrote at 2009-05-01 03:27 UTC using Firefox 2.0.0.20 on Linux:

    This seems to have been fixed in Rails 2.2.2 (or before).

    >> puts Time.now.utc
    Fri May 01 03:22:17 UTC 2009
    => nil
    >> puts 60.days.ago
    2009-03-02 03:22:06 UTC
    => nil
    >> puts 2.months.ago
    2009-03-01 03:22:10 UTC
    => nil

    Interestingly enough, without the .ago, they look the same:

    >> puts 60.days
    5184000
    => nil
    >> puts 2.months
    5184000
    => nil
  5. Reinier

    Wrote at 2010-03-01 00:21 UTC using Safari 531.21.10 on Mac OS X:

    I don’t think it works any different yet:

    Loading production environment (Rails 2.3.4)
    >> Time.now
    => Mon Mar 01 00:12:04 +0100 2010
    >> Date.today
    => Mon, 01 Mar 2010
    >> 1.month.ago(Date.today)
    => Mon, 01 Feb 2010
    >> 1.month.ago(Time.now)
    => Mon Feb 01 00:12:51 +0100 2010
    >> 1.month.ago
    => Thu, 28 Jan 2010 23:12:57 UTC +00:00