Skip to main content

Calendar maths

· 14 min read
Filip Tammergård
Software Engineer at Frilans Finans

You might think week numbers are dead simple. Surely you just start at week 1 and go up to week 52, then start over? Unfortunately it's not that easy. It turns out to be surprisingly complicated!

note

This article describes week numbering according to ISO 8601, which Sweden and most European countries follow. Other conventions exist — the United States, for example, uses Sunday as the first day of the week and treats week 1 as the week containing January 1 — so the model looks different there. The "53-week year" phenomenon explored below is specific to ISO 8601: ISO requires full seven-day weeks, while the US convention simply truncates the first and last weeks of the year to fit.

TL;DR

  • Most years have 52 weeks, but some have 53.
  • Whether a year has 53 weeks depends on what day of the week January 1 of the following year falls on, and whether the current year is a leap year.
  • Leap years occur slightly less than every four years on average, which makes the model for when 53 weeks appear tricky.

Try the finished week-numbering and leap-year model here:

Year 2026 has 53 weeks and is not a leap year.

So how do week numbers actually work?

Most years have 52 weeks. But if you look at a calendar, roughly every fifth or sixth year seems to have 53. What determines whether a year has 53 weeks isn't simple — it's something as odd as which day of the week the new year starts on. If January 1 is a Monday, Tuesday, Wednesday or Thursday, the week containing January 1 is week 1. If January 1 is a Friday, that week is week 53 (even though it's January). If January 1 is a Saturday, the week is 53 if the previous year was a leap year and 52 if it wasn't. If January 1 is a Sunday, the week is 52 regardless of whether the previous year was a leap year.

Week-numbering model

The model can be clarified and summarized in a list:

  • If January 1 is a Monday, Tuesday, Wednesday or Thursday, the day belongs to week 1.
  • If January 1 is a Friday, the day belongs to week 53.
  • If January 1 is a Saturday, the day belongs to week 53 if the previous year was a leap year, otherwise week 52.
  • If January 1 is a Sunday, the day belongs to week 52.

Weekday shift without leap years

A normal year has 365 days, which means that if January 1 in one normal year is a Monday, January 1 in the next normal year is a Tuesday. The math is that 365 isn't evenly divisible by 7 (the number of days in a week). If a year had 364 days instead, there would be no weekday shift between two normal years since 364 is divisible by 7 (364/7 = 52).

If leap years didn't exist, reasonably every seventh year would have 53 weeks, since January 1 would land on a Friday every seventh year (according to the shift), and only those years would have 53 weeks per the week-numbering model.

Weekday shift with leap years

The presence of leap years really messes up the weekday shift. In a leap year, the shift becomes two days instead of one. If January 1 in a leap year is a Monday, January 1 the next year is a Wednesday.

Let's say year 1 is the first normal year after a leap year, and January 1 that year is a Monday. Then the weekday on January 1 in the following years is:

YearWeekdayType
1MondayNormal
2TuesdayNormal
3WednesdayNormal
4ThursdayLeap
5SaturdayNormal
6SundayNormal
7MondayNormal
8TuesdayLeap
9ThursdayNormal
10FridayNormal
11SaturdayNormal
12SundayLeap
13TuesdayNormal
14WednesdayNormal
15ThursdayNormal
16FridayLeap
17SundayNormal
18MondayNormal
19TuesdayNormal
20WednesdayLeap
21FridayNormal
22SaturdayNormal
23SundayNormal
24MondayLeap
25WednesdayNormal
26ThursdayNormal
27FridayNormal
28SaturdayLeap
29MondayNormal

So year 29 is again January 1 on a Monday in a year that's the first normal year after a leap year. After that the same sequence repeats. Years 1–28 form a sequence whose weekdays look the same for the next 28 years (years 29–56). It's not that strange that the sequence is exactly 28 years. 28 is the lowest common multiple of 4 (leap year frequency) and 7 (days per week).

Multiples of 4: 4, 8, 12, 16, 20, 24, 28

Multiples of 7: 7, 14, 21, 28

The multiples can be visualized in the list because January 1 in leap years (which happen every fourth year) has to take all seven weekdays before the same weekday comes back.

Here's the same list with only the leap years, plus year 32:

YearWeekday
4Thursday
8Tuesday
12Sunday
16Friday
20Wednesday
24Monday
28Saturday
32Thursday

You can see that all seven weekdays appear before the same weekday repeats.

Remind yourself of the week-numbering model:

January 1 belongs to week 53 if it's a Friday, or if it's a Saturday and the previous year was a leap year.

By looking at the weekday-shift list, you can count how many years in a 28-year sequence have 53 weeks. These years have 53 weeks:

YearTypeJanuary 1 of next year
4LeapSaturday
9NormalFriday
15NormalFriday
20LeapFriday
26NormalFriday

So during years 1–28 (one sequence), there are 5 years with 53 weeks, the rest have 52. Four of these years get 53 weeks because the next year's January 1 is a Friday, and one of the years gets 53 weeks because it is a leap year and the next year's January 1 is a Saturday. If leap years didn't exist, 4 years per sequence would have 53 weeks. This matches my earlier statement that every seventh year should have 53 weeks without leap years, since 28/4 = 7.

With leap years counted, 5 years with 53 weeks happen per sequence. 28/5 = 5.6, which means 53-week years occur every 5.6 years on average. So sometimes there are 5 years between 53-week years, and sometimes 6.

How do leap years actually work?

That 53-week years appear every 5.6 years was a bit trickier than you might have thought. But it's unfortunately even more complex than that. I've ignored the real frequency of leap years.

The reason leap years exist at all is to prevent seasons from drifting forward or backward in the calendar over time. If every year had 365 days, seasons, equinoxes and solstices would shift by several weeks over a 100-year period. A real tropical year is actually 365 days, 5 hours, 48 minutes and 45 seconds, which equals 365.2421875 days. If a tropical year had been 365 + 1/4 = 365.25 days = 365 days and 6 hours, a leap year every four years would have fully compensated for the drift. But in reality a tropical year is 11 minutes and 15 seconds shorter than that, which means a leap-year frequency of every four years is slightly too high.

The slightly-too-high leap-year frequency is compensated for by century years not being leap years. Normally all years divisible by 4 are leap years (for example years 4, 16 or 2020), but century years not being leap years means years 100, 1900 or 2100 aren't leap years, even though they are divisible by 4 (which they always are mathematically).

The century-year rule would be perfect if a tropical year had been 365 + 1/4 − 1/100 = 365.24 days = 365 days, 5 hours, 45 minutes and 36 seconds. But a tropical year is 3 minutes and 9 seconds longer than that, which means this leap-year frequency would be slightly too low.

The slightly-too-low leap-year frequency in this case is compensated for by century years divisible by 400 being an exception to the rule about century years, so they are leap years anyway. Years 1900 and 2100 aren't divisible by 400 and are therefore not leap years, while years 2000 and 2400 are divisible by 400 and are leap years.

The exception for century years divisible by 400 would be perfect if a tropical year had been 365 + 1/4 − 1/100 + 1/400 = 365.2425 = 365 days, 5 hours, 49 minutes and 12 seconds. But a tropical year is 27 seconds shorter than that. Now you might think the difference would warrant yet another rule, and maybe several more, but it doesn't. With a calendar year that's 27 seconds longer than the tropical year, the drift becomes 7.5 hours over 1,000 years, which apparently wasn't a big enough problem.

Leap year model

The model can be clarified and summarized in a table that compares each rule to the tropical year (365 days, 5 hours, 48 minutes and 45 seconds). Plus = calendar year is longer than the tropical year, minus = shorter.

Leap year when…Calendar year lengthDeviation
Never365 d−5 h 48 min 45 s
Year is divisible by 4365.25 d+11 min 15 s
Divisible by 4 but not 100365.24 d−3 min 9 s
Divisible by 4 but not 100, or divisible by 400 (Gregorian)365.2425 d+27 s

The rules can be summarized in one sentence:

The years evenly divisible by 4, except for century years not evenly divisible by 400, are leap years.

The model as a JavaScript function

With all that knowledge we should be able to build a function where you pass in a year and get back the number of weeks. Something like:

function getWeeksInYear(year) {
// logic
return weeks
}

To determine how many weeks a year has, we need to know what day of the week January 1 of the following year falls on, and whether the current year is a leap year. Perfect cases for two separate functions!

function isLeapYear(year) {
// logic
return boolean
}

function getFirstWeekdayInYear(year) {
// logic
return weekday
}

function getWeeksInYear(year) {
// logic
return weeks
}

isLeapYear

We can start with isLeapYear. My first attempt at implementing the leap-year rules straight off looked something like this:

function isLeapYear(year) {
if (year % 4 === 0) {
if (year % 100 === 0) {
if (year % 400 === 0) return true
else if (year % 400 !== 0) return false
} else if (year % 100 !== 0) return true
} else return false
}

Even though the function does what it should, it's incredibly hard to read. Modulus is used in five places, which clutters the code, and nested if statements are hard to follow. With the approach of starting with the narrowest rule, you can get a much cleaner function. We also take the opportunity to extract the modulus operation:

function isDivisibleBy(numerator, denominator) {
return numerator % denominator === 0
}

function isLeapYear(year) {
if (isDivisibleBy(year, 400)) return true
if (isDivisibleBy(year, 100)) return false
if (isDivisibleBy(year, 4)) return true
return false
}

Here we use that years divisible by 400 are always leap years, that the other years divisible by 100 aren't leap years, and finally that the other years divisible by 4 are leap years (and the rest aren't). Clear and good!

