SELECT
CAST(strftime(dates, '%Y%m%d') AS INTEGER) AS date_key,
dates AS date,
YEAR(dates) AS year,
QUARTER(dates) AS quarter,
MONTH(dates) AS month,
monthname(dates) AS month_name,
WEEK(dates) AS week,
DAY(dates) AS day_of_month,
DAYOFWEEK(dates) AS day_of_week,
dayname(dates) AS day_name,
DAYOFYEAR(dates) AS day_of_year,
CASE WHEN DAYOFWEEK(dates) IN (0, 6) THEN true ELSE false END AS is_weekend,
false AS is_holiday, -- Can be updated with actual holidays
CASE
WHEN MONTH(dates) >= 7 THEN YEAR(dates) + 1
ELSE YEAR(dates)
END AS fiscal_year, -- Assuming fiscal year starts July 1
CASE
WHEN MONTH(dates) >= 7 THEN QUARTER(dates) - 2
ELSE QUARTER(dates) + 2
END AS fiscal_quarter
FROM generate_series(
DATE '2020-01-01',
DATE '2025-12-31',
INTERVAL '1 day'
) AS t(dates);10 Calendar Date Dimension
A date dimension is a fancy synonym for “calendar”. I think having a calendar table is essential for any data warehouse. If you don’t have one yet, drop everything that you’re doing and go create one. Just trust me, you can thank me later.
10.1 Building it with DuckDB
There’s no one right way to build a calendar table. If you want to maintain an Excel file that gets regularly ingested, that’s fine. If you want to use a Python or R, that’s great too because they both have expressive ways to manipulate dates1. If you want to use SQL, that’s also fine. In DuckDB, it’s extremely easy because generate_series returns a list of dates that you can manipulate. Here’s some code that creates the calendar:
10.2 Adding Holidays
A good calendar table also needs a column whether a date is a holiday. Generally, there is no way to create a holiday field without some sort of dependency, so there is no best way of creating it. The simplest way is to create an Excel file with some years in advance that you would ingest on a yearly basis. In that case your dependency is someone on the team remembering to expand the table and updating the holidays.
A different way of handling holidays is to automate the creation of the holiday field. Basically, it would be a case when statement that checks whether the date is December 24th, December 25th, January 1st etc. The first wrinkle is that someone will still have to keep up with adding or removing holidays. In fact, in 2019, Lithuania declared November 2nd, All Souls’ day, a holiday. Holidays do change over time!
The second wrinkle is that calculating your own holidays can be tricky. For example, Easter Sunday is a holiday in many countries but it doesn’t have a fixed date. It falls on the first Sunday after the first full moon following the vernal equinox. In other words, it’s a moving target and there’s a whole Wikipedia article about the it. In SQL, you could create a macro or a function. Based on this great 2020 blog post, here’s an implementation of an algorithm published in New Scientist in 1961:
CREATE OR REPLACE MACRO easter_sunday(y) AS TABLE
SELECT easter_sunday_date
FROM (VALUES (y % 19, (y / 100)::int, y % 100)) AS e1(a, b, c)
CROSS JOIN LATERAL (VALUES (b / 4, b % 4)) AS e2(d, e)
CROSS JOIN LATERAL (VALUES (FLOOR((8 * b + 13) / 25))) AS e3(g)
CROSS JOIN LATERAL (VALUES ((19*a + b - d - g + 15) % 30, FLOOR(c/4), c%4)) AS e4(h, i, k)
CROSS JOIN LATERAL (VALUES (FLOOR((32 + 2*e + 2*i - h - k) % 7))) AS e5(l)
CROSS JOIN LATERAL (VALUES (FLOOR((a + 11*h + 19*l) / 433))) AS e6(m)
CROSS JOIN LATERAL (VALUES (FLOOR((h + l - 7*m + 90) / 25))) AS e7(n)
CROSS JOIN LATERAL (VALUES (FLOOR((h + l - 7*m + 33*n + 19) % 32))) AS e8(p)
CROSS JOIN LATERAL (VALUES (make_date(y, n::int, p::int))) AS e9(easter_sunday_date);To join the dates to our date dimension, we generate a series of years to pass into our macro and then join the resulting table to the date dimension:
create or replace table dim_date_with_easter as
with easter_sundays as (
SELECT
e.*
FROM generate_series(2020, 2025) AS t(y),
LATERAL easter_sunday(t.y) AS e
)
select
dim_date.*,
case when easter_sunday_date is not null then 1 else 0 end as is_easter_sunday
from dim_date
left join easter_sundays
on easter_sunday_date = date
select *
from dim_date_with_easter
where is_easter_sunday = 1| date_key | date | year | quarter | month | month_name | week | day_of_month | day_of_week | day_name | day_of_year | is_weekend | is_holiday | fiscal_year | fiscal_quarter | is_easter_sunday | is_easter_sunday_1 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 20200412 | 2020-04-12 | 2020 | 2 | 4 | April | 15 | 12 | 0 | Sunday | 103 | TRUE | FALSE | 2020 | 4 | 1 | 1 |
| 20210404 | 2021-04-04 | 2021 | 2 | 4 | April | 13 | 4 | 0 | Sunday | 94 | TRUE | FALSE | 2021 | 4 | 1 | 1 |
| 20220417 | 2022-04-17 | 2022 | 2 | 4 | April | 15 | 17 | 0 | Sunday | 107 | TRUE | FALSE | 2022 | 4 | 1 | 1 |
| 20230409 | 2023-04-09 | 2023 | 2 | 4 | April | 14 | 9 | 0 | Sunday | 99 | TRUE | FALSE | 2023 | 4 | 1 | 1 |
| 20240331 | 2024-03-31 | 2024 | 1 | 3 | March | 13 | 31 | 0 | Sunday | 91 | TRUE | FALSE | 2024 | 3 | 1 | 1 |
| 20250420 | 2025-04-20 | 2025 | 2 | 4 | April | 16 | 20 | 0 | Sunday | 110 | TRUE | FALSE | 2025 | 4 | 1 | 1 |
The final way to handle holidays is to use a service provider. There are paid providers but Nager.Date has a free API that you can use to get holidays for a given country and year. Here’s an R function that wraps it:
library(dplyr)
library(tidyr)
library(httr)
get_holidays <- function(country_code = "lt", year = "2023") {
rs <- GET(glue::glue("https://date.nager.at/api/v3/publicholidays/{year}/{country_code}"))
out <- content(rs) %>%
tibble::enframe() %>%
select(value) %>%
unnest_wider(value) %>%
transmute(date = as.Date(date),
holiday_name_local = localName,
holiday_name = name)
return(out)
}
get_holidays(country_code = "lt", year = "2026")# A tibble: 14 × 3
date holiday_name_local holiday_name
<date> <chr> <chr>
1 2026-01-01 Naujieji metai New Year's Day
2 2026-02-16 Lietuvos valstybės atkūrimo diena The Day of Restoration o…
3 2026-03-11 Lietuvos nepriklausomybės atkūrimo diena Day of Restoration of In…
4 2026-04-05 Velykos Easter Sunday
5 2026-04-06 Antroji Velykų diena Easter Monday
6 2026-05-01 Tarptautinė darbo diena International Working Day
7 2026-06-24 Joninės, Rasos St. John's Day
8 2026-07-06 Valstybės diena Statehood Day
9 2026-08-15 Žolinė Assumption Day
10 2026-11-01 Visų šventųjų diena All Saints' Day
11 2026-11-02 Vėlinės All Souls' Day
12 2026-12-24 Šv. Kūčios Christmas Eve
13 2026-12-25 Šv. Kalėdos Christmas Day
14 2026-12-26 Šv. Kalėdos St. Stephen's Day
Of course, now you are dependent on an external provider but an external provider might make more sense if you are handling holidays across different countries. In all of the ways provided, you need someone or something to handle holiday changes.
10.3 Smart keys in the date dimension
In Kimball dimensional modelling every dimension and fact has a primary key, the date dimension is not an exception. In fact, you could use a smart key as the primary key and use 20260101 for a row with the date 2026-01-01. While this does make your warehouse consistent and let’s you create a reference to unknown values, I prefer joining directly on date columns. I feel like the added overhead of creating primary keys isn’t worth it considering that dates are stored as integers anyway and most databases handle NULL values in joins and aggregations just fine.
Full disclosure, I used R to generate the date dimension for this book.↩︎