close

DEV Community

SEN LLC
SEN LLC

Posted on

A 150-Line FastAPI Service for 'Is Today a Holiday?'

A 150-Line FastAPI Service for "Is Today a Holiday?"

Every backend ends up needing a holiday calendar. Here's why a tiny HTTP service is a better place to put one than embedding a library in every app — and what I learned shipping holidays-api.

"Is tomorrow a holiday?" is the kind of question every non-trivial backend eventually needs to answer. Shipping cutoffs, SLA clocks, support rosters, batch jobs that must skip weekends and national holidays, customer-facing "estimated delivery" pages, invoicing dates — they all end up wanting a canonical source of truth for "what counts as a working day?"

📦 GitHub: https://github.com/sen-ltd/holidays-api

holidays-api screenshot showing curl against /health, /holidays, /holidays/check, and /holidays/business-days

The three common answers, all bad

When I've seen teams reach for a holiday calendar, they usually pick one of these, and none of them hold up past the first year:

1. A hardcoded list in the app. Fastest to write. Goes stale the first time a government adds a new holiday, and you find out when the pager goes off during Golden Week. In Japan, the Cabinet Office has added or moved holidays multiple times in the last decade (山の日 was only added in 2016; the 2020 Olympics moved 海の日 and スポーツの日 around). A hardcoded list is guaranteed wrong eventually.

2. Scraping a government website. Feels like the "authoritative source." In practice it's a maintenance tarpit — the layout changes, legal terms of use are often ambiguous, the HTML is not what you'd call stable, and you've added a new way your build can break. I don't recommend doing this. There's a perfectly good maintained library; use it.

3. Embedding a holiday library in every service. This is the sensible answer for a monolith. It stops being sensible the moment you have two backends in different languages. Now your Python service is using python-holidays, your Node worker is using date-holidays, and your Go batch job is using cavaliergopher/grab or whatever, and all three have slightly different opinions about what's a holiday, what's observed, and how to handle the "constitution day falls on a Sunday" case. You're back to square one: not one canonical answer.

The case for an HTTP service

A tiny service over HTTP is a cleaner abstraction than any of those:

  • One canonical answer. Every backend language can hit GET /holidays/check?country=JP&date=2026-05-03 and get the same boolean. The drift problem disappears because there's only one piece of software that computes the answer.
  • One place to upgrade. When python-holidays releases a new version that catches a legislative change, you deploy one container, not N services.
  • Explicit contract. The fact that it's JSON over HTTP forces you to write down the response shape. That turns out to be useful — you notice things like "some dates have multiple holiday names" that are easy to miss inside a function call.
  • Testable in isolation. You can smoke-test the service with curl without spinning up any of its consumers.

The downside is latency — one HTTP hop. For anything calling this thing at human scale (hundreds to thousands of req/s), that's fine. For a hot loop in a batch job, you cache the year's holidays once and look up locally. The service is still the source.

The design

The service is FastAPI + holidays (the Python package), both of which are excellent and neither of which I tried to reinvent. The value here is not calendar computation. It's the HTTP surface.

Five endpoints:

GET /holidays?country=JP&year=2026
GET /holidays/next?country=JP&count=3&after=2026-04-15
GET /holidays/check?country=JP&date=2026-05-03
GET /holidays/business-days?country=JP&from=2026-04-01&to=2026-04-30
GET /countries
Enter fullscreen mode Exit fullscreen mode

Plus /health, /docs (auto-generated OpenAPI), and / (an HTML explainer with a curl example for the lost web-browser user).

The whole service layer is about 150 lines, the FastAPI routes layer another 150 lines, and then roughly the same again in tests. The test suite is 37 cases and runs in 0.6 seconds.

Wrap the library, don't reinvent

Rule zero: I am not writing holiday-date math. The holidays package has been maintained since 2014, supports ~500 countries and subdivisions, handles observed-day shifts, and is driven by people who care about specific legislative changes in specific countries. My job is to expose it over HTTP cleanly. Here's the entire "get all holidays for a year" function:

