EOMONTH and EDATE Equivalents in pandas
Contract end dates, invoice due dates, reporting periods and subscription renewals all rest on two Excel functions. EDATE(start, months) moves a date by whole months, and EOMONTH(start, months) returns the last day of the month that many months away. pandas has the pieces to do the same for a whole column at once, but its most obvious tool, MonthEnd, behaves differently from EOMONTH in a way that quietly shifts some dates by a month. This guide, part of Excel Formula Equivalents in pandas, gives exact equivalents, checked against a spreadsheet, and covers the edge cases that matter in schedules.
Prerequisites
pip install pandas numpy openpyxl
Dates that exercise the edge cases: a 31st, a mid-month date, a leap day, a date with a time and a year end:
import numpy as np
import pandas as pd
contracts = pd.DataFrame({
"contract": ["C1", "C2", "C3", "C4", "C5"],
"start": pd.to_datetime(["2026-01-31", "2026-01-15", "2024-02-29",
"2026-03-31 14:30", "2026-12-31"], format="mixed"),
"term_months": [1, 12, -12, 2, 3],
})
contracts.to_excel("contracts.xlsx", index=False)
df = pd.read_excel("contracts.xlsx")
Step 1 — EDATE with DateOffset
For a fixed number of months, add a DateOffset. It moves the month and clamps the day to the end of shorter months, exactly as EDATE does:
plus_one = (df["start"] + pd.DateOffset(months=1)).dt.normalize()
print(plus_one.dt.strftime("%Y-%m-%d").tolist())
# ['2026-02-28', '2026-02-15', '2024-03-29', '2026-04-30', '2027-01-31']
Recalculating =EDATE(DATE(2026,1,31),1) in a spreadsheet gives 28 February 2026, and =EDATE(DATE(2024,2,29),-12) gives 28 February 2023 — the same clamping. The normalize() matters for C4: DateOffset keeps the 14:30 time, but EDATE returns a whole date and drops it. Leave the time in and a later comparison such as "due date is before today" can flip on the due day itself.
Step 2 — EDATE with a different number of months per row
In the workbook, the months argument usually points at a cell — each contract has its own term. DateOffset takes a single number, so for a column of month counts, use monthly periods: shift the month, then put back the original day, clamped to the length of the target month:
def edate(dates, months):
dates = pd.to_datetime(dates)
target = dates.dt.to_period("M") + np.asarray(months, dtype=int)
day = np.minimum(dates.dt.day, target.dt.days_in_month)
return target.dt.to_timestamp() + pd.to_timedelta(day - 1, unit="D")
df["end"] = edate(df["start"], df["term_months"])
print(df["end"].dt.strftime("%Y-%m-%d").tolist())
# ['2026-02-28', '2027-01-15', '2023-02-28', '2026-05-31', '2027-03-31']
Everything happens on whole columns, so this is fast for hundreds of thousands of rows, and to_timestamp() produces midnight, dropping the time like EDATE. Excel truncates fractional months — EDATE(date, 2.9) moves two months — and converting the months to integers with np.asarray(months, dtype=int) does the same. Negative fractions truncate towards zero in both.
Step 3 — EOMONTH with periods, not MonthEnd
The obvious pandas tool for month ends is the MonthEnd offset. It is also the trap:
from pandas.tseries.offsets import MonthEnd
print((df["start"] + MonthEnd(1)).dt.strftime("%Y-%m-%d").tolist()[:2])
# ['2026-02-28', '2026-01-31']
MonthEnd(1) means "roll forward to the next month end". For 15 January that is 31 January — the current month — while for 31 January, already a month end, it is 28 February. EOMONTH(date, 1) returns the end of the following month for both. The period approach has no such dependency:
def eomonth(dates, months=0):
dates = pd.to_datetime(dates)
target = dates.dt.to_period("M") + np.asarray(months, dtype=int)
return target.dt.to_timestamp(how="end").dt.normalize()
print(eomonth(df["start"], 1).dt.strftime("%Y-%m-%d").tolist())
# ['2026-02-28', '2026-02-28', '2024-03-31', '2026-04-30', '2027-01-31']
print(eomonth(df["start"], 0).dt.strftime("%Y-%m-%d").tolist()[:2])
# ['2026-01-31', '2026-01-31']
to_timestamp(how="end") returns the last nanosecond of the month; normalize() brings it back to midnight so it compares equal to a plain date. The months argument accepts one number or a column, just like the Excel function referencing a cell.
Step 4 — First day of the month
The Excel idiom =EOMONTH(date, -1) + 1 gives the first day of the date's month. With periods it is one call:
first = df["start"].dt.to_period("M").dt.to_timestamp()
print(first.dt.strftime("%Y-%m-%d").tolist())
# ['2026-01-01', '2026-01-01', '2024-02-01', '2026-03-01', '2026-12-01']
same_as_excel = eomonth(df["start"], -1) + pd.Timedelta(days=1)
print((first == same_as_excel).all()) # True
First-of-month dates are the usual key for monthly aggregation; grouping by them sorts naturally and writes to Excel as real dates. Monthly and fiscal grouping more broadly is covered in Group Excel Data by Fiscal Year and ISO Week.
Step 5 — Build month-end schedules without drift
A payment schedule built by applying EDATE repeatedly — each date one month after the previous — drifts. Start on 31 January and the second date is 28 February; one month after that is 28 March, not 31 March, and every later date sticks to the 28th:
chained = [pd.Timestamp("2026-01-31")]
for _ in range(3):
chained.append(chained[-1] + pd.DateOffset(months=1))
from_start = [pd.Timestamp("2026-01-31") + pd.DateOffset(months=k) for k in range(4)]
month_ends = eomonth(pd.Series([pd.Timestamp("2026-01-31")] * 4), range(4))
print([d.strftime("%m-%d") for d in chained]) # ['01-31', '02-28', '03-28', '04-28']
print([d.strftime("%m-%d") for d in from_start]) # ['01-31', '02-28', '03-31', '04-30']
print(month_ends.dt.strftime("%m-%d").tolist()) # ['01-31', '02-28', '03-31', '04-30']
Always offset from the original start date — EDATE(start, k) for the k-th payment — rather than from the previous payment. When the schedule is meant to fall on month ends, use EOMONTH semantics directly; it states the intent and cannot drift.
Step 6 — Prorate by days in the month
A common reason for EOMONTH in a workbook is proration: a subscription that starts on the 20th is charged for the remaining days of that month. Excel writes DAY(EOMONTH(start, 0)) for the number of days in the month and EOMONTH(start, 0) - start + 1 for the days remaining. Both translate directly:
month_end = eomonth(df["start"], 0)
days_in_month = df["start"].dt.days_in_month
days_left = (month_end - df["start"].dt.normalize()).dt.days + 1
monthly_fee = 90.0
df["first_charge"] = (monthly_fee * days_left / days_in_month).round(2)
print(pd.DataFrame({"start": df["start"].dt.strftime("%m-%d"), "left": days_left,
"of": days_in_month, "charge": df["first_charge"]}).values.tolist()[:3])
# [['01-31', 1, 31, 2.9], ['01-15', 17, 31, 49.35], ['02-29', 1, 29, 3.1]]
dt.days_in_month handles leap years, so 29 February 2024 is correctly the last of 29 days. Normalising the start date before subtracting keeps a start time such as 14:30 from shortening the count by a day. If the charge must match the workbook to the cent, round with the Excel-compatible function from Match Excel Rounding in pandas rather than round.
Step 7 — Write the dates back as Excel dates
The results are ordinary datetime columns, so to_excel writes them as real Excel dates. Give them a date format so they do not show a time of 00:00:00:
with pd.ExcelWriter("contracts_out.xlsx", engine="openpyxl", datetime_format="yyyy-mm-dd") as writer:
df.assign(month_end=eomonth(df["end"])).to_excel(writer, index=False)
print(pd.read_excel("contracts_out.xlsx")["month_end"].dt.strftime("%Y-%m-%d").tolist()[:2])
# ['2026-02-28', '2027-01-31']
If the workbook will keep calculating after you hand it over — users changing start dates, for example — write the formulas instead of the values, so Excel stays the source of the calculation; see Write a Formula to an Excel Cell with openpyxl.
Common pitfalls
- Adding 30 days for "one month". It lands on the wrong day in every month that is not 30 days long, and the error grows with every month added. Use month arithmetic.
- Dates read as text. Month arithmetic needs datetime columns. Convert with
pd.to_datetimeand an explicitformatfirst. - Business days.
EDATEandEOMONTHignore weekends; a month end can be a Sunday. If a due date must fall on a working day, roll it with a business-day offset afterwards, the equivalent of Excel'sWORKDAY. - Timezone-aware timestamps. Periods drop the timezone. Convert to local dates first when month boundaries depend on the local calendar.
Conclusion
EDATE is DateOffset(months=n) for a fixed count, or a period shift with a clamped day for a count per row; normalise either result to drop the time as Excel does. EOMONTH is a period shift converted back with how="end" and normalised — not MonthEnd, which depends on whether the start is already a month end. Offset every schedule date from the original start to avoid drift, and write the results as formatted Excel dates.
Frequently asked questions
What is the pandas equivalent of EDATE? Add pd.DateOffset(months=n) to a datetime column. Like EDATE, it clamps to the last valid day, so 31 January plus one month is 28 February. Normalise the result to drop the time, which EDATE also discards.
What is the pandas equivalent of EOMONTH? Convert to a monthly Period, add the number of months, and convert back with to_timestamp(how="end") followed by normalize(). That gives the last day of the target month for every date, whether or not it is already a month end.
Why does MonthEnd(1) give different results for different dates? MonthEnd(1) rolls forward to the next month end, so 15 January goes to 31 January but 31 January goes to 28 February. EOMONTH(date, 1) always returns the end of the following month. Use Period arithmetic to avoid the difference.
How do I get the first day of the month? Convert to a monthly Period and back with to_timestamp(), which returns the first day of the month at midnight. It gives the same result as the Excel idiom EOMONTH(date, -1) + 1.
Related
- Up one level: Excel Formula Equivalents in pandas — Excel functions translated to pandas.
- Running Totals and Year-over-Year Growth in pandas — month-over-month comparisons.
- Match Excel Rounding in pandas — another place where pandas and Excel differ quietly.
- Working with Dates and Times in Excel Data — reading and converting Excel dates.
More on excel functions in pandas
- How-toThe VLOOKUP Equivalent in pandas for Excel Files
- How-toExtract Numbers and Codes from Text with pandas
- How-toSplit Full Names into First and Last Names with pandas
- How-toBin Values into Ranges Like Excel FREQUENCY with pd.cut
- How-toCalculate Percent of Total per Group in pandas
- How-toCount Distinct Values per Group in pandas