A billing job computes the nth renewal as start.AddDate(0, n, 0); for a customer who signed up on 31 January 2025, why do two charges land in March?
answer
- it never says it will clamp
- the same rule as building a date
- out-of-range components roll forward
- 31 February has to become something
- February skipped, March billed twice
basics
~20 sAddDate adds the fields and then normalises like time.Date, so 31 January plus one month becomes 31 February, which rolls forward to 3 March 2025. Plus two months is 31 March, so February is skipped and March billed twice.
solid answer
~50 s`time.Time.AddDate(years, months, days)` adds each component to the calendar fields and then normalises out-of-range values exactly as `time.Date` does — it does not clamp. Adding one month to 31 January 2025 produces 31 February, and since February has 28 days that year the normalised result is 3 March. Adding two months produces a valid 31 March. The schedule therefore reads 31 January, 3 March, 31 March: February is skipped and two charges land inside March, which is what the customer sees on the statement. The fix is to stop expecting the standard library to do business logic: compute the target month first, clamp the day of month to that month's length, then build the instant with `time.Date`. Verify it with a table-driven test over month ends, 30-day versus 31-day months and 29 February, asserting the exact next billing instant for each.
code
go · 5 linesstart := time.Date(2025, time.January, 31, 0, 0, 0, 0, time.UTC)
fmt.Println(start.AddDate(0, 1, 0)) // 2025-03-03 00:00:00 +0000 UTC
fmt.Println(start.AddDate(0, 2, 0)) // 2025-03-31 00:00:00 +0000 UTC
fmt.Println(start.AddDate(0, 3, 0)) // 2025-05-01 00:00:00 +0000 UTCgo deeper
Know that AddDate takes years, months and days as integers and that the result is normalised, so a day that does not exist in the target month rolls into the next one.
Explain normalisation by reference to how time.Date accepts out-of-range components, and show the worked steps from 31 January to 3 March rather than just asserting the outcome.
Diagnose from the symptom the customer reported, distinguish the double-charge shape from the anniversary-drift shape, implement clamping from the original anniversary, and name the table rows that prove it.
Own the decision the standard library refuses to make: write down what a month-end anniversary means for billing, make it one shared function rather than per-service arithmetic, and keep calendar steps out of duration-typed configuration.
## What AddDate actually promises The signature is: func (t Time) AddDate(years int, months int, days int) Time Its contract is short and completely honest: it adds the given number of years, months and days to the date components of `t`, and then **normalises the result in the same way `time.Date` does**. `time.Date` accepts out-of-range components and rolls them over — month 13 becomes January of the next year, day 0 becomes the last day of the previous month, and day 31 in a 28-day month becomes 3 March. The standard library's own documentation gives the same shape of example: adding one month to October 31 yields December 1, because November 31 normalises forward. Normalising is not clamping. Nothing in the API ever says "the last day of the target month", because there is no single correct business answer to what "a month after the 31st" means, and the standard library declines to pick one for you. ## Walking the reported schedule Start at 31 January 2025 and compute the nth renewal as `start.AddDate(0, n, 0)`: | n | fields after adding | normalised result | |---|---|---| | 1 | 31 February 2025 | **3 March 2025** (February has 28 days in 2025) | | 2 | 31 March 2025 | 31 March 2025 | | 3 | 31 April 2025 | 1 May 2025 | So the customer is charged on 31 January, then 3 March, then 31 March. February contains no charge and March contains two. On the statement that reads as a double charge in one calendar month, and the person on the other end of that support ticket does not care that both dates are individually "correct". The leap year moves the boundary but not the bug: in 2024, 31 January plus one month normalises to 2 March, because February had 29 days. ## The second failure mode: chaining The other common shape is to compute each renewal from the previous one: `next = last.AddDate(0, 1, 0)`. That does not double-bill inside a month, but it does something arguably worse — the anniversary **drifts permanently**. 31 January becomes 3 March, which becomes 3 April, 3 May, and the customer's billing day has silently moved by three days for the rest of the subscription. Every month-end signup slides forward, and no single step looks wrong in a log. Both failures have the same root: the day of month is being carried through arithmetic that is allowed to invent a different day. ## The fix Decide the rule explicitly, then implement it. For subscriptions the near-universal rule is *clamp to the last day of the target month*, always measured from the original anniversary rather than from the previously billed date: 1. Take the original anniversary's day of month. 2. Compute the target month by adding n months to the start month. 3. If the anniversary day exceeds that month's length, use the month's last day. 4. Build the instant with `time.Date`, carrying the time of day and the location you intend. The month-length step has a neat idiom: `time.Date(y, m+1, 0, ...)` asks for day zero of the following month, which normalises backwards to the last day of the month you care about, and `.Day()` reads off its length. With clamping, a 31 January anniversary bills on 28 February 2025 (29 February in a leap year), 31 March, 30 April, 31 May — one charge per calendar month, and the anniversary never drifts because every step is computed from the original day. ## Proving it This is a table-driven test, and it is the artefact that actually keeps the bug fixed. The rows worth writing down are: a 31st into a 28-day February; a 31st into a 29-day February; a 31st into a 30-day month; a 30th into February; a mid-month date, which must be untouched; the last day of a 30-day month into a 31-day month, checking you did not accidentally clamp to 30 everywhere; and December into January, checking the year rolls. For each row, assert the exact resulting instant, not just the day of month — a test that only checks the day will not notice a time-of-day or location mistake. Because the calculation is pure — instant in, instant out — it needs no clock injection and no waiting, so it runs in microseconds and belongs in the fast test suite. ## The wider principle for this API Go separates two kinds of arithmetic deliberately. `Add` takes a `time.Duration`, a fixed count of nanoseconds, and answers *elapsed time* questions. `AddDate` takes integers and answers *calendar* questions. Neither is a substitute for the other: adding `30 * 24 * time.Hour` for "a month" produces a date that walks backwards through the calendar all year, and adding `AddDate(0, 0, 1)` is a calendar step rather than an exact 24 hours. Choose the one that matches the promise you are making to the customer, and where the calendar has no valid answer — the 31st of a month with 30 days — write the business rule down in your own code, because the standard library will normalise rather than guess.
- Would computing each renewal from the previous one instead of from the signup date fix it?No, it trades a double charge for permanent drift. 31 January normalises to 3 March, then 3 April, 3 May, and the customer's billing day has moved for good. Every month-end signup slides forward and no individual step looks wrong in a log. Always compute from the original anniversary with an explicit clamp, so each month is derived independently.
- How do you get the number of days in a given month in Go without a lookup table?Ask for day zero of the following month and read its day number: `time.Date(y, m+1, 0, 0, 0, 0, 0, loc).Day()`. `time.Date` normalises out-of-range components, so day zero rolls back to the last day of month m, and month 13 rolls into January of the next year. Leap years fall out of it for free, which is why it beats a hand-written table.
- Which test rows convince you the clamped version is right?A 31st into a 28-day February and into a 29-day February; a 31st into a 30-day month; a 30th into February; a mid-month date that must come through untouched; the 30th of a 30-day month into a 31-day month, to prove you did not clamp everything to 30; and December into January for the year roll. Assert the whole instant, not just the day.
- Why not just add 30 * 24 * time.Hour for a monthly renewal?Because that is elapsed-time arithmetic answering a calendar question. Thirty days is not a month, so the billing date walks backwards through the calendar all year and a customer gets thirteen charges in some years. `Add` takes a Duration and is right for timeouts and measured spans; `AddDate` takes calendar integers and is right for anniversaries — with your own clamping rule on top.
saying these in an interview costs you the question
- Believes AddDate clamps to the last day of the target month
- Says the standard library has a bug rather than a documented rule
- Chains each renewal off the previous date and calls it fixed
- Uses 30 * 24 * time.Hour as one month
- Tests only a mid-month date and declares it correct
- Assumes the problem only appears in leap years