getFirstWeekdayInYear

In the next function we want to know what the first weekday of the next year is. A first thought might be that the function should simply return the first weekday of next year. But to make the function more generally useful, I think it's a better idea that the function returns the first weekday of the year you pass in, and you pass in the next year. Like this:

function getFirstWeekdayInYear(year) {
const date = new Date(year, 0, 1) // 0 = January, 1 = first day of the month
return date.getDay()
}

Here we use the built-in Date object, where year, month 0 (January) and day 1 (first of the month) are used to get a Date for the first day of the year you pass in. The weekday is computed with the built-in getDay which returns 0 for Sunday, 1 for Monday and so on up to 6 for Saturday. So when we use this function we won't get back something like 'Tuesday', but 2 representing Tuesday.

note

There's a sneaky pitfall in the Date constructor: when year is between 0 and 99, the value is interpreted as 1900–1999. So new Date(50, 0, 1) becomes January 1, 1950, not January 1 of year 50. It's a backwards-compatibility behavior from JavaScript's early days that's still around today. To handle small years (and negative BCE years) literally, you can call setFullYear afterwards, which always interprets the integer as the year you pass in:

function getFirstWeekdayInYear(year) {
const date = new Date(year, 0, 1)
date.setFullYear(year) // interpret years 0–99 literally too
return date.getDay()
}

