offdataai
Dimensional ModelingSQLGuide

How to Build a Date Dimension (dim_date) in SQL, Plus Role-Playing Dates

A complete guide to the date dimension: which columns to include, generating dim_date in SQL for Snowflake, BigQuery, Postgres and Databricks, fiscal calendars, and role-playing dates.

ooffdataai8 min read

Almost every fact table has at least one date, and almost every report filters or groups by some part of it: month, quarter, week, fiscal period, business day, holiday. The date dimension (dim_date) is a table with one row per calendar day and a column for every way the business describes a day. It is the most reused table in a dimensional model and usually the first one you should build.

This guide covers what to put in it, how to generate it with SQL on the main warehouses, how to handle fiscal calendars and holidays, and how one date dimension plays several roles on the same fact.

Why not just use date functions?

You can compute extract(month from order_date) in every query. The problems show up quickly:

  • Business rules are not functions. No SQL function knows your fiscal year starts in February, that 26 December is a bank holiday in the UK but not the US, or that the retail calendar uses 4-5-4 weeks.
  • Consistency. Ten analysts will write ten slightly different expressions for "week". Some will use ISO weeks, some will start on Sunday.
  • Usability. BI tools and semantic layers work best with named attributes (month_name, fiscal_quarter) they can offer as dimensions.
  • Sorting and labels. month_name needs a matching month_number for correct ordering. A dimension makes that pairing explicit.

Choosing the key

The conventional key is an integer in YYYYMMDD form: 20261103 for 3 November 2026.

  • It is readable, so debugging fact tables is easier.
  • It sorts correctly.
  • It is a deliberate exception to the rule that surrogate keys should be meaningless. Dates are stable; there is no risk of the "natural key" changing.

Many teams on modern warehouses instead use the date value itself as the key. That is also fine, and it removes a conversion step. Pick one and use it everywhere.

Include special rows for unknown and not-yet-occurred dates, for example date_key = -1 ("Unknown") and date_key = 99991231 ("Not yet"). Facts with a missing date or an unreached milestone then still join with an inner join.

Columns to include

GroupColumns
Key and valuedate_key, full_date
Dayday_of_week_number, day_name, day_name_short, day_of_month, day_of_year
Weekiso_week_number, iso_year, week_start_date, week_end_date
Monthmonth_number, month_name, month_name_short, year_month (e.g. 2026-11), month_start_date, month_end_date, is_month_end
Quarter and yearquarter_number, quarter_name (Q4), year_quarter (2026-Q4), year_number
Fiscalfiscal_year, fiscal_quarter, fiscal_month_number, fiscal_period_label
Flagsis_weekend, is_weekday, is_holiday, holiday_name, is_business_day
Relative (optional)days_from_today, is_current_month (only if rebuilt daily)

Keep relative columns out unless you rebuild the table every day. A stale is_current_month is worse than none.

Generating dim_date in SQL

All of these generate a row per day from 2015 to 2035. Adjust the range to cover your oldest data and a few years ahead.

Postgres

sql
create table dim_date asselect    to_char(d, 'YYYYMMDD')::int                as date_key,    d::date                                    as full_date,    extract(isodow from d)::int                as day_of_week_number,  -- 1 = Monday    to_char(d, 'FMDay')                        as day_name,    to_char(d, 'Dy')                           as day_name_short,    extract(day from d)::int                   as day_of_month,    extract(doy from d)::int                   as day_of_year,    extract(week from d)::int                  as iso_week_number,    extract(isoyear from d)::int               as iso_year,    date_trunc('week', d)::date                as week_start_date,    (date_trunc('week', d) + interval '6 day')::date as week_end_date,    extract(month from d)::int                 as month_number,    to_char(d, 'FMMonth')                      as month_name,    to_char(d, 'Mon')                          as month_name_short,    to_char(d, 'YYYY-MM')                      as year_month,    date_trunc('month', d)::date               as month_start_date,    (date_trunc('month', d) + interval '1 month - 1 day')::date as month_end_date,    extract(quarter from d)::int               as quarter_number,    'Q' || extract(quarter from d)             as quarter_name,    extract(year from d)::int                  as year_number,    extract(isodow from d) in (6, 7)           as is_weekendfrom generate_series(date '2015-01-01', date '2035-12-31', interval '1 day') as g(d);

