15

Can anyone explain the mathematical or simply the reasoning behind the leap year calculations in .NET when using AddYears method on DateTime?

  • If you take the 29th Feb 2012 and add a year, you get the 28th Feb 2013, not the 1st Mar 2013 (day before one year later).
  • If you add one year to 31st Jan 2012, you get 31st Jan 2013 (same date one year later).

I think most people would assume that "one year from 29.02.leapX is 01.03.leapX+1".

Example:

// Testing with 29th Feb
var now1 = DateTime.Parse("2012-02-29 15:00:00");

var results1 = new DateTime[]
{
    now1.AddYears(1),
    now1.AddYears(2),
    now1.AddYears(3),
    now1.AddYears(4)
};

foreach(var dt in results1)
{
    Console.WriteLine(dt.ToString("s"));
}

// Output:
// 2013-02-28T15:00:00
// 2014-02-28T15:00:00
// 2015-02-28T15:00:00
// 2016-02-29T15:00:00


// Testing with 31st Jan
var now2 = DateTime.Parse("2012-01-31 13:00:00");

var results2 = new DateTime[]
{
    now2.AddYears(1),
    now2.AddYears(2),
    now2.AddYears(3),
    now2.AddYears(4)
};

foreach(var dt in results2)
{
    Console.WriteLine(dt.ToString("s"));
}

// Output:
// 2013-01-31T13:00:00
// 2014-01-31T13:00:00
// 2015-01-31T13:00:00
// 2016-01-31T13:00:00
krembanan
  • 1,408
  • 12
  • 28
  • 3
    [MSDN](http://msdn.microsoft.com/en-us/library/system.datetime.addyears.aspx) is pretty clear: "The AddYears method calculates the resulting year taking into account leap years. The month and time-of-day part of the resulting DateTime object remains the same as this instance." – Tim Schmelter Feb 29 '12 at 11:31

3 Answers3

19

I think most people would assume that "one year from 29.02.leapX is 01.03.leapX+1".

I wouldn't. I would normally expect truncation. It's fundamentally similar to adding one month to January 30th - I'd expect to get the last day in February. In both cases, you're adding a "larger unit" (month or year) and a "smaller unit" (day) is being truncated to fit in with the year/month combination.

(This is how Joda Time and Noda Time behave too, btw.)

As Tim mentioned in comments, it's documented that way too:

The AddYears method calculates the resulting year taking into account leap years. The month and time-of-day part of the resulting DateTime object remains the same as this instance.

So the month has to stay as February; the year will change based on how many years are being added, obviously - so the day has to adjust to stay valid.

Jon Skeet
  • 1,421,763
  • 867
  • 9,128
  • 9,194
  • 1
    Thanks. This makes sense to me, however I realize I have been thinking of dates and calendars as overlapping arrays where the "index" of 29.02 would be the same as the index of 01.03 the following non-leap year. A very simplified (and wrong) thinking. – krembanan Feb 29 '12 at 14:35
  • A year is always 12 months, but 12 months can be either 365 or 366 days, and each individual month can be a variable number of days ranging from 28 to 31. A day is always 24 hours, so Add methods for Days, Hours, etc. will be precise with no variability because the length of a day does not vary across days per custom. As expected, if you add 365 days to 1/1/2000, you get 12/31/2000, because there are 366 days that year. AddYears and AddMonths are different, because the meaning or length of a year varies across years, just as the length of a month varies across months. – Triynko Dec 03 '13 at 00:58
  • 1
    This means that given a date like March 15, adding "a month" to that value is technically ambiguous, since it involves the # of days in both March and April. Since it is ambiguous, to avoid "drift" one must hold the day constant and increase the month, increasing the year every 12 months. Doing so can result in "out of bounds" numbers, and so the day must be altered. Altering the day should only occur in the final step. Interestingly, calling AddMonths(2) on "Jan 31" gives a different value "Mar 31" than calling AddMonth(1) twice in succession on intermediate value "Feb 28" -> "Mar 28" ;) – Triynko Dec 03 '13 at 01:06
  • In other words, for days between 29 and 31, the AddMonths method (or operator if you will) lacks the associative property of addition, because you can get different dates depending on how you group the additions. i.e. DateTime("Jan 31").AddMonth(1).AddMonths(1) gives a different result than DateTime("Jan 31").AddMonths(2), and a different result on a leap year. So it doesn't make complete sense, but it effectively minimizes the date drift per call, and the other possible ways of doing it make even less sense (e.g. using the average number of days across all months involved in the calculation). – Triynko Dec 03 '13 at 01:14
  • @Triynko: I wouldn't even say that it "doesn't make complete sense" - it's just that calendars don't provide the same sort of basis for calculation as (say) the natural numbers. – Jon Skeet Dec 03 '13 at 06:42
3

With your rationale then 1-Mar-2012 would become 2-Mar-2012 when you added a year. If you add this shift for all prior leap years then you are going to find your calculation massively adrift. The only sensible response is to return 28-Feb for non-leap years.

Lazarus
  • 41,906
  • 4
  • 43
  • 54
0

It is interesting, nether-the-less ..

e.g. this function is sometimes used:

private static int Age(DateTime birthDate, DateTime asAtDate)
{
    // Calculate age in years
    int age = asAtDate.Year - birthDate.Year;
    if (asAtDate < birthDate.AddYears(age)) age--;
    if (age < 0) age = 0;
    return age;
}

If a person was born on 29Feb2016, this function is going to conclude they have reached age 1 on 28Feb2017.

I noted Excel Function examples as per:

=DATEDIF(DATE(2016,2,28),DATE(2017,2,28),"Y")     
   gives result of 1
=DATEDIF(DATE(2016,2,29),DATE(2017,2,28),"Y")
   gives result of 0
=DATEDIF(DATE(2016,2,29),DATE(2017,3,1),"Y")
   gives result of 1
=DATEDIF(DATE(2016,3,1),DATE(2017,3,1),"Y")
   gives result of 1
Allan F
  • 2,110
  • 1
  • 24
  • 29