getWeeksInYear

What now about getWeeksInYear so that it returns the number of weeks for any given year? Since getFirstWeekdayInYear(year + 1) is used twice, we cache the result in a variable — that way we avoid creating two separate Date objects:

function getWeeksInYear(year) {
const firstWeekdayNextYear = getFirstWeekdayInYear(year + 1)
if (firstWeekdayNextYear === 5) return 53
if (firstWeekdayNextYear === 6 && isLeapYear(year)) return 53
return 52
}

Putting it all together

The full functionality looks like this:

function isDivisibleBy(numerator, denominator) {
return numerator % denominator === 0
}

function isLeapYear(year) {
if (isDivisibleBy(year, 400)) return true
if (isDivisibleBy(year, 100)) return false
if (isDivisibleBy(year, 4)) return true
return false
}

function getFirstWeekdayInYear(year) {
const date = new Date(year, 0, 1)
date.setFullYear(year) // interpret years 0–99 literally too
return date.getDay()
}

function getWeeksInYear(year) {
const firstWeekdayNextYear = getFirstWeekdayInYear(year + 1)
if (firstWeekdayNextYear === 5) return 53
if (firstWeekdayNextYear === 6 && isLeapYear(year)) return 53
return 52
}

Year 2026 has 53 weeks and is not a leap year.

The function produces a result for all negative and positive integers. Year −3008, for example, gives the result "Year −3008 has 52 weeks and is a leap year." Keep in mind that the week-numbering and leap-year model hasn't always looked the way it does today.

Better alternatives

I think it's useful to practise writing correct and readable functions when programming. In the case of week-number and leap-year calculations, though, I'd rarely recommend doing the math manually like I have here. The risk of bugs is high in home-grown functions — and these days you barely need a third-party library thanks to the built-in Temporal API. Temporal.PlainDate has properties that cover both leap years and week numbers directly:

function isLeapYear(year) {
return Temporal.PlainDate.from({ year, month: 1, day: 1 }).inLeapYear
}

function getWeeksInYear(year) {
// December 28 is always in the last ISO week of the year
return Temporal.PlainDate.from({ year, month: 12, day: 28 }).weekOfYear
}

Temporal follows ISO 8601, so the leap-year reasoning is handled for you under the hood.

Temporal is a recent addition, so support still varies across environments. Where it isn't available, the @js-temporal/polyfill polyfill works, or an established library like date-fns.

The manual implementation in this article is therefore essentially superfluous in practice — but hopefully pedagogically useful for understanding why some years have 53 weeks.

There you go — now you know all about the math behind week numbers and leap years!

References