def get_holidays(country: str, year: int) -> dict[str, Any]:
    _validate_year(year)
    h = _country_holidays(country, years=year)
    category = _category_of(h)
    rows: list[dict[str, Any]] = []
    for d in sorted(h.keys()):
        raw_name = h[d]
        names = _split_names(raw_name)
        for name in names:
            rows.append({
                "date": d.isoformat(),
                "name": name,
                "local_name": name,
                "type": category,
            })
    return {
        "country": country.upper(),
        "year": year,
        "count": len(rows),
        "holidays": rows,
    }
Enter fullscreen mode Exit fullscreen mode

The only interesting line is _split_names(raw_name). I'll come back to that.

Business days: the endpoint worth shipping

The most-used endpoint in practice is going to be /holidays/business-days. It's dead simple:

def business_days(country: str, from_date: date, to_date: date) -> dict[str, Any]:
    if to_date < from_date:
        raise HolidayServiceError("'to' must be on or after 'from'")
    years = list(range(from_date.year, to_date.year + 1))
    h = _country_holidays(country, years=years)

    total = 0
    weekends = 0
    holiday_rows: list[dict[str, Any]] = []
    holiday_days_excluded = 0

    current = from_date
    while current <= to_date:
        total += 1
        is_weekend = current.weekday() >= 5
        is_holiday = current in h
        if is_weekend:
            weekends += 1
        elif is_holiday:
            holiday_days_excluded += 1
            for name in _split_names(h[current]):
                holiday_rows.append({"date": current.isoformat(), "name": name})
        current += timedelta(days=1)

    return {
        "country": country.upper(),
        "from": from_date.isoformat(),
        "to": to_date.isoformat(),
        "total_days": total,
        "business_days": total - weekends - holiday_days_excluded,
        "excluded_weekends": weekends,
        "excluded_holidays": holiday_rows,
    }
Enter fullscreen mode Exit fullscreen mode

That is trivially derivable. You could write it in fifteen lines in any language. And yet — every team I've ever been on has written it from scratch, usually twice, usually with a subtle off-by-one at year boundaries. The whole reason I wanted this endpoint in the service is that "trivial to derive but always reimplemented" is a smell. Ship it once, correctly, with tests, and let every other service just call it.

A quick sanity check against April 2026 in Japan:

