How-to
Advanced Data Transformation And CleaningDeep dive

Flag Public Holidays in Excel Data with Python

Mark which rows of an Excel dataset fall on public holidays, country by country and region by region, with the holidays package and pandas: holiday names per row, substitute days when a holiday lands on a weekend, regional holidays such as German states, company shutdown days, working days between two dates that skip holidays, and the effect of holidays on daily averages.

Daily sales, delivery times, call volumes and staffing figures all behave differently on public holidays, and a report that ignores them misleads in predictable ways: an average daily order count dragged down by Christmas, an SLA breach counted against a day the warehouse was legally closed, a forecast that expects a normal Monday on Easter Monday. Knowing which dates are holidays sounds trivial until the data covers several countries, holidays move every year (Easter), some apply only in certain regions (Bavaria has holidays Berlin does not), and a holiday that falls on a Saturday is often moved to the following Monday. The holidays package knows all of this for more than a hundred countries. This page uses it with pandas to flag holiday rows per country and region, add company closure days, count working days between dates, and compare averages with and without holidays. It belongs to Working with Dates and Times in Excel Data.

Prerequisites

Bash
pip install holidays pandas openpyxl numpy

shipments.xlsx holds daily shipments for five distribution centres in England, Scotland, Bavaria, Berlin and France from mid-December 2025 to mid-June 2026 — a period with Christmas, New Year, Easter, May holidays and several regional differences. deliveries.xlsx holds four delivery requests with requested and delivered dates, for counting working days. The examples were run with holidays 0.105.

Step 1 — Holidays for each country and region in the data

Build one holiday calendar per country-and-region combination that appears in the data. Regions are called subdivisions in the package:

Python
import holidays
import pandas as pd

ship = pd.read_excel("shipments.xlsx")
years = sorted(ship["Date"].dt.year.unique())
places = ship[["Country", "Region"]].drop_duplicates()

calendars = {}
for country, region in places.itertuples(index=False):
    sub = region if isinstance(region, str) else None
    calendars[(country, sub)] = holidays.country_holidays(country, subdiv=sub, years=years)

for key in [("GB", "ENG"), ("GB", "SCT"), ("DE", "BY"), ("DE", "BE")]:
    hols = calendars[key]
    print(key, len(hols), "holidays, e.g.", sorted({n for d, n in hols.items() if d.month in (1, 3)})[:3])

holidays.country_holidays(country, subdiv=..., years=...) returns a dictionary-like object of dates and names for that place and those years. England and Scotland differ — Scotland has 2 January and St Andrew's Day, England has Easter Monday but Scotland does not — and Bavaria has Epiphany and Corpus Christi, which Berlin does not, while Berlin has International Women's Day on 8 March. Building calendars from the combinations actually present in the data means a new site in a new country only needs its country and region codes filled in. Keep the package up to date: governments announce one-off holidays, and the version used here already lists a one-off Scottish holiday on 15 June 2026 that an older version would miss — worth reviewing whenever the package is upgraded.

Same country, different holidays England has Easter Monday, Scotland does not but has 2 January. Bavaria has Epiphany on 6 January and Corpus Christi, Berlin has International Women's Day on 8 March. A holiday flag must use the region, not just the country. only here not here England (GB-ENG) Easter Monday 2 January Scotland (GB-SCT) 2 January, St Andrew's Day Easter Monday Bavaria (DE-BY) Epiphany, Corpus Christi Women's Day Berlin (DE-BE) Women's Day (8 March) Epiphany

Step 2 — Flag each row with its holiday

With the calendars built, each row looks up its own date in its own place's calendar:

Python
def holiday_name(row):
    sub = row["Region"] if isinstance(row["Region"], str) else None
    return calendars[(row["Country"], sub)].get(row["Date"].date())

ship["Holiday"] = ship.apply(holiday_name, axis=1)
ship["Is holiday"] = ship["Holiday"].notna()
ship["Is weekend"] = ship["Date"].dt.dayofweek >= 5
print(ship.loc[ship["Is holiday"], ["Date", "Site", "Holiday"]].drop_duplicates(["Date", "Holiday"]).head(10).to_string(index=False))
print(ship[ship["Date"] == "2026-01-06"][["Site", "Is holiday", "Holiday"]].to_string(index=False))

Each row now carries the holiday's name, or nothing, plus boolean flags for holidays and weekends. Checking a single date shows the regional effect: 6 January is a holiday in Munich and nowhere else in the data. The name is worth keeping alongside the flag — "why is this day low?" is answered by "Corpus Christi" far better than by True. For large tables, apply the lookup per unique (date, country, region) combination and merge the result back, rather than row by row; the logic is the same.

Step 3 — Substitute days and company closures

When a holiday falls on a weekend, many countries give a substitute weekday — Boxing Day on a Saturday becomes a holiday on Monday 28 December. The package includes these "observed" days where the law provides them; company-specific closures can be added to a calendar by hand:

Python
eng = calendars[("GB", "ENG")]
print({d.isoformat(): n for d, n in sorted(eng.items()) if d.month == 12})

company = holidays.country_holidays("GB", subdiv="ENG", years=years)
company.update({pd.Timestamp("2025-12-24").date(): "Company closure", pd.Timestamp("2025-12-31").date(): "Company closure"})
leeds = ship["Site"] == "Leeds DC"
ship.loc[leeds, "Closed"] = ship.loc[leeds, "Date"].dt.date.map(lambda d: company.get(d))
print(ship.loc[leeds & ship["Closed"].notna(), ["Date", "Closed"]].head(6).to_string(index=False))

