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
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" }
}
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;
}
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)" }
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
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 hasvalid_from: 2024, while the state-level versions that existed before end withvalid_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) oroptional(a day off only by agreement or government decision, like Carnival in Portugal). By default the API returnspublicandlocal. -
status:verifiedorunverified. A holiday is onlyverifiedwhen 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" }
]}
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
- Model the places before the holidays. Once the jurisdiction tree was right, inheritance was almost free.
- 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.
-
Always say where a value came from. The
jurisdictionon each holiday, and thesource, 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.
Top comments (0)