Calculate Month-to-Date and Year-to-Date Totals with pandas
"How are we doing this month?" is a month-to-date question: sales from the first of the month up to today. Management packs add quarter-to-date and year-to-date, and every one of those figures is compared with the same period last year — which must stop at the same day last year, not at the end of last year's month, or this month always looks bad. In Excel these are SUMIFS formulas with date bounds that someone has to keep correct. In pandas they are boolean masks on a date column, plus groupby(...).cumsum() when the report needs a running column. This page builds both, and the comparison that people most often get wrong. It belongs to Working with Dates and Times in Excel Data.
Prerequisites
pip install pandas openpyxl
daily_sales_2y.xlsx has daily sales from 1 January 2025 to 2 October 2026, with no rows for Sundays — a realistic gap that matters for the comparisons below. The examples report as of 2 October 2026.
Step 1 — MTD, QTD and YTD as of a date
import pandas as pd
sales = pd.read_excel("daily_sales_2y.xlsx", parse_dates=["Date"])
as_of = pd.Timestamp("2026-10-02")
def period_to_date(df, as_of, freq):
start = as_of.to_period(freq).start_time
mask = (df["Date"] >= start) & (df["Date"] <= as_of)
return df.loc[mask, "Sales"].sum(), start
for label, freq in (("MTD", "M"), ("QTD", "Q"), ("YTD", "Y")):
total, start = period_to_date(sales, as_of, freq)
print(f"{label}: {total:>12,.0f} ({start:%d %b %Y} – {as_of:%d %b %Y})")
as_of.to_period("M").start_time is the first day of the period containing the report date — 1 October for the month, 1 October for the quarter (Q4 starts in October), 1 January for the year. One function covers all three, and the printed date range makes the figure self-explanatory in a log or report. Pass the report date in explicitly rather than calling Timestamp.today() inside the function: reruns and backfills then produce the same numbers, as discussed in Backfill Excel Reports for Past Dates.
Step 2 — The same period last year, to the same day
def same_period_last_year(df, as_of, freq):
ly_end = as_of - pd.DateOffset(years=1)
ly_start = ly_end.to_period(freq).start_time
mask = (df["Date"] >= ly_start) & (df["Date"] <= ly_end)
return df.loc[mask, "Sales"].sum(), ly_start, ly_end
rows = []
for label, freq in (("MTD", "M"), ("QTD", "Q"), ("YTD", "Y")):
this, _ = period_to_date(sales, as_of, freq)
last, s, e = same_period_last_year(sales, as_of, freq)
rows.append({"Measure": label, "This year": this, "Last year": last,
"Change %": (this / last - 1) * 100, "LY window": f"{s:%d %b} – {e:%d %b %Y}"})
summary = pd.DataFrame(rows)
print(summary.round(1).to_string(index=False))
Last year's window ends on 2 October 2025, not 31 October: comparing two days of this October with all of last October would show a 90-plus percent drop every month on day two. pd.DateOffset(years=1) moves the date back one calendar year and handles month lengths; for a report run on 29 February it lands on 28 February, which is the usual convention. The comparison is like-for-like in calendar days but not necessarily in trading days: 1–2 October 2026 are a Thursday and Friday, 1–2 October 2025 a Wednesday and Thursday, and with no Sunday trading a window that contains an extra Sunday has one fewer trading day. Step 5 deals with that.
Step 3 — Running period-to-date columns
Daily reports often want a column that accumulates through the month and resets on the first — the running MTD next to each day's sales:
recent = sales[sales["Date"] >= "2026-09-01"].copy()
recent["MTD"] = recent.groupby(recent["Date"].dt.to_period("M"))["Sales"].cumsum()
recent["YTD"] = sales.groupby(sales["Date"].dt.year)["Sales"].cumsum().loc[recent.index]
print(recent.tail(5).to_string(index=False))
print(recent.loc[recent["Date"].isin(pd.to_datetime(["2026-09-30", "2026-10-01"])), ["Date", "Sales", "MTD"]].to_string(index=False))
Grouping by the month period and taking a cumulative sum gives a running total that restarts each month: 30 September carries the full September total, 1 October starts again from that day's sales. The YTD column is computed on the full table and then aligned to the recent rows by index, so it includes January to August. Running totals in general, including year-over-year growth, are in Running Totals and Year-Over-Year Growth in pandas.
Step 4 — Fiscal year-to-date
With a fiscal year starting in April, YTD starts on 1 April. Pandas periods can carry the fiscal year directly through an anchored frequency:
fiscal_year = sales["Date"].dt.to_period("Q-MAR").dt.qyear # FY ends in March
sales["FiscalYear"] = "FY" + fiscal_year.astype(str).str[-2:]
fy_label = sales.loc[sales["Date"] == as_of, "FiscalYear"].iloc[0]
fy_start = pd.Timestamp(year=as_of.year if as_of.month >= 4 else as_of.year - 1, month=4, day=1)
fytd = sales.loc[(sales["Date"] >= fy_start) & (sales["Date"] <= as_of), "Sales"].sum()
print(f"{fy_label} to date ({fy_start:%d %b %Y} – {as_of:%d %b %Y}): {fytd:,.0f}")
to_period("Q-MAR") creates quarters for a year ending in March, and .qyear is the fiscal year each date belongs to — 2 October 2026 is in fiscal year 2027, labelled FY27 here. The same labels come out of the date table in Build a Date Table for Excel Reports with pandas, which is the better home for fiscal logic when several reports share it.
Step 5 — Compare per trading day when calendars differ
When the number of trading days differs between the two windows, compare averages per trading day alongside the totals:
def window(df, start, end):
w = df[(df["Date"] >= start) & (df["Date"] <= end)]
return w["Sales"].sum(), w["Date"].nunique()
this_total, this_days = window(sales, pd.Timestamp("2026-10-01"), as_of)
last_total, last_days = window(sales, pd.Timestamp("2025-10-01"), as_of - pd.DateOffset(years=1))
print(f"this year: {this_total:,.0f} over {this_days} trading days = {this_total / this_days:,.0f}/day")
print(f"last year: {last_total:,.0f} over {last_days} trading days = {last_total / last_days:,.0f}/day")
Counting distinct dates with sales gives the trading days in each window. In this sample both windows have two trading days, so the totals and the per-day averages tell the same story — an 11% drop on a two-day base, which says more about two individual days than about the month. On a window that straddles a Sunday in one year and not in the other, the two measures diverge. The totals answer "how much", the per-day averages answer "how well", and showing both prevents an extra trading day from passing as growth. Early in a month, when windows are a few days long, the per-day figure is the more honest of the two.
Step 6 — Write the summary sheet
summary["This year"] = summary["This year"].round(0)
summary["Last year"] = summary["Last year"].round(0)
summary["Change %"] = (summary["Change %"] / 100).round(4)
with pd.ExcelWriter("period_to_date.xlsx", engine="xlsxwriter") as xw:
summary.to_excel(xw, sheet_name="Summary", index=False, startrow=2)
book, ws = xw.book, xw.sheets["Summary"]
ws.write(0, 0, f"Period-to-date sales as of {as_of:%d %B %Y}", book.add_format({"bold": True, "font_size": 13}))
ws.set_column("B:C", 14, book.add_format({"num_format": "#,##0"}))
ws.set_column("D:D", 10, book.add_format({"num_format": "+0.0%;-0.0%"}))
ws.set_column("E:E", 26)
print(summary.to_string(index=False))
The title states the report date, the windows are spelled out in the last column, and the change is formatted with an explicit sign. A reader can verify every number against the source without asking how it was computed — the best defence against "these don't match my spreadsheet".
Performance notes
Each period-to-date figure is a boolean mask and a sum over the date column, which pandas evaluates in milliseconds even on millions of rows; computing a dozen measures by masking repeatedly is fine. If the table is very large and the report needs many windows, sort by date once and use searchsorted to find window boundaries, or aggregate to daily totals first — summing 600 daily rows is faster than masking 6 million transaction rows each time, and gives the same answer.
Common pitfalls
- Comparing with last year's whole period. End last year's window on the same day.
- Using today's date inside the calculation. Pass the report date in, so reruns are reproducible.
- Incomplete data for the report date. If today's sales arrive overnight, report up to yesterday.
- Leap days.
DateOffset(years=1)maps 29 February to 28 February; be consistent about it. - Trading-day differences. Show per-trading-day averages next to totals.
Conclusion
Period-to-date figures are sums over a window from the start of the period to the report date: compute the start with to_period(freq).start_time, mask, and sum. Compare with last year by moving the report date back with DateOffset(years=1) and using the same-length window, not last year's full period. Use groupby(period).cumsum() for running columns that reset, Q-MAR periods for an April fiscal year, per-trading-day averages when calendars differ, and state the report date and windows in the output.
Frequently asked questions
How do I calculate month-to-date sales in pandas? Find the first day of the month with as_of.to_period("M").start_time, select rows with dates between that day and the report date, and sum the sales column.
How do I compare MTD with the same period last year? Move the report date back one year with as_of - pd.DateOffset(years=1), take the start of that month, and sum the window up to that same day. Do not compare with the whole of last year's month.
How do I add a running MTD column that resets each month? Group by the month period of the date column and take a cumulative sum of the sales within each group; the total restarts on the first of every month.
How do I calculate fiscal year-to-date for a year starting in April? Use 1 April of the current fiscal year as the window start — the same calendar year if the report month is April or later, otherwise the previous year. to_period("Q-MAR").dt.qyear gives the fiscal year number.
Related
- Up one level: Working with Dates and Times in Excel Data — date handling for reports.
- Group Excel Rows by Month and Quarter with pandas — totals per full period.
- SUMIF and SUMIFS Equivalent in pandas — the conditional sums behind these figures.
- Add a KPI Summary Block to an Excel Dashboard — presenting MTD and YTD in a dashboard.
More on dates & times
- How-toHighlight Overdue Dates in Excel with openpyxl
- How-toHighlight Weekends and Holidays in a Date Column
- How-toKeep the Latest Record per Key When Deduplicating with pandas
- How-toCalculate Moving Averages with pandas rolling
- How-toEOMONTH and EDATE Equivalents in pandas
- How-toYEARFRAC and DAYS360 Equivalents in Python