$ curl -s "http://localhost:8000/holidays/business-days?country=JP&from=2026-04-01&to=2026-04-30" | jq
{
  "country": "JP",
  "from": "2026-04-01",
  "to": "2026-04-30",
  "total_days": 30,
  "business_days": 21,
  "excluded_weekends": 8,
  "excluded_holidays": [
    { "date": "2026-04-29", "name": "昭和の日" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

April 2026 has 30 days, 8 weekend days, and 1 public holiday (Shōwa Day, on Wednesday the 29th). That's 30 - 8 - 1 = 21 business days. The test suite asserts exactly those numbers.

The multi-name day gotcha

This is the edge case I wouldn't have thought to look for if I hadn't been building this as a deliberate learning exercise.

Some dates are more than one holiday. Italy's November 1st is both Giorno dell'unità nazionale delle Forze armate and Ognissanti (All Saints' Day). India's October 2nd in some years is both Gandhi Jayanti and Dussehra. The holidays package represents this by joining the names with "; " in the string value:

>>> h = holidays.country_holidays('IT', years=2026)
>>> h[date(2026, 11, 1)]
"Giorno dell'unità nazionale e delle Forze armate; Ognissanti"
Enter fullscreen mode Exit fullscreen mode

If you naively return h[date] as "the name," your consumers are either going to render that string verbatim in a UI (ugly) or assume every date has exactly one name and start string-parsing on "; " themselves (fragile and they'll forget). So the service unpacks it:

NAME_SEPARATOR = "; "

def _split_names(value: str) -> list[str]:
    if not value:
        return []
    return [p.strip() for p in value.split(NAME_SEPARATOR) if p.strip()]
Enter fullscreen mode Exit fullscreen mode

And the check endpoint always returns names as a list, even when there's only one:

{
  "country": "IT",
  "date": "2026-11-01",
  "is_holiday": true,
  "names": [
    "Giorno dell'unità nazionale e delle Forze armate",
    "Ognissanti"
  ]
}
Enter fullscreen mode Exit fullscreen mode

This is the tiny, unglamorous kind of design decision that separates "wraps the library" from "useful HTTP contract." The library's output is technically correct but ergonomically hostile. The service fixes that once, for every consumer.

The test for this is particularly satisfying because it exercises a real country-year combo where you can check the answer by hand:

def test_italy_2026_nov_1_has_multiple_names():
    r = check_date("IT", date(2026, 11, 1))
    assert r["is_holiday"] is True
    assert len(r["names"]) == 2
    joined = " / ".join(r["names"])
    assert "Ognissanti" in joined
    assert "nazionale" in joined.lower()
Enter fullscreen mode Exit fullscreen mode

No mocks, no fixtures, no freezegun — just the real library against a real date with a real multi-name answer.

Validation and 422s

Every input is validated before it reaches the library. Unknown country codes, malformed dates, and years outside [1900, 2100] all return 422 with a structured error body. Year bounds in particular are worth calling out: python-holidays will accept any year you ask for, but some country definitions are based on specific legislation and return empty sets for years before the relevant law passed. I'd rather return a clean 422 at the API boundary than silently return an empty holiday list for "1752."

FastAPI's Query(..., ge=1900, le=2100) does year-bound validation in the route layer. Date parsing uses date.fromisoformat and raises HolidayServiceError on failure, which the route handler converts to a 422. Country validation happens inside service._country_holidays, which catches NotImplementedError/KeyError from the library and re-raises as a friendly service error.

Tradeoffs and things that are out of scope (for v0.1)

Observed-day adjustments. When a holiday falls on a weekend, some countries "observe" it on the nearest weekday. UK Boxing Day in 2026 is a Saturday, so the library emits both "Boxing Day" on Dec 26 and "Boxing Day (observed)" on Dec 28. The service surfaces this transparently — I don't collapse it, I don't hide it. Your downstream code has to decide whether "observed" counts as a holiday for its purposes. For most business-calendar use cases, it does.

Regional vs national. The library supports subdivisions — US states, Japanese prefectures, German Länder — each with their own local holidays. v0.1 of this service exposes only national-level holidays. Adding a subdivision query parameter is a mechanical extension; I left it out to keep the v0.1 scope honest.

Public vs bank holidays. The library distinguishes "public," "bank," "school," etc. via categories. v0.1 returns type: "public" by default. Filtering by category is another mechanical extension waiting in the wings.

Timezones. business_days currently treats all dates as naive ISO dates, which is the right call — "is April 15th a holiday in Japan?" is a question about a local calendar day, not a wall-clock instant. If you need to ask "is the current instant a holiday for a user in Tokyo?" you do the tz conversion in your caller and send the resulting ISO date. The service doesn't try to be clever about that.

Caching. None, yet. python-holidays is already fast (it lazy-evaluates the year you ask for), and for typical use this comes in under 10 ms per request. When traffic justifies it, a per-(country, year) LRU is about four lines of code.

Try it in 30 seconds

git clone https://github.com/sen-ltd/holidays-api.git
cd holidays-api
docker build -t holidays-api .
docker run --rm -p 8000:8000 holidays-api
Enter fullscreen mode Exit fullscreen mode

Then:

curl "http://localhost:8000/health"
curl "http://localhost:8000/holidays?country=JP&year=2026"
curl "http://localhost:8000/holidays/check?country=JP&date=2026-05-03"
curl "http://localhost:8000/holidays/next?country=US&count=3&after=2026-04-15"
curl "http://localhost:8000/holidays/business-days?country=GB&from=2026-12-01&to=2026-12-31"
Enter fullscreen mode Exit fullscreen mode

The full OpenAPI surface is at http://localhost:8000/docs for interactive poking.

What this is, and what it isn't

This is not a production-ready SaaS. It's a ~300-line FastAPI service that settles the "is tomorrow a holiday?" question for any backend in your stack, wrapped around a library you should be using anyway. If you already have a monolith in Python and a holiday library imported, you don't need this. If you have three services in three languages all pretending they know when Golden Week is, you probably do.

The bigger lesson, and the reason I wanted to write it up: the right place to put a dependency is often "one hop away," not "in your code." Isolating a library behind a small HTTP service trades a little latency for a lot of organizational clarity — one canonical answer, one thing to upgrade, one place to test, one contract. For holiday calendars, that trade is obviously worth it. It's worth it for a surprising number of other "every service needs a copy of this" libraries, too.

Top comments (0)