England's December entries show the effect: in 2025 Christmas Day and Boxing Day fall on a Thursday and Friday, while in 2026 Boxing Day is a Saturday and the package adds "Boxing Day (observed)" on Monday 28 December automatically. update adds company closure days — Christmas Eve and New Year's Eve here — to a copy of the public calendar, so the Leeds site's "closed" flag combines both. Keeping company days in a separate, explicit list (or a sheet maintained by HR) makes them easy to review each year; mixing them invisibly into the public calendar would make it hard to tell later why a date was excluded.

Step 4 — Working days between two dates, skipping holidays

Delivery times, SLAs and payment terms are usually counted in working days. NumPy's business-day functions accept a holiday list:

Python
import numpy as np

deliv = pd.read_excel("deliveries.xlsx").merge(ship[["Site", "Country", "Region"]].drop_duplicates(), on="Site")

def working_days(row):
    sub = row["Region"] if isinstance(row["Region"], str) else None
    hols = holidays.country_holidays(row["Country"], subdiv=sub, years=[2026])
    hol_dates = np.array(list(hols.keys()), dtype="datetime64[D]")
    return int(np.busday_count(row["Requested"].date(), row["Delivered"].date(), holidays=hol_dates))

deliv["Calendar days"] = (deliv["Delivered"] - deliv["Requested"]).dt.days
deliv["Working days"] = deliv.apply(working_days, axis=1)
print(deliv[["Site", "Requested", "Delivered", "Calendar days", "Working days"]].to_string(index=False))

np.busday_count(start, end, holidays=...) counts weekdays from the start date up to (not including) the end date, skipping the listed holidays. The Leeds request from 31 March to 8 April spans Good Friday and Easter Monday, so eight calendar days are only four working days; the Munich order over the same period loses Good Friday and Easter Monday too, while the Berlin delivery in late May loses Whit Monday. The Leeds request from 23 to 30 December 2026 shows the substitute day at work: seven calendar days, but only three working days once Christmas Day and the observed Boxing Day are skipped. A delivery promised "within five working days" can be checked correctly only this way. Business days without holidays are covered in Calculate Business Days Between Excel Dates with pandas.

Calendar days versus working days around Easter A Leeds delivery requested on Tuesday 31 March and delivered on Wednesday 8 April 2026 took eight calendar days. Skipping the weekend, Good Friday on 3 April and Easter Monday on 6 April leaves four working days. A B 1 Requested Delivered 2 Tue 31 Mar Wed 8 Apr measure days calendar 8 working 4 dates count np.busday_count(…, holidays)

Step 5 — Holidays and daily averages

The most common reporting error is averaging across holidays. Compare a site's average weekday shipments with and without holidays:

Python
weekdays = ship[~ship["Is weekend"]]
summary = weekdays.groupby("Site").agg(
    all_weekdays=("Shipments", "mean"),
    working_days_only=("Shipments", lambda s: s[~weekdays.loc[s.index, "Is holiday"]].mean()),
    holiday_weekdays=("Is holiday", "sum"),
).round(1)
print(summary.to_string())
ship.to_excel("shipments_flagged.xlsx", index=False)

In this synthetic data, holidays were not simulated as quiet days, so the averages barely differ — which is itself the check: real data would show a clear gap, and the holiday_weekdays column shows how many weekday holidays each site had in the period, from Munich and Berlin with more regional holidays to France. With real shipments that drop on holidays, the "working days only" average is the one to use for capacity planning and forecasting, and the holiday rows deserve their own line in the report. The flagged table is written back to Excel, ready for pivots that slice by holiday.

Making a dataset holiday-aware Build one holiday calendar per country and region present, flag each row with its holiday name, add company closure days from an explicit list, count working days between dates with those holidays, and calculate averages on working days only. Calendars per country + region Flag rows holiday name per date Company days explicit extra list Working days busday_count, averages

Common pitfalls

  • One national calendar for all sites. Regional holidays differ; use subdivisions.
  • Hard-coded holiday dates. Easter and others move every year; generate them.
  • Forgetting substitute days. A weekend holiday often moves to Monday; the package handles it.
  • Company closures mixed in silently. Keep them as a separate, reviewable list.
  • Averages that include holidays. Report working-day averages separately.

Conclusion

To make Excel data holiday-aware, build a calendar with the holidays package for each country and region in the data, look up every row's date in its own calendar to add a holiday flag and name, and add company closure days from an explicit list. Use the same holiday lists with np.busday_count to count working days for delivery times and SLAs, and calculate daily averages on working days only, with holiday rows reported separately.

Frequently asked questions

How do I add public holidays to a pandas DataFrame? Create a calendar with the country_holidays function of the holidays package, passing the country, the region as subdiv and the years, then map each date to the calendar's get method, which returns the holiday name or None.

How do I handle regional holidays such as German states? Pass the region code as subdiv, for example "BY" for Bavaria, and build a separate calendar for each country and region combination in the data.

Does the holidays package include substitute days? Yes, where the country's rules provide them, such as a Monday holiday when Christmas falls on a weekend in England.

How do I count working days excluding holidays? Use numpy.busday_count with a holidays argument containing the holiday dates for the relevant country and region.

More on dates & times

More on dates & times →