Junk, Mini, and Role-Playing Dimensions
The specialized dimension patterns for flags nobody owns, attributes that won't sit still, and one calendar wearing three hats — built end-to-end in dbt on Snowflake.
The bridge table solved a structural problem — a genuine many-to-many that no foreign key could carry. The patterns in this chapter solve smaller, scrappier problems: a fistful of boolean flags nobody wants to model, a customer profile that changes faster than the dimension it lives in, an order with three dates all pointing at the same calendar. None of them breaks the star the way an unresolved many-to-many does. They just make it worse in slow, cumulative ways — a fact table bloated with low-cardinality columns, a dimension churning out near-duplicate rows, a BI user staring at three columns named quarter with no idea which one they grabbed.
Each pattern here is a deliberate trade — a little indirection for a lot of tidiness — and each one has a failure mode when applied without a reason. The judgement that ran through bridge tables runs through these too: reach for a pattern when the shape of your data forces the issue, not because it looks sophisticated on the architecture diagram.
Junk dimensions: where flags go to live
Every transaction fact accumulates debris. The bookshop’s orders carry is_gift_wrapped, is_expedited, is_first_order, and a channel that’s one of web, store, or phone. None of these is a measure — you don’t sum a gift-wrap flag. None deserves its own dimension — a one-column dim_gift_wrap with two rows is ceremony, not modeling. And left on the fact, each one is a low-cardinality column repeated across millions of rows, cluttering every query and inviting analysts to treat the fact table as a place to browse attributes.
The junk dimension sweeps them into one small table: every combination of the flags gets a row and a surrogate key, and the fact stores just that key. Three booleans and a three-value channel make at most 24 combinations — a 24-row dimension replacing four columns of noise. The name is honestly self-deprecating — it’s a home for junk — but the tidiness it buys is real, and so is the modeling question hiding inside it: which combinations get rows?
Two ways to populate it
There are two honest strategies, and they differ in when a combination is born.
Cross-join the domains. If every flag’s domain is known and small, build the complete combination space up front:
-- models/marts/dim_order_flags.sql
{{ config(materialized='table') }}
with gift_wrap as (
select * from (values (true), (false)) as t(is_gift_wrapped)
),
expedited as (
select * from (values (true), (false)) as t(is_expedited)
),
first_order as (
select * from (values (true), (false)) as t(is_first_order)
),
channels as (
select * from (values ('web'), ('store'), ('phone'), ('unknown')) as t(channel)
),
combos as (
select
g.is_gift_wrapped,
e.is_expedited,
f.is_first_order,
c.channel
from gift_wrap g
cross join expedited e
cross join first_order f
cross join channels c
)
select
{{ dbt_utils.generate_surrogate_key([
'is_gift_wrapped', 'is_expedited', 'is_first_order', 'channel'
]) }} as order_flags_sk,
is_gift_wrapped,
is_expedited,
is_first_order,
channel
from combos
Thirty-two rows, minted once, complete forever. Every combination the fact could ever compute already has a home, including the ones no order has produced yet. The dimension never has to race the fact — a brand-new combination arriving at 9 a.m. joins to a row that has existed since the model was first built.
Select the observed combinations. When a domain isn’t enumerable — say channel grows values you don’t control — build the dimension from what actually occurs:
with combos as (
select distinct
coalesce(is_gift_wrapped, false) as is_gift_wrapped,
coalesce(is_expedited, false) as is_expedited,
coalesce(is_first_order, false) as is_first_order,
coalesce(channel, 'unknown') as channel
from {{ ref('stg_orders') }}
)
select
{{ dbt_utils.generate_surrogate_key([
'is_gift_wrapped', 'is_expedited', 'is_first_order', 'channel'
]) }} as order_flags_sk,
*
from combos
The select distinct is the dimension — the grain is “one row per combination that occurs.” It’s compact and self-maintaining, and it has a subtlety worth understanding: the fact doesn’t ref() this dimension, so dbt’s DAG doesn’t order one before the other. That’s fine in a batch world — both models read the same staging table in the same run, so every combination the fact computes is also visible to the dimension’s select distinct, and the referential-integrity test at the end of dbt build passes. It stops being fine if the two models ever read different snapshots of the source — which is one more reason the cross-join build, with its complete domain, is the safer default when the domains allow it.
Choose by domain shape. Enumerable, low-cardinality flags — the common case — want the cross-join. Open-ended values want the observed build, plus a test that screams when cardinality grows past what a junk dimension should hold. A junk dimension with fifty thousand rows isn’t junk anymore; it’s a real dimension that deserves a name and an owner.
The fact side, and the unknown member
The fact computes the same surrogate key from its own flag columns — same hash, same column order, same coalesced defaults:
-- inside fct_order_items (or a header-grain fact)
{{ dbt_utils.generate_surrogate_key([
"coalesce(is_gift_wrapped, false)",
"coalesce(is_expedited, false)",
"coalesce(is_first_order, false)",
"coalesce(channel, 'unknown')"
]) }} as order_flags_sk
The coalesce calls are load-bearing. With hashed surrogate keys there’s no magic -1 row — the unknown member is simply the hash of the coalesced defaults, minted identically on both sides. An order with a null channel hashes to the same key the dimension minted for (false, false, false, 'unknown'), the join resolves, and no fact row ever carries a null foreign key. You still materialize that row in the dimension — that’s why 'unknown' sits in the cross-join’s channel domain — because a key that resolves to nothing is an orphan whether or not it was deterministic. The rule from the dimension chapters holds here with extra force: junk dimensions are where nulls congregate, and every null must be routed to a value before the hash on both sides, or the fact and the dimension will hash different things and quietly disagree.
Testing it
Two tests, both cheap. Uniqueness on the surrogate key proves the hash inputs form the grain, and a combination test pins the grain to the named columns so a careless edit to the hash can’t drift them apart:
models:
- name: dim_order_flags
columns:
- name: order_flags_sk
data_tests:
- unique
- not_null
data_tests:
- dbt_utils.unique_combination_of_columns:
combination_of_columns:
- is_gift_wrapped
- is_expedited
- is_first_order
- channel
The next chapter builds the full test battery for the star; the junk dimension just needs these two plus a relationships test from the fact — and it’ll pass, because the unknown member made it honest.
Mini-dimensions: for the attributes that won’t sit still
Some customer attributes are geological — name, city, signup date. Others are weather: loyalty tier, spend band, preferred format. The weather attributes can shift monthly, and if you version them with Type 2 rows, dim_customer grows a new version every time someone crosses a spend threshold — thousands of near-duplicate rows per customer, each differing in one volatile column, dragging twenty stable columns along for the ride. The SCD chapter named the cure Type 4: split the volatile attributes into a mini-dimension. This is where we actually build it — including the part that chapter deferred, the fact carrying two keys and looking up the right profile per event.
The profile dimension
The mini-dimension is a junk dimension in disguise: the distinct combinations of the banded volatile attributes, each with a surrogate key. Banding is what keeps it small — you don’t store a spend of $1,847.23, you store the band 1000-2500, because a dimension row per distinct dollar amount is just the fact table wearing a costume.
-- models/marts/dim_customer_profile.sql
{{ config(materialized='table') }}
with tiers as (
select * from (values ('bronze'), ('silver'), ('gold'), ('unknown')) as t(loyalty_tier)
),
bands as (
select * from (values
('0-100'), ('100-500'), ('500-1000'), ('1000-2500'), ('2500+'), ('unknown')
) as t(spend_band)
),
formats as (
select * from (values ('paperback'), ('hardcover'), ('ebook'), ('unknown')) as t(preferred_format)
)
select
{{ dbt_utils.generate_surrogate_key([
'loyalty_tier', 'spend_band', 'preferred_format'
]) }} as customer_profile_sk,
t.loyalty_tier,
b.spend_band,
f.preferred_format
from tiers t
cross join bands b
cross join formats f
A hundred-odd rows covering every profile any customer will ever have. Note what’s not here: no customer_id. The mini-dimension doesn’t know about customers — it’s a catalog of possible profiles, and many customers share each one. That’s the trick that stops the churn: when a customer moves from silver to gold, no dimension row is written anywhere. The row for “gold, 1000–2500, paperback” already exists. What changes is which row her next fact points at.
The two-key fact, and the profile at event time
Here’s the piece that makes Type 4 real. The fact carries two customer-shaped keys — one to the stable dimension, one to the profile that was current when the event happened:
fct_order_items
├── customer_sk → dim_customer (who, stable attributes)
└── customer_profile_sk → dim_customer_profile (what they looked like that day)
To assign customer_profile_sk at build time you need to know each customer’s profile as of each order date — which means you need profile history. The volatile columns come out of dim_customer but they don’t stop being versioned; the versioning just moves somewhere cheaper. A dbt snapshot over a slim staging model that carries only the volatile columns does the job:
# snapshots/customer_profile_snapshot.yml
snapshots:
- name: customer_profile_snapshot
relation: ref('stg_customer_profiles')
config:
unique_key: customer_id
strategy: timestamp
updated_at: updated_at
dbt_valid_to_current: "to_date('9999-12-31')"
The snapshot is narrow — customer_id, the three volatile columns, updated_at — so its Type 2 churn is cheap: new versions carry four columns, not twenty-four. Then the fact build does a windowed lookup, exactly the point-in-time join from the SCD chapter, aimed at the profile instead of the whole customer:
-- inside fct_order_items
select
-- ... grain key, dimension keys, measures as before ...
{{ dbt_utils.generate_surrogate_key([
"coalesce(p.loyalty_tier, 'unknown')",
"coalesce(p.spend_band, 'unknown')",
"coalesce(p.preferred_format, 'unknown')"
]) }} as customer_profile_sk
from order_items oi
join orders o
on oi.order_id = o.order_id
left join {{ ref('customer_profile_snapshot') }} p
on o.customer_id = p.customer_id
and o.order_date >= p.dbt_valid_from
and o.order_date < p.dbt_valid_to
Walk through what happens. An order placed on March 3rd joins to the snapshot row whose window contains March 3rd — the profile the customer had that day — and hashes its banded values into customer_profile_sk. When she crosses into gold on March 20th, the snapshot closes one window and opens another; her April orders hash to the gold profile’s key. Nothing about March’s facts changes, ever. And the left join plus coalesce handles the customer with no profile history at all — an order predating the snapshot’s first run lands on the all-'unknown' profile row, which the cross-join build already minted. The unknown member and the mini-dimension aren’t separate patterns; the second doesn’t work honestly without the first.
The query payoff is that profile history becomes sliceable without touching dim_customer at all. “Revenue by loyalty tier at time of purchase” is a one-hop join from the fact to dim_customer_profile — no point-in-time logic in the query, because the point-in-time work was done once, at load. Compare the Type 2 alternative: the same question against a versioned dim_customer needs the as-of join every single time, and a dim_customer with tier churn in it might carry fifty versions per customer to make it possible.
One refinement Kimball notes and the bookshop can skip: you can also stamp the current profile key onto dim_customer as an attribute, so “what do gold customers buy?” (today’s tier, applied to all history) doesn’t have to route through the fact. That’s convenient and mildly dangerous — it’s a Type 1 attribute pointing at a Type 4 structure, and someone will eventually confuse it with the at-event-time key. Add it when analysts actually ask the current-profile question; label it loudly.
Role-playing dimensions: one table, several hats
The accumulating snapshot from the fact-table chapters is where this pattern earns its keep. fct_orders tracks each order’s march through fulfillment — order_id, customer_sk, order_date_sk, ship_date_sk, delivery_date_sk, order_status, days_to_ship, days_to_deliver — and three of those columns are dates. You already have one beautifully built dim_date. The wrong instinct is to build three date dimensions. The right one is to let the single dim_date play three roles.
Physically there’s one calendar. Logically it appears in the model three times, once per foreign key, each appearance wearing the name of its role. Kimball calls this a role-playing dimension, and it’s the most common advanced pattern you’ll meet — facts are lousy with dates, and dates are expensive to model well, so the one good calendar gets reused everywhere.
There are two ways to express the roles, and the difference is where the renaming happens.
Physical views: rename at build time
The dbt-idiomatic move is a thin view per role:
-- models/marts/dim_date_order.sql
{{ config(materialized='view') }}
select
date_sk as order_date_sk,
date_day as order_date,
calendar_year as order_year,
calendar_quarter as order_quarter,
calendar_month as order_month,
day_name as order_day_of_week,
is_weekend as order_is_weekend
from {{ ref('dim_date') }}
dim_date_ship and dim_date_delivery are the same view with the prefix swapped. The materialization is view, so you’re not copying the calendar three times — you’re renaming its columns three ways, at zero storage cost. Each fact key joins to its own role, and the renaming is the entire point: a BI user who drags in order_quarter never wonders which of three dates they sliced by, and an unqualified quarter column that could mean any of them is a support ticket waiting to happen.
Query-time aliasing: rename in the join
The alternative keeps one physical dim_date and aliases it per query:
select
d_order.year as order_year,
d_order.month_name as order_month,
avg(f.days_to_deliver) as avg_days_to_deliver
from fct_orders f
join dim_date d_order on f.order_date_sk = d_order.date_sk
join dim_date d_delivery on f.delivery_date_sk = d_delivery.date_sk
where d_delivery.is_weekend
group by 1, 2
Same table, two aliases, two roles — “orders that delivered on a weekend, by order month,” a question that needs both roles in one query and answers it in ten lines. For anyone writing SQL by hand this is strictly fine; the aliasing is explicit and the intent is readable.
Which one, and why the BI layer decides
The honest answer is that the choice is rarely about SQL — it’s about what sits on top of it.
The views approach makes every tool happy at the cost of catalog clutter. Each role is a first-class object with unambiguous column names; drag-and-drop tools, auto-generated data catalogs, and semantic layers all see three cleanly named date tables and never need to understand aliasing. The cost is 3× the objects — a warehouse with a dozen multi-date facts sprouts dozens of near-identical calendar views, and someone eventually asks why.
The aliasing approach keeps the catalog minimal but pushes the role logic into every consumer, and consumers vary wildly in how gracefully they take it. Power BI’s model allows only one active relationship between two tables — a second date key relates to the same calendar through an inactive relationship you must invoke per-measure with USERELATIONSHIP(), or you import the date table multiple times, which is the views approach reinvented inside the tool. Looker handles roles natively: the same LookML view joins into an explore several times under different names via from: aliasing, which is query-time aliasing formalized. Hand-written SQL and dbt models alias freely. So the real question is: who consumes the star? A drag-and-drop BI population wants the views; a SQL-fluent team with a semantic layer can skip them. (Tool behaviors here are stable and well documented, but they do evolve — check your BI tool’s current story before committing the pattern.)
One more accumulating-snapshot interaction: delivery_date_sk is unknowable for an order that hasn’t delivered. It must not be null — the never-null rule for fact keys doesn’t bend — so it points at the calendar’s designated not-yet member (the “hasn’t happened” date row the date-dimension build set aside) until the milestone lands and the merge updates it. Role-playing views inherit that member automatically, since they’re just renamings; queries through dim_date_delivery see undelivered orders grouped under the not-yet row rather than silently dropped by an inner join.
Outriggers: the join you allow yourself sparingly
Occasionally a dimension wants to reference another dimension. dim_store might point at a dim_region carrying demographic rollups — population, median income, market classification — that you’d rather not copy onto every store row. That second hop, a dimension hanging off a dimension, is an outrigger:
fct_order_items ──> dim_store ──> dim_region
It works, and Kimball permits it, with a raised eyebrow. The permitted case has a specific shape: the outrigger is a genuinely separate thing, with its own grain, its own source, its own refresh cadence — census demographics updated yearly have no business being maintained inside a store dimension refreshed nightly. The classic sanctioned example is a date outrigger: dim_customer carrying a first_order_date_sk that points at dim_date, so “customers who first ordered in Q4” gets the full calendar for free instead of re-deriving fiscal quarters on a raw date column.
The eyebrow exists because every outrigger is a snowflake in miniature, and snowflakes metastasize. Region points at country, country points at economic zone, someone normalizes genre out of dim_book because an outrigger worked last time — and three refactors later your star is the snowflake schema that chapter spent its length warning you about, a two-hop join everywhere the star promised one. The test before adding one: is this actually its own well-defined thing with its own maintenance story, or am I saving a few redundant columns? Redundant columns are what dimensions are made of. Save the outrigger for the census data.
Value-band and behavioral dimensions
Two related patterns hide inside the mini-dimension build above, and each deserves its own name.
A value-band dimension turns banding itself into data. The dimension chapter hard-coded price_band as a case expression inside dim_book — fine, until the business wants to re-band. Bands baked into a case statement change only when an engineer edits a model; bands stored as rows change when someone edits a table:
-- models/marts/dim_order_size_band.sql
{{ config(materialized='table') }}
select * from (values
('small', 0::number(10,2), 25::number(10,2)),
('medium', 25::number(10,2), 100::number(10,2)),
('large', 100::number(10,2), 100000::number(10,2))
) as t(band_name, min_amount, max_amount)
Queries attach the band with a range join — on f.extended_price >= b.min_amount and f.extended_price < b.max_amount — so re-banding is an update to three rows, and no fact is touched. The trade: a non-equi join, which the warehouse handles fine at dashboard scale but which no BI drag-and-drop understands natively. Most teams band at build time for the common bands (the case in the dimension) and keep a band table for the ones the business fiddles with. Both are value-banding; the difference is who’s allowed to change the definition, and how fast.
A behavioral dimension is a dimension attribute computed from the facts. The spend_band in dim_customer_profile doesn’t come from any source system — it comes from aggregating fct_order_items, banding the result, and feeding it back into a dimension. That loop — facts → aggregation → banded attribute → dimension → back into new facts — is how RFM segments, “lapsed customer” flags, and lifetime-value tiers get built, and it has one rule: compute it at load time on a declared cadence, never at query time. A behavioral attribute recomputed inside every query is a different number in every dashboard; one stamped into the mini-dimension nightly is a fact about the customer as-of that night, versioned like anything else. The mini-dimension is the natural home precisely because behavioral attributes are the fastest-changing attributes a customer has.
Supertype and subtype: heterogeneous products
The last pattern arrives with success. The bookshop starts selling e-books and audiobooks, and suddenly dim_book has a problem: physical books have a format and a page_count, e-books have a file_size_mb and a DRM flag, audiobooks have a narrator and a duration_minutes. One wide table with every column means every row is mostly null, and the nulls carry meaning (“not applicable”) that null does a bad job of carrying.
The Kimball answer is a supertype/subtype split. One core dimension carries the attributes every product shares — and every fact key points at it:
dim_product (supertype) dim_product_ebook (subtype)
product_sk ◄─────────────────── product_sk (same key, 1:1)
product_id file_size_mb
title drm_scheme
author
genre
list_price
product_type ── 'ebook' dim_product_audiobook (subtype)
product_sk
narrator
duration_minutes
The subtype tables share the supertype’s surrogate key one-to-one — no new key to mint, no bridge, just an optional extension per type. Cross-catalog queries (“revenue by genre”) touch only the supertype and never see a null. Type-specific queries (“audiobook hours sold by narrator”) join the subtype in, and the product_type column tells any query which extension applies. Facts stay uniform: one product_sk, whatever the product is.
Honest guidance on when to bother: later than you think. Three or four type-specific columns per subtype don’t justify the split — take the wide table, take the nulls, keep the model one-hop. The split pays when subtypes have many attributes each, or different teams own them, or the heterogeneity extends to the facts themselves — a lending library’s loan events and a shop’s sale events want different fact tables entirely, each pointing at the shared product supertype. That’s the full heterogeneous-products pattern: shared core dimension, custom facts per line of business, and the supertype is what makes cross-business questions answerable at all.
Final thoughts
None of these patterns is a default, and all of them are the same pattern if you squint: put the attribute where its rate of change and its cardinality say it belongs. Fast-changing banded attributes leave the big dimension for a mini-dimension. Correlated low-cardinality flags leave the fact for a junk dimension. A calendar used three ways stays one table wearing three names. Attributes shared by every product type stay in the supertype; the rest move out. The star doesn’t get more complicated when you apply these well — it gets more honest, because each table’s grain and churn finally match its contents. The skill isn’t knowing the catalog; it’s hearing the specific creak in your model — the churning dimension, the flag-studded fact, the ambiguous quarter — and reaching for the one pattern that answers it. A modeler who deploys a mini-dimension because the dimension might churn someday is gold-plating. One who deploys it because dim_customer grew forty versions per customer in a quarter is doing the job.
Comments