DEV Community

loading...
Nordcloud

How a calculation of previous month in golang can break production

Grzegorz Kotlarz
💻 Software Engineer☁️ Cloud Computing Enthusiast🎙️ AjTiTi [PL]
・3 min read

image
Illustration from “A Journey With Go”, created by Renee French.

In one of the applications that I'm working on, I have to calculate what is a previous month of a given date. It seems easy, isn't it? Golang provides the time package which has a method: AddDate.

I'm quite fresh in Go, previously I was working with C#, Python and (for some time) TypeScript. Every one of these languages has also some methods to calculate time. Let's see how they work.

C# .NET 5:

Let's use System.DateTime.AddMonths:

using System;

public class Program
{
    public static void Main()
    {
        var endOfMay = new DateTime(2021, 05, 31, 12, 00, 00);
        var endOfApril = endOfMay.AddMonths(-1);
        var enfOfJune = endOfMay.AddMonths(1);
        Console.WriteLine("End of May: {0}", endOfMay);
        Console.WriteLine("End of April: {0}", endOfApril);
        Console.WriteLine("End of June: {0}", enfOfJune);
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

End of May: 05/31/2021 12:00:00
End of April: 04/30/2021 12:00:00
End of June: 06/30/2021 12:00:00
Enter fullscreen mode Exit fullscreen mode

Looks good 👍

Python 3.9

Here I will use dateutil.relativedelta:

import datetime
from dateutil.relativedelta import relativedelta

end_of_may = datetime.datetime(2021, 5, 31, 12, 0, 0, 0)
end_of_april = end_of_may + relativedelta(months=-1)
end_of_june = end_of_may + relativedelta(months=1)
print(f'End Of May: {end_of_may}')
print(f'End Of April: {end_of_april}')
print(f'End of June: {end_of_june}')
Enter fullscreen mode Exit fullscreen mode

Let's see logs:

End Of May: 2021-05-31 12:00:00
End Of April: 2021-04-30 12:00:00
End of June: 2021-06-30 12:00:00
Enter fullscreen mode Exit fullscreen mode

Exactly what I was looking for 👌

Go

Now, the code which broke my service:

package main

import (
    "fmt"
    "time"
)

func main() {
    endOfMay := time.Date(2021, 5, 31, 12, 00, 00, 00, time.UTC)
    endOfApril := endOfMay.AddDate(0, -1, 0)
    endOfJune := endOfMay.AddDate(0, 1, 0)
    fmt.Printf("End of May: %s\n", endOfMay)
    fmt.Printf("End of April: %s\n", endOfApril)
    fmt.Printf("End of June: %s\n", endOfJune)
}
Enter fullscreen mode Exit fullscreen mode

And the output:

End of May: 2021-05-31 12:00:00 +0000 UTC
End of April: 2021-05-01 12:00:00 +0000 UTC
End of June: 2021-07-01 12:00:00 +0000 UTC
Enter fullscreen mode Exit fullscreen mode

🤔 🤔 🤔
Something is wrong...
... and exactly that happened on production on 31 May 2021 in my project. This piece of code broke production. Funny, because it was broken just for one day. And in the next few days, until the end of July, it will work fine again.

This behavior of the AddDate was totally unexpected for me. I wanted to blame Go for doing it, but... I can only blame myself 😅 because in documentation of AddDate method we can find:

AddDate normalizes its result in the same way that Date does, so, for example, adding one month to October 31 yields December 1, the normalized form for November 31.

Not what I was expecting from my experience with Python and C# 🤷‍♂️ But, documented. So - my fault 😕

Takeaways

In the end, 3 takeaways for you and myself from the future:

  1. Reading documentation can be useful 😄
  2. Write tests for edge cases.
  3. Handling dates in the programming world is a tough job.

Postscriptum - TypeScript

TypeScript is doing the same thing as Go.

const endOfMay: Date = new Date(2021, 4, 31, 12, 0, 0, 0); // months are starting from 0 here 🤦
console.log(`End of May: ${endOfMay}`);
const endOfApril = new Date(endOfMay);
endOfApril.setMonth(endOfMay.getMonth() - 1);
console.log(`End of April: ${endOfApril}`);
const endOfJune = new Date(endOfMay);
endOfJune.setMonth(endOfMay.getMonth() + 1);
console.log(`End of June: ${endOfJune}`);
Enter fullscreen mode Exit fullscreen mode

Output:

End of May: Mon May 31 2021 12:00:00 GMT+0200 (Central European Summer Time)
End of April: Sat May 01 2021 12:00:00 GMT+0200 (Central European Summer Time)
End of June: Thu Jul 01 2021 12:00:00 GMT+0200 (Central European Summer Time)
Enter fullscreen mode Exit fullscreen mode

but dayjs handles it perfectly.

import dayjs from "dayjs";

const endOfMayDate = new Date(2021, 4, 31, 12, 0, 0, 0);
const endOfMay = dayjs(endOfMayDate);
console.log(`End of May: ${endOfMay}`);
const endOfApril = dayjs(endOfMayDate).add(-1, "month");
console.log(`End of April: ${endOfApril}`);
const endOfJune = dayjs(endOfMayDate).add(1, "month");
console.log(`End of June: ${endOfJune}`);
Enter fullscreen mode Exit fullscreen mode

Output:

End of May: Mon, 31 May 2021 10:00:00 GMT 
End of April: Fri, 30 Apr 2021 10:00:00 GMT 
End of June: Wed, 30 Jun 2021 10:00:00 GMT 
Enter fullscreen mode Exit fullscreen mode

Discussion (8)

Collapse
glavic profile image
Glavić

Go and TypeScript produce correct date. If you subtract 1 month from 31 May, you will get 1 May and not last of previous month. 31 May - 1month is 31 April, but that is overflown date, so 31 April is converted to 1 May.
PHP also works correctly: sandbox.onlinephpfunctions.com/cod...

Collapse
pochmurnygrzech profile image
Grzegorz Kotlarz Author

Hello, thanks for your input 😊 Interesting to see how other languages work! Also, thanks for your clarification on why it happens - I forgot to write it in the post.

About the correctness - from my perspective, there is no easy way to tell which of the outputs is correct. Based on my experience from Python and C#, I've expectations that GoLang will produce the same output. I'm not blaming any of the languages, cause, for me, there is no correct answer 🤷‍♂️ There is only an assumption of language/library creator, and I wrote this post to aware other programmers.

BTW - Interesting things happen when you are subtracting from 31.05.2021 and, immediately, adding a one month to the previous calculation - neither in C# nor Go, you are not back to 31.05.2021. You will be at 30.05.2021, or at 1.06.2021.
Examples:
play.golang.org/p/rd1xjc1CbKJ
dotnetfiddle.net/fTO9IX

Collapse
glavic profile image
Glavić

1.6. is correct when calculating overflow dates into equation.
31.5. minus 1 month is 31.4. whish is overflown by 1 day, so this becomes 1.5.
And then you add 1 month on 1.5. which becomes 1.6. This is expacted bahavious in Go and PHP. Sorry I don't know why python and C# are doing that way :(

Thread Thread
glavic profile image
Glavić • Edited

On other hand, PostgreSQL and MySQL both return 30.4. on 31.5.-month.

MySQL:

select date_add("2021-05-30", interval -1 month)
Enter fullscreen mode Exit fullscreen mode

PostgreSQL:

select '2021-05-31'::timestamp - '1 month'::interval
Enter fullscreen mode Exit fullscreen mode
Thread Thread
pochmurnygrzech profile image
Grzegorz Kotlarz Author • Edited

Very thanks for your input and curiosity. I also dig deeper into other languages. Ruby and Java behave like C#.

Interesting thing happens in Rust, where you don't have a method to add or subtract months. You can however do something like this:

    let end_of_april = NaiveDate::from_ymd(end_of_may.year(), end_of_may.month() - 1, end_of_may.day());
Enter fullscreen mode Exit fullscreen mode

and it... panics because of the out-of-range date. You can check the snippet

I ran onto it here.

Thread Thread
glavic profile image
Glavić

This is really interesting, that one language decided to overflow in positive and other into negative days... So when developing we must be familiar with this and act accordingly. Tnx for the overview in other languages.

Collapse
dougaws profile image
Doug

The first step should have been to define what "previous month of a given date" means, since the previous month might not have a 1-1 corresponding date.

Once that term is defined, then you create unit tests, such as previous date for March 30.

Collapse
khola profile image
Kuba Holak

Dayjs <3