DEV Community

Jules Robineau
Jules Robineau

Posted on Originally published at jrobineau.com

Go: January 31 plus one month is March 3

Quick question. You take 31 January 2026 and add one month in Go. Which date do you get?

d := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(d.AddDate(0, 1, 0).Format("2006-01-02"))
// 2026-03-03
Enter fullscreen mode Exit fullscreen mode

March 3. Not February 28.

TL;DR: in Go, AddDate does not stop at the end of the month. January 31 plus one month gives March 3. If you compute each due date from the previous one, your date drifts forever. Compute each due date from the start date, with a counter. Stop at the last day when the month is too short.

This article is for Go developers who build subscriptions, reminders or any monthly job.

What AddDate really does

AddDate(0, 1, 0) adds 1 to the month number and keeps the day. So January 31 becomes "February 31". That date does not exist.

Go does not return an error. It normalizes. February 2026 has 28 days, so February 31 overflows by 3 days and becomes March 3.

This is not a bug. The documentation says so, with another example: October 31 plus one month gives December 1. Several issues were opened on the Go repository to report it. The behaviour is intended.

The real topic is elsewhere. Adding a month to a date is not a calculation. It is a decision, and it is yours to make.

The context: recurrences chosen by the user

I ran into it again this month while designing a recurrence system. A user schedules an operation that repeats: every week, every month or every year.

The business rule is easy to state. The recurrence is anchored on its creation day. Created on August 28, it comes back on September 28, then October 28.

And created on the 31st? It must land on the last day of short months, then return to the 31st.

I built a prototype in Go, with Postgres and Temporal, to validate the design. Here are the two traps I ruled out, then the rule I kept.

Trap 1: adding a month to the previous due date

It is the most natural code. You store the next date. When it comes, you do next = next.AddDate(0, 1, 0).

I ran that loop from 31 January 2026:

2026-03-03
2026-04-03
2026-05-03
2026-06-03
Enter fullscreen mode Exit fullscreen mode

February gets no due date. And the recurrence moved from the 31st to the 3rd, forever. Nobody asked for that.

Trap 2: fixing the date, then writing the fixed date back

Second try. You keep the same loop, but you pull the date back to the last day when the month is too short. February does give the 28th.

The problem comes on the next round. You start again from February 28:

2026-02-28
2026-03-28
2026-04-28
2026-05-28
Enter fullscreen mode Exit fullscreen mode

The recurrence forgot it was born on a 31st. One February was enough.

Both traps share one cause. The previous date no longer holds the starting information.

The rule: every due date is computed from the anchor

The anchor is the start date of the recurrence. It never changes. Due date number k is the anchor plus k periods.

Here is the function from the prototype:

// Occurrence returns the k-th date, always computed from the anchor.
func Occurrence(anchor time.Time, period string, k int) time.Time {
    if period == "weekly" {
        return anchor.AddDate(0, 0, 7*k)
    }
    months := 1
    if period == "yearly" {
        months = 12
    }
    y, m, d := anchor.Date()
    first := time.Date(y, m+time.Month(months*k), 1,
        anchor.Hour(), anchor.Minute(), anchor.Second(), 0, anchor.Location())
    lastDay := first.AddDate(0, 1, -1).Day()
    return first.AddDate(0, 0, min(d, lastDay)-1)
}
Enter fullscreen mode Exit fullscreen mode

It does three things for a month or a year.

  • It aims at the 1st of the target month. The 1st always exists, so nothing overflows.
  • It finds the last day of that month: the 1st of the next month, minus one day.
  • It takes the smaller of the two: the anchor day or that last day.

For a week, AddDate advances by seven calendar days. You still need a policy for clock changes.

The prototype tests give what the user expects:

Anchor Period k Result
31 January 2026 month 1 28 February 2026
31 January 2026 month 2 31 March 2026
31 January 2026 month 3 30 April 2026
29 February 2028 year 1 28 February 2029
29 February 2028 year 4 29 February 2032

Look at the second row. After February 28, the recurrence returns to the 31st. It forgot nothing.

In the database: the anchor, a counter, and a derived date

This rule changes what you store. Here is the table, simplified:

CREATE TABLE recurrences (
    id          BIGSERIAL PRIMARY KEY,
    period      TEXT        NOT NULL CHECK (period IN ('weekly', 'monthly', 'yearly')),
    anchor_at   TIMESTAMPTZ NOT NULL,
    timezone    TEXT        NOT NULL DEFAULT 'UTC',
    next_index  INT         NOT NULL DEFAULT 0,
    next_run_at TIMESTAMPTZ NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

The calculation uses anchor_at, period, timezone and next_index. The anchor does not move. The counter goes up by 1 for each processed due date.

next_run_at is a derived date. It has one purpose: finding the rows to process quickly, with an index. It is recomputed from the anchor every time. Nobody ever adds a month to it.

Catching up becomes a plain loop

A service sometimes stops for several days. On restart, it must catch up on missed due dates without creating duplicates.

With a counter, it is a loop. While due date k is in the past, I process it and move on. I tested a delay of several months on an anchor set to January 31. The 9 missed due dates came out at once, each on the right day.

The counter also protects against duplicates. Each write carries the recurrence identifier and the due date number. A unique index on those two columns refuses the second write.

CREATE UNIQUE INDEX entries_once
    ON entries (recurrence_id, occurrence_index);
Enter fullscreen mode Exit fullscreen mode

The index alone rejects a duplicate with an error. The prototype also uses ON CONFLICT DO NOTHING when inserting. Here is the simplified write:

INSERT INTO entries (recurrence_id, occurrence_index)
VALUES ($1, $2)
ON CONFLICT (recurrence_id, occurrence_index) DO NOTHING;
Enter fullscreen mode Exit fullscreen mode

The prototype locks the recurrence during processing. The insert and counter update happen in the same transaction. If that transaction fails, both are rolled back.

This guarantee covers database writes. An external call, such as sending an email, needs its own protection against duplicates.

Do not forget the time zone

"The 31st" depends on where the user lives. An anchor at 00:30 in Paris is still the previous day in UTC.

So I store the time zone of the recurrence. Before computing, I convert the anchor into that zone with anchor.In(loc). The function then works on the date the user saw.

Same for the hour. A test checks that a weekly recurrence at 9 am in Paris stays at 9 am after the October clock change. AddDate keeps the local hour, not a fixed gap of 168 hours.

Clock changes can create missing or repeated local times. The 9 am test does not cover those cases. Decide whether to shift or skip a missing time, and which occurrence to choose when a time happens twice.

The checklist

A due date is computed from the anchor, never from the previous due date.

  • [ ] Search your code for AddDate(0, 1, 0) and AddDate(1, 0, 0)
  • [ ] Test every calculation with an anchor on January 31 and one on February 29
  • [ ] Decide in writing what "one month later" means for your business
  • [ ] Store the anchor and a due date counter
  • [ ] Recompute the next date from the anchor, every time
  • [ ] Pair the unique index with ON CONFLICT DO NOTHING and advance the counter in the same transaction
  • [ ] Store the time zone and convert the anchor before computing
  • [ ] Test clock changes, including missing and repeated local times

What to remember

Go is not wrong about January 31. It answers a question you did not ask. The right question is: what is due date number k of this recurrence?

Once the question is asked that way, the rest follows. Short months no longer shift later due dates. Catch-up keeps stable identifiers for each write.

Do you have dates slipping in a billing system or a scheduler in Go? Let's talk.


Sources: Go documentation, Time.AddDate · golang/go, issue 57139 · Temporal, Schedules in Go · PostgreSQL, INSERT and ON CONFLICT

Top comments (0)