Snowflake

sql
create or replace table dim_date aswith days as (    select dateadd(day, seq4(), '2015-01-01'::date) as d    from table(generator(rowcount => 7700)))select    to_number(to_char(d, 'YYYYMMDD'))  as date_key,    d                                  as full_date,    dayofweekiso(d)                    as day_of_week_number,    dayname(d)                         as day_name_short,    day(d)                             as day_of_month,    dayofyear(d)                       as day_of_year,    weekiso(d)                         as iso_week_number,    yearofweekiso(d)                   as iso_year,    date_trunc('week', d)              as week_start_date,    month(d)                           as month_number,    monthname(d)                       as month_name_short,    to_char(d, 'YYYY-MM')              as year_month,    date_trunc('month', d)             as month_start_date,    last_day(d)                        as month_end_date,    d = last_day(d)                    as is_month_end,    quarter(d)                         as quarter_number,    year(d)                            as year_number,    dayofweekiso(d) in (6, 7)          as is_weekendfrom dayswhere d <= '2035-12-31';

Note that seq4() can have gaps in some circumstances; row_number() over (order by seq4()) - 1 is safer if you need a strictly contiguous series. Also check the WEEK_START session parameter, which affects date_trunc('week', ...).

BigQuery

sql
create or replace table analytics.dim_date asselect    cast(format_date('%Y%m%d', d) as int64)      as date_key,    d                                            as full_date,    extract(dayofweek from d)                    as day_of_week_number, -- 1 = Sunday    format_date('%A', d)                         as day_name,    extract(day from d)                          as day_of_month,    extract(dayofyear from d)                    as day_of_year,    extract(isoweek from d)                      as iso_week_number,    extract(isoyear from d)                      as iso_year,    date_trunc(d, isoweek)                       as week_start_date,    extract(month from d)                        as month_number,    format_date('%B', d)                         as month_name,    format_date('%Y-%m', d)                      as year_month,    date_trunc(d, month)                         as month_start_date,    last_day(d, month)                           as month_end_date,    extract(quarter from d)                      as quarter_number,    extract(year from d)                         as year_number,    extract(dayofweek from d) in (1, 7)          as is_weekendfrom unnest(generate_date_array('2015-01-01', '2035-12-31')) as d;

BigQuery numbers Sunday as 1. If you want ISO numbering, compute mod(extract(dayofweek from d) + 5, 7) + 1.

Databricks

sql
create or replace table dim_date asselect    cast(date_format(d, 'yyyyMMdd') as int)  as date_key,    d                                        as full_date,    extract(dayofweek from d)                as day_of_week_number, -- 1 = Sunday    date_format(d, 'EEEE')                   as day_name,    dayofmonth(d)                            as day_of_month,    dayofyear(d)                             as day_of_year,    weekofyear(d)                            as iso_week_number,    date_trunc('week', d)                    as week_start_date,    month(d)                                 as month_number,    date_format(d, 'MMMM')                   as month_name,    date_format(d, 'yyyy-MM')                as year_month,    trunc(d, 'month')                        as month_start_date,    last_day(d)                              as month_end_date,    quarter(d)                               as quarter_number,    year(d)                                  as year_number,    extract(dayofweek from d) in (1, 7)      as is_weekendfrom (select explode(sequence(date'2015-01-01', date'2035-12-31', interval 1 day)) as d);

Fiscal calendars

If the fiscal year starts on 1 February and is named for the calendar year in which it ends, fiscal attributes are simple date arithmetic:

sql
-- Postgres; FY2027 runs 2026-02-01 to 2027-01-31select    full_date,    extract(year from full_date + interval '11 month')::int          as fiscal_year,    ((extract(month from full_date)::int + 10) % 12) + 1             as fiscal_month_number,    ceil((((extract(month from full_date)::int + 10) % 12) + 1) / 3.0)::int as fiscal_quarterfrom dim_date;

