How to Find the First/Last Day of a Month from a given Date
by Christoph Schiessl on Python
Last week, I published an article explaining how to use Python to find the first/last day of a given year. One natural follow-up question is how to find the first/last day of a given month, and this is the topic of the article you are reading now ...
The first day of the year is always the 1st of January, and the last is always the 31st of December — there are no exceptions. However, months are more interesting because they are not always the same length: Some are 31 days, and others are 30 days. February is the weirdest month because it is either 28 or 29 days in leap years. That means we cannot simply hardcode the last day of the month but have to calculate it depending on the number of the month (1-12) and the year (i.e., 2024).
We will again use the built-in datetime
module from the standard library and compose our functions from this module's functionality. We will name our functions beginning_of_month
and end_of_month
, the first which is easy because every month has a day number 1, and we can use this to our advantage:
from datetime import date
def begnning_of_month(today: date | None = None) -> date:
today = today or date.today()
return date(today.year, today.month, 1)
But, end_of_month
is more challenging for the reasons that we have already discussed. We first need to explore a few more features of the date
class to implement this function.
Introducing date.resolution
and the timedelta
class
The smallest increment of time the date
class can represent is one full day. For example, given a date
object like the one you get when you call date.today()
, you can move forward by one full day to get tomorrow's date, or you can move backward by one full day to get yesterday's date. However, these are the smallest increments/decrements that you can make — the date
class cannot represent anything between those dates. Inside the datetime
module, this concept is called resolution, and for the date
class, you can get it programmatically with the class property date.resolution
. This property is an instance of class timedelta
, which represents, as the name suggests, a specific duration of time. In the case of the date
class, it's exactly one full day.
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from datetime import date
>>> today = date.today()
>>> res = date.resolution
>>> (res, type(res))
(datetime.timedelta(days=1), <class 'datetime.timedelta'>)
>>> yesterday, tomorrow = today - res, today + res
>>> print(yesterday, today, tomorrow)
2024-02-18 2024-02-19 2024-02-20
You can add/subtract instances of timedelta
from date
objects to move forward/backward by the number of days that the timedelta
represents. Apart from that, you can also multiply timedelta
objects with int
s:
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from datetime import date
>>> one_week = date.resolution * 7
datetime.timedelta(days=7)
>>> today = date.today()
>>> next_week = today + one_week
>>> (today, next_week)
(datetime.date(2024, 2, 19), datetime.date(2024, 2, 26))
How does that help us to implement end_of_month
? Given that we can subtract timedelta
s from date
s, we can already calculate the end of the current month by subtracting one day from the beginning of the next month.
from datetime import date
begnning_of_next_month = date(2024, 3, 1)
end_of_current_month = beginning_of_next_month - date.resolution
assert end_of_current_month == date(2024, 2, 29) # 29 because 2024 is a leap year!
So, now we have to answer a different question: How do we calculate the beginning of the next month? Simple with a three-step process:
- We get the beginning of the current month,
- then we add 31 days (duration of the longest possible month),
- finally, we get the beginning of the month of the date that we just got as a result.
The idea is easier to explain with code:
# This file is saved as firstlast.py so that it can be imported ...
from datetime import date
def beginning_of_month(today: date | None = None) -> date:
today = today or date.today()
return date(today.year, today.month, 1)
def end_of_month(today: date | None = None) -> date:
today = today or date.today()
beginning_of_current_month = beginning_of_month(today) # Step (1)
beginning_of_next_month = beginning_of_month( # Step (3)
beginning_of_current_month + date.resolution * 31 # Step (2)
)
return beginning_of_next_month - date.resolution
And with that, we are done. Except ... there is a bug, but it's pretty hard to spot. Here is a hint:
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from firstlast import *
>>> assert end_of_month(date(9999, 12, 31)) == date(9999, 12, 31)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "/home/cs/firstlast.py", line 12, in end_of_month
beginning_of_current_month + date.resolution * 31
~~~~~~~~~~~~~~~~~~~~~~~~~~~^~~~~~~~~~~~~~~~~~~~~~
OverflowError: date value out of range
Boundaries: date.min
and date.max
As it turns out, the date
class can only represent dates within a specific range. We can use the class properties date.min
/date.max
to get the lower/upper boundaries of this range:
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from datetime import date
>>> date.min
datetime.date(1, 1, 1)
>>> date.max
datetime.date(9999, 12, 31)
>>> date.min - date.resolution
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
OverflowError: date value out of range
>>> date.max + date.resolution
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
OverflowError: date value out of range
As you can see, we get OverflowError
s if we try to step over these boundaries. And if you have been paying attention so far, you should already see why this is a problem for us. The calculation we implemented in end_of_month
has intermediate states that may require date
objects greater than date.max
(we have no problem at the lower boundary). We are computing the beginning of the next month, but as far as the date
class is concerned, there is no next month for December 9999. Long story short, we must find a way to avoid this calculation.
Introducing calendar.monthrange(year, month)
Instead of building our own solution, we can use monthrange()
function from the built-in calendar
module. This function returns a tuple[int, int]
, whose first element is the weekday of the first day of the month, and its second element is the number of days in the month given by the parameters.
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from calendar import monthrange
>>> monthrange(2024, 2) # handles leap-years, and
(calendar.THURSDAY, 29)
>>> monthrange(2025, 2) # non leap-years correctly
(calendar.SATURDAY, 28)
>>> monthrange(9999, 12)
(calendar.WEDNESDAY, 31)
Armed with this new knowledge, we can rewrite our end_of_month()
function as follows:
# This file is saved as firstlast.py so that it can be imported into a console session ...
from datetime import date
from calendar import monthrange
def beginning_of_month(today: date | None = None) -> date:
today = today or date.today()
return date(today.year, today.month, 1)
def end_of_month(today: date | None = None) -> date:
today = today or date.today()
(_, num_days_in_month) = monthrange(today.year, today.month)
return date(today.year, today.month, num_days_in_month)
As expected, the bug is now fixed — no more OverflowError
s:
Python 3.12.2 (main, Feb 17 2024, 11:13:07) [GCC 13.2.1 20230801] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> from firstlast import *
>>> end_of_month(date.min)
datetime.date(1, 1, 31)
>>> end_of_month(date.today())
datetime.date(2024, 2, 29)
>>> end_of_month(date.max)
datetime.date(9999, 12, 31)
This is everything for today. Thank you for reading, and see you again next time!