DEV Community

Alexander Denisov
Alexander Denisov

Posted on Originally published at hldays.dev on

Building hldays #2: Modelling holidays from country down to municipality

In part 1 I explained why I started hldays: I kept forgetting my municipal holiday, and found out that every Portuguese municipality has its own.

This post is about the first real problem: how do you model holidays so the same API works for Lisbon, Bavaria and Texas?

The naive version

My first mental model was a table:

date name country
2026-12-25 Christmas PT

That breaks almost immediately:

  • Holidays aren't dates, they're rules. Christmas is "25 December". Good Friday is "two days before Easter". Thanksgiving is "fourth Thursday of November". You can't store 2016 to 2099 by hand.
  • Holidays aren't national. Bavaria has Epiphany, Berlin doesn't. Madeira has Madeira Day. Braga has São João on 24 June.
  • Places have different shapes. Portugal has districts, two autonomous regions and 308 municipalities. Germany has states. The US has states and territories.

So the model became two things: a tree of jurisdictions, and a set of rules attached to them.

1. A tree of jurisdictions

Every place is a jurisdiction with an id, a level and a parent:

PT country
├── PT-03 subdivision (district) Braga
│ └── PT-M-0303 municipality Braga
├── PT-11 subdivision (district) Lisboa
│ └── PT-M-1106 municipality Lisboa
├── PT-20 subdivision (autonomous_region) Azores
└── PT-30 subdivision (autonomous_region) Madeira

Enter fullscreen mode Exit fullscreen mode

There are only three levels (country, subdivision, municipality), but each subdivision also has a kind (district, autonomous_region, state...). The levels keep the API predictable, and the kind keeps the local meaning.

Not every country uses every level. Right now Portugal, Germany and Brazil go down to municipalities. The UK and the US stop at subdivisions, and Greece is national only. Each country also says what its weekend is, because "business day" depends on it.

The ids follow ISO 3166 where it exists (PT, PT-03, DE-BY), with a municipality code for the extra level (PT-M-0303, from Portugal's official municipality code).

2. Holidays are inherited

The main idea is simple: a jurisdiction gets its own holidays plus everything from its parents.

Ask for Braga municipality and you get:

  • the 13 national public holidays from PT
  • São João on 24 June from PT-M-0303

Every holiday in the response says where it came from:

{
  "date": "2026-06-24",
  "holiday_id": "feriado-municipal",
  "name": { "pt": "São João Baptista" },
  "type": "local",
  "status": "verified",
  "source": {
    "url": "https://irn.justica.gov.pt/Contactos-do-Registo/Registo-Civil-de-Braga",
    "verified_at": "2026-10-16",
    "quote": "Encerrado no feriado municipal (24 de junho)"
  },
  "jurisdiction": { "id": "PT-M-0303", "name": "Braga", "level": "municipality" }
}

Enter fullscreen mode Exit fullscreen mode

That jurisdiction field turned out to matter a lot. For Bavaria, Epiphany comes from DE-BY and Christmas from DE, so you can tell "this is a Bavarian holiday" apart from "this is a German holiday" without guessing.

Inheritance also needs an escape hatch for the day a place doesn't observe something its parent does (a US state that skips a federal holiday, say). A jurisdiction can list excludes: inherited holiday ids it skips. No place in the data needs it yet, but it's tested and ready.

The whole mechanism is small. Walk from the place up to its country, collect each level's holidays, and drop anything a more specific level has excluded. This is the real code from src/core/calendar.ts:

/** The jurisdiction followed by its ancestors up to the country. */
chain(j: Jurisdiction): Jurisdiction[] {
  const out: Jurisdiction[] = [];
  for (let cur: Jurisdiction | undefined = j; cur; cur = cur.parent ? this.byId.get(cur.parent) : undefined) {
    out.push(cur);
  }
  return out;
}

/** Own + inherited occurrences, minus inherited holidays a descendant in the chain `excludes`. */
private resolved(j: Jurisdiction, year: number, municipal: boolean, includeSelf = true): Occurrence[] {
  const out: Occurrence[] = [];
  const excluded = new Set<string>();
  for (const x of this.chain(j)) {
    if ((includeSelf || x !== j) && (municipal || x.level !== "municipality"))
      out.push(...this.own(x, year).filter((o) => !excluded.has(o.holiday_id)));
    for (const id of x.excludes ?? []) excluded.add(id);
  }
  return out;
}

Enter fullscreen mode Exit fullscreen mode

The municipal flag is also where the free plan is enforced: with municipal: false the walk simply skips the municipality level, so the same code serves both plans.

3. Rules, not dates

hldays stores 716 holiday rules, not dates. When you ask for a year (anything from 2016 to 2099), the rules are expanded into concrete dates.

The data lives in YAML files in the repo. This is Braga's entire entry:

- id: PT-M-0303
  country: PT
  level: municipality
  name: "Braga"
  parent: PT-03
  codes: { ine: "0303" }
  holidays:
    - id: feriado-municipal
      name: { pt: "São João Baptista" }
      type: local
      rule: { fixed: "06-24" }
      status: verified
      source: { url: "https://irn.justica.gov.pt/Contactos-do-Registo/Registo-Civil-de-Braga", verified_at: "2026-10-16", quote: "Encerrado no feriado municipal (24 de junho)" }

Enter fullscreen mode Exit fullscreen mode

A rule has exactly one shape. These are the real ones, from src/core/types.ts:

export type Rule =
  | { fixed: string } // "MM-DD"
  | { easter: number } // offset in days from Easter Sunday (Gregorian)
  | { nth_weekday: { month: number; weekday: number; n: number; offset?: number } } // n=-1 is last
  | { on_or_before: { date: string; weekday: number } } // that weekday on or before "MM-DD"
  | { dates: string[] } // explicit "YYYY-MM-DD" list, for holidays decreed year by year
  | { orthodox_easter: number } // offset in days from Orthodox (Julian) Easter Sunday
  | { calendar_date: CalendarDate }; // a day in a non-Gregorian calendar

Enter fullscreen mode Exit fullscreen mode

In practice, three of them do most of the work: fixed (437 rules), nth_weekday (135) and easter (105). Corpus Christi in Portugal is { easter: 60 }, Memorial Day in the US is the last Monday of May (n: -1), and a municipality whose holiday is "the Monday after the first Sunday of October" is nth_weekday with an offset of 1. The odd ones exist because real holidays needed them: dates covers holidays that a government decrees one year at a time, and orthodox_easter is for Greece.

Two more fields sit next to the rule:

  • substitute : what happens when the date falls on a weekend. US federal holidays move a Saturday to the Friday before and a Sunday to the Monday after. In 2026, 4 July is a Saturday, so the US response lists both 4 July and Friday 3 July, the second one with "substitute_for": "2026-07-04".
  • valid_from / valid_to : holidays get added and removed by law. Brazil's National Day of Zumbi and Black Awareness (20 November) became a national holiday in 2024, so it has valid_from: 2024, while the state-level versions that existed before end with valid_to: 2023. 95 rules have a range like this.

Portugal has its own famous case: four national holidays were suspended between 2013 and 2015 and restored in 2016. The API's range starts in 2016, so that one never needs a range at all.

4. Types and status

Every holiday also carries two fields that I now consider essential:

  • type : public (a day off by law), local (a municipal holiday, observed by public services and optional for some private employers) or optional (a day off only by agreement or government decision, like Carnival in Portugal). By default the API returns public and local.
  • status : verified or unverified. A holiday is only verified when it's backed by an official source, with a URL and the date it was checked. That's the topic of the next post.

Right now Portugal has 309 of 326 rules verified. The other countries are still unverified, and the API says so instead of hiding it.

5. Names are harder than dates

"Braga" is both a district (PT-03) and a municipality (PT-M-0303). Searching for it returns both:

curl "https://api.hldays.dev/v1/jurisdictions?q=braga"


{ "data": [
  { "id": "PT-M-0303", "name": "Braga", "level": "municipality", "country": "PT", "parent": "PT-03" },
  { "id": "PT-03", "name": "Braga", "level": "subdivision", "kind": "district", "country": "PT", "parent": "PT" },
  { "id": "PT-M-0402", "name": "Bragança", "level": "municipality", "country": "PT", "parent": "PT-04" },
  { "id": "PT-04", "name": "Bragança", "level": "subdivision", "kind": "district", "country": "PT", "parent": "PT" }
]}

Enter fullscreen mode Exit fullscreen mode

So the holiday endpoints accept a name (jurisdiction=lisboa or jurisdiction=Lisbon) and prefer the most specific level. Search ignores accents, aliases cover English names, and a country parameter resolves names that exist in more than one country. There's a whole post on this later in the series.

Holiday names are stored per language ({ "pt": "Ano Novo", "en": "New Year's Day" }): the official name first, plus English where I have it. National holidays have both; the municipal ones only have their Portuguese name so far.

What I'd tell myself at the start

  1. Model the places before the holidays. Once the jurisdiction tree was right, inheritance was almost free.
  2. Store rules, generate dates. It's less data, it covers every year, and changes in the law become a date range instead of a migration.
  3. Always say where a value came from. The jurisdiction on each holiday, and the source, made the data easier to trust and easier to debug.

Next time: a source on every record, and how I collect, verify and keep 300+ municipal holidays up to date.

hldays.dev · Docs

Top comments (0)