Retail 4-4-5 or 4-5-4 calendars, and 53-week years, do not reduce to a formula reliably. Generate them once from the official finance calendar and store them in a seed table that you join into dim_date. Finance owns that table; the data team should not reinvent it.

Holidays and business days

Holidays depend on country, region and sometimes company policy. Keep them in a separate small table:

sql
create table ref_holiday (    holiday_date   date not null,    country_code   char(2) not null,    holiday_name   varchar(100) not null,    primary key (holiday_date, country_code));

If you operate in one country, flatten is_holiday and holiday_name onto dim_date. If you operate in many, keep the reference table and either add a column per major country (is_holiday_us, is_holiday_gb) or join through a calendar-by-country bridge. A banking model calculating settlement days, for example, needs the calendar of the settlement currency, not the customer's location.

Then is_business_day = not is_weekend and not is_holiday. A useful extra is business_day_of_month, which supports reports like "sales on the third working day".

Role-playing dates

An accumulating snapshot for order fulfilment has several dates: placed, paid, shipped, delivered. Each is a foreign key to the same dim_date. That is a role-playing dimension: one physical table playing several logical roles.

sql
create table fct_order_fulfilment (    order_id            varchar(40) primary key,    placed_date_key     integer not null references dim_date (date_key),    shipped_date_key    integer not null references dim_date (date_key),    delivered_date_key  integer not null references dim_date (date_key),    order_amount        numeric(12,2) not null);

To query by more than one role, join the dimension once per role with distinct aliases:

sql
select    placed.year_month    as placed_month,    shipped.year_month   as shipped_month,    count(*)             as ordersfrom fct_order_fulfilment fjoin dim_date placed  on placed.date_key  = f.placed_date_keyjoin dim_date shipped on shipped.date_key = f.shipped_date_keygroup by 1, 2;

For BI users, create one view per role with renamed columns, so a field list shows "Shipped Month" rather than an ambiguous "Month":

sql
create view dim_ship_date asselect    date_key   as ship_date_key,    full_date  as ship_date,    year_month as ship_year_month,    is_business_day as ship_is_business_dayfrom dim_date;

The same pattern applies in healthcare (admission date, discharge date), insurance (policy start, claim incurred, claim reported) and banking (trade date, settlement date).

Common mistakes

Joining on timestamps. A fact with ordered_at timestamp joined directly to full_date silently drops every row that is not exactly midnight. Convert to a date key when loading the fact.

Time zones. "The day an order happened" depends on whose day. Decide whether the date key reflects UTC, the store's local time or the customer's local time, and apply it consistently. For global businesses it is common to store both a UTC date key and a local date key.

Mixing week definitions. ISO weeks start on Monday and week 1 contains the first Thursday of the year. US weeks often start on Sunday. Week 53 exists in some ISO years. Pick a definition, name the columns accordingly (iso_week_number), and pair week numbers with the right year (iso_year, not year_number).

Putting time of day in dim_date. A row per minute per day makes a huge table. If you need time-of-day analysis, use a separate dim_time_of_day with one row per minute or second.

Not covering the future. Accumulating snapshots, forecasts and contract end dates need future dates. Extend the range well past today.

For how dates fit into the wider design, read what is grain in data modeling and fact vs dimension tables.

Build one in offdataai Learn

The Dimensional Modeling (Kimball) track in offdataai Learn includes a date dimension exercise and a role-playing dates exercise: you build dim_date and wire it to a multi-milestone fact in the Model Builder, and an instant check confirms the key, attributes and role-playing relationships. It is hands-on and included in the Explorer plan at $1/month.

You can also generate DDL for your warehouse from any of the templates, all of which include a date dimension.

Try offdataai

Ship a warehouse from a single conversation.

Describe your domain. Get ERDs, DDL, dbt projects, and seed data for every major cloud warehouse.

Start building

Keep reading

All posts →