ModelingGENERALWAREHOUSE-SPECIFICORG-SPECIFIC

Dimension Tables

The descriptive context you filter and group by — customer, product, date, region — and the reason a calendar deserves a table of its own.

Who needs this, what one row is, and why the obvious build breaks

Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.

The question

Where should the attributes people filter and group by live, and why not simply on the fact table?

Who needs this

Anyone building a filter, a breakdown or a drop-down. A BI tool browses dimensions to offer the fields a user can slice by, so the dimension table is literally the vocabulary the business is allowed to use when asking a question.

What one row is

One row is one entity, or one version of one entity — one customer, one product, one calendar day, one region. Which of the two it is depends entirely on the history policy, and that ambiguity is the single most consequential thing about dimension design (Slowly Changing Dimensions).

The obvious build

Copy the source entity tables across, keep their primary keys, join facts to them on those keys. It works immediately, the joins are obvious, and the model looks like a star.

Why it breaks

A customer moves from Poland to Germany. The dimension is overwritten, and every historical report about Polish revenue silently reassigns that customer's entire order history to Germany. Last quarter's signed-off numbers change (SCD Type 2 in Practice).

How it breaks with real data
  • A customer moves from Poland to Germany. The dimension is overwritten, and every historical report about Polish revenue silently reassigns that customer's entire order history to Germany. Last quarter's signed-off numbers change (SCD Type 2 in Practice).
  • A product's category is corrected from Accessories to Peripherals. Every past month shifts, and the year-on-year comparison in the board deck no longer matches the one shown last month.
  • The dimension load runs after the fact load. For a few hours, new fact rows reference keys that do not exist yet, and every inner join drops them — a completeness bug that resolves itself before anyone can reproduce it (Missing Rows).
  • A re-run inserts a second row for one customer instead of updating. Every fact joined to that customer is now duplicated, and revenue rises for reasons unrelated to sales (Grain: What Does One Row Represent?).
  • Someone needs revenue for weeks with no sales in a category. There are no fact rows for those weeks, so the report simply omits them, and a flat line looks like an absent one (Fact Tables).
SourceIngestionRawTransformationValidationStorage ModelServingConsumerObservability

What is actually happening

  • A dimension holds the descriptive, low-cardinality-per-row context that facts are grouped and filtered by. It is typically wide and short: many columns, comparatively few rows. Facts are narrow and long. That asymmetry is what makes the star schema efficient — the big table stays skinny and the descriptive text lives once, in a table small enough to broadcast (Broadcast Joins).
  • Dimensions are joined many-to-one from the fact. That relationship is what preserves the fact's grain, and it holds only while the dimension key is genuinely unique. A dimension with a duplicate key silently turns every star join into a fan-out (Grain: What Does One Row Represent?).
  • A date dimension is a table of one row per calendar day, pre-computed with every attribute anyone might group by: month, quarter, ISO week, fiscal period, day of week, holiday flag, business-day flag. It exists because date arithmetic in SQL is dialect-specific, because fiscal calendars do not follow the Gregorian one, and because a table of dates is the only way to get rows for days on which nothing happened (Snapshot Tables).
  • A conformed dimension is one shared by several facts with identical keys and meaning, which is what makes cross-process questions possible — orders and support tickets and marketing touches, all sliced by the same customer. Conforming dimensions is mostly an organisational act, not a technical one (Data Ownership).
  • Degenerate dimensions are identifiers with no attributes — order_id, invoice_number — that stay on the fact rather than getting a table of their own. Junk dimensions collect several low-cardinality flags into one small table to keep the fact narrow. Both exist to stop the fact table growing columns.

What a dimension carries, and what stays on the fact

The split is mechanical once stated: numbers that measure the process go on the fact; words that describe the context go in a dimension; identifiers with no attributes stay on the fact as degenerate dimensions. Anything that does not fit one of those three is usually a design question you have not finished answering.

The reason to hold the line is restatement. A country stored on the fact is a value frozen at load time and repeated on every row — correcting it means rewriting history, and there is no single place to look up what it currently is. The same country in a dimension is one row, correctable in one statement, with an explicit policy about whether the correction applies to the past.

The second reason is width. Analytical scans read columns, so every descriptive column on the fact is bytes that a query touching it must read across the whole table, and bytes on disk for every row whether or not anyone reads them (Projection Pushdown).

Three dimensions around one fact
1-- Descriptive context, versioned. One row per customer VERSION.
2CREATE TABLE dim_customer (
3 customer_key BIGINT PRIMARY KEY, -- surrogate: identifies a VERSION
4 customer_id VARCHAR NOT NULL, -- natural key from the source
5 name VARCHAR,
6 country VARCHAR, -- SCD2: versioned, history preserved
7 segment VARCHAR, -- SCD2: versioned
8 email VARCHAR, -- SCD1: overwritten, no history kept
9 valid_from TIMESTAMP NOT NULL,
10 valid_to TIMESTAMP, -- NULL for the current version
11 is_current BOOLEAN NOT NULL
12);
13-- grain: one row per (customer_id, valid_from). NOT one row per customer.
14
15-- Descriptive context, not versioned. One row per product.
16CREATE TABLE dim_product (
17 product_key BIGINT PRIMARY KEY,
18 product_id VARCHAR NOT NULL,
19 name VARCHAR,
20 category VARCHAR, -- flattened from the source hierarchy
21 subcategory VARCHAR, -- snowflaking would split these out
22 brand VARCHAR
23);
24
25-- The calendar, as data. One row per day, generated once, for decades.
26CREATE TABLE dim_date (
27 date_key INT PRIMARY KEY, -- 20260826, sorts and partitions well
28 date DATE NOT NULL,
29 year INT, quarter INT, month INT, day_of_month INT,
30 year_month VARCHAR, -- '2026-08': groupable without date maths
31 iso_week INT, day_of_week INT, day_name VARCHAR,
32 is_weekend BOOLEAN,
33 is_holiday BOOLEAN, -- per market; often one column per market
34 fiscal_year INT, fiscal_quarter INT, -- does not follow the calendar
35 is_business_day BOOLEAN
36);
37
38-- Reserved rows, inserted once, so nothing is ever silently dropped:
39INSERT INTO dim_customer (customer_key, customer_id, name, country, is_current, valid_from)
40VALUES (-1, 'UNKNOWN', 'Unknown', 'Unknown', TRUE, '1900-01-01');

Two things to notice. dim_customer mixes SCD1 and SCD2 columns in one table, which is the normal case — history is decided per attribute. And the unknown member exists so that a fact arriving before its dimension row can still be loaded and counted, as a visible Unknown category rather than as a row that an inner join deleted (Slowly Changing Dimensions).

Why a calendar deserves a table

GENERALEvery row here is engine-independent — the same failures appear in a warehouse, a lakehouse and a hand-rolled Parquet model. What differs is detection latency: platforms with enforced tests in the transformation layer catch the first two at load time, while a hand-written pipeline catches them from a dashboard.

A date dimension looks like the most redundant object in a warehouse. Every engine has date functions; why store the year of 2026-08-26 when EXTRACT will compute it? The answer has four parts and each one is a real reporting failure that a calendar table prevents.

First, fiscal calendars do not follow the Gregorian one. A company whose fiscal year starts in February, or which uses 4-4-5 retail periods, cannot express its own reporting periods with any built-in function, and every analyst reimplements the rule slightly differently.

Second, absence. A fact table is sparse: no row exists for a day with no sales. GROUP BY date therefore returns no row for that day, and a chart shows a gap that looks like missing data rather than a zero. Only a table with a row per day, outer-joined to the fact, can produce the zero.

Third, holidays and business days are business facts, not calendar arithmetic, and they differ per market. Fourth, date functions are the most dialect-divergent part of SQL, so a calendar table is also portability. The whole thing is a few thousand rows for a century of coverage.

Dimension failures, and what each looks like from the dashboard
TriggerSymptomCauseResponse
A re-run inserts instead of upserting, leaving two rows for one customer.Revenue rises for that customer's cohort with no matching change in orders. The rise is exactly a factor of two for the affected subset.The dimension key is no longer unique, so the many-to-one fact join became many-to-many and every matching fact row was duplicated (Grain: What Does One Row Represent?).Uniqueness test on the dimension key, blocking publication. Then rebuild every fact partition loaded while the duplicate existed — lineage tells you which (Lineage Debugging).
The dimension load fails; the fact load runs anyway.Yesterday's new customers are missing from every breakdown, and the totals are slightly low in a way that resolves itself tomorrow.Fact rows referenced keys that did not exist, and an inner join dropped them silently.Order the DAG so dimensions load first, fail the fact load on unresolved keys, and route genuinely unknown context to the unknown member instead of dropping it (Task Dependencies).
A category is renamed in the source and the dimension is overwritten.Last quarter's numbers changed since the last time anyone looked, and no pipeline failed.A Type 1 overwrite applies retroactively to all history, because history joins to the current dimension row (Slowly Changing Dimensions).Decide per attribute whether a change is a correction (overwrite) or a real-world change (version). Renames of a taxonomy are almost always the second (SCD Type 2 in Practice).
A source migration renumbers customer ids.Half the fact rows land on the unknown member overnight; the other half are attributed to the wrong customers.Facts were joined on the natural key, so the key space moved underneath them.Surrogate keys plus a mapping from old to new natural key, applied during the migration (Surrogate Keys).
Two teams each maintain a customer dimension.Orders and support tickets cannot be joined, and any attempt produces a partial intersection that looks like a real answer.The dimensions are not conformed: different key spaces, and often different definitions of what counts as a customer.One owned, conformed dimension with a documented key strategy. This is a governance decision before it is a technical one (Data Ownership).
Without a date dimension, a sparse fact:

  SELECT order_date, SUM(revenue) FROM fct_orders GROUP BY 1

    2026-03-09   1200.00
    2026-03-10    980.00
    2026-03-13   1450.00      <- the 11th and 12th are simply ABSENT
    2026-03-14   1100.00         the chart draws a straight line through them

With a date dimension, LEFT JOINed from the calendar:

  SELECT d.date, COALESCE(SUM(f.revenue), 0)
  FROM   dim_date d
  LEFT   JOIN fct_orders f ON f.date_key = d.date_key
  WHERE  d.date BETWEEN '2026-03-09' AND '2026-03-14'
  GROUP  BY 1

    2026-03-09   1200.00
    2026-03-10    980.00
    2026-03-11      0.00      <- a real zero, visible as an outage or a holiday
    2026-03-12      0.00
    2026-03-13   1450.00
    2026-03-14   1100.00

Same data. One version hides a two-day gap; the other makes it the
most obvious thing on the chart.

Fiscal periods no built-in function can produce:

  date        year_month  fiscal_year  fiscal_quarter  is_business_day
  2026-02-01  2026-02     FY2027       Q1              TRUE
  2026-01-31  2026-01     FY2026       Q4              FALSE

A dimension has a grain too

SIMPLIFIEDMini-dimensions, outriggers and multi-valued attributes handled with weighting factors are all real constructs this table leaves out; each of them changes the grain again, and each needs its own declared sentence rather than being inferred from the columns.

Facts get the attention when grain is discussed, and dimensions quietly have the same property. "One row is one customer" and "one row is one customer version" are different tables with the same name, the same columns and completely different behaviour on every join and every distinct count.

The consequence people hit first is counting. COUNT(DISTINCT customer_key) means "customers" in an unversioned dimension and "customer versions" in a versioned one, and the metric drifts upward over time in a way that looks exactly like growth. The consequence they hit second is fan-out: joining on the natural key matches every version, and a customer who changed address three times contributes three copies of every order they placed.

The table below is the dimension-side companion to the fact-side grain table. Every row is a legitimate dimension design and each one changes what a join and a count mean. The point is not that one is right — it is that the sentence has to be written down, on the dimension, in the same place a fact declares its own.

What one row of a dimension represents, by design
StageOne row isBreaks if
Unversioned dimension (Type 1 throughout)One entity, holding its current attribute values.A consumer expects history. Every past-period query is answered with today's attributes, silently, and closed periods change when an attribute does (Slowly Changing Dimensions).
Versioned dimension (any Type 2 column)One entity version over one validity interval, keyed by (natural_key, valid_from).Anything counts distinct surrogate keys and calls the result entities, or joins on the natural key and matches every version at once (SCD Type 2 in Practice).
The `is_current = TRUE` view over itOne entity again, restoring the pre-versioning shape for consumers who only want the present.A half-completed merge leaves an entity with zero or two current rows, in which case the view either loses the entity or duplicates every fact joined through it.
Date dimensionOne calendar day, whether or not anything happened on it.It is joined to a sparse fact with an inner join, which removes exactly the zero-activity days the table existed to provide (Fact Tables).
Junk dimensionOne distinct combination of several low-cardinality flags, not one entity at all.Someone counts its rows expecting a population. The row count is the number of flag combinations observed and means nothing about the business.
Bridge table (many-to-many)One pairing — one product in one of its several categories.It is joined without allocation weights. The fan-out is deliberate here and every measure through it double counts unless the query says how to divide it (Grain: What Does One Row Represent?).

Six dimension shapes, six different meanings of "one row". Only the first two are usually written down, and the junk and bridge rows are where a confident count comes back meaning something nobody asked for.

How to build it

Most important first.

  • Give every dimension a surrogate key and join facts on that, never on the source id. It is what makes versioning possible and what absorbs source-key churn (Surrogate Keys).
  • Decide history per attribute. Most columns can be overwritten; the small number that appear in historical reports need versioning, and deciding that per column rather than per table keeps the dimension from doubling in size for no reason (Slowly Changing Dimensions).
  • Build a date dimension on day one, covering more years than you expect to need, and make every fact carry a date_key into it. It is the cheapest table in the warehouse and it removes an entire category of date-arithmetic bug (Window Functions).
  • Load dimensions before facts within a run, and make the fact load fail rather than proceed if a key does not resolve. Silent key loss is the failure mode you least want to be quiet.
  • Include an unknown member row — a reserved key such as -1 with descriptive values of Unknown — so facts with missing or late context can be loaded without being dropped by an inner join, and so the gap is visible as a category rather than as absence.
  • Keep descriptive text in the dimension and out of the fact. A customer_country column on the fact is a value frozen at load time that you can never restate (Analytical Data Modeling).

What this actually promises

Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.

  • A dimension guarantees uniqueness on its key only if you test it. Analytical engines generally accept a primary-key declaration without enforcing it, so uniqueness is a property of your pipeline and not of your schema (Database Constraints).
  • A conformed dimension guarantees that two facts joined to it mean the same thing by "customer". That guarantee is organisational — it survives only as long as both producers keep using the same key space.
  • A date dimension guarantees a row exists for every day in range, which is what makes zero-activity periods expressible. Nothing else in the model provides that.
  • A dimension without versioning explicitly guarantees only current-state answers. It will answer a historical question anyway, with today's attribute values and no indication that it did so.

Can I trust it?

A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.

The check that would catch this
  • Test uniqueness on the dimension's key on every load. A duplicate key here is not a dimension problem — it is a fan-out in every fact joined to it, which is a far larger blast radius than the table itself (Data Tests).
  • Test referential integrity from the other side: every dimension key referenced by a fact must exist. Track the unresolved rate over time rather than only alerting on zero, because a slow rise means an id space is drifting (The Dimensions of Data Quality).
  • Test that the date dimension has no gaps and covers the full range of fact dates, which is a one-line check that prevents a whole family of reporting holes.
  • What these miss: attribute correctness. A dimension where every key is unique, every reference resolves and every date exists can still carry a country value that is stale, mis-mapped or silently defaulted, and no structural test will see it (Distribution Tests).
Freshness
  • Dimensions and facts have different natural freshness. Facts arrive as events occur; dimensions change rarely and are usually rebuilt on a schedule. The mismatch is what produces late-arriving dimension rows (Late-Arriving Data).
  • A fact that arrives before its dimension context is a real and common situation — a new customer's first order. Handle it with an unknown member plus a later update, or by holding the fact, and say which one you chose (Late-Arriving Data).
  • Dimension attributes are frozen at whatever the last dimension load saw. A daily-refreshed segment column answers today's questions with yesterday's segments, correctly and invisibly.
When the schema or meaning changes
  • Adding an attribute is safe for existing queries and raises one real question: what is the value for history? Null, backfilled, or a sentinel — pick deliberately, because consumers will read null as zero (Nullability & Defaults).
  • Changing an attribute's *values* — a category rename, a segment re-definition — is the change with no schema signature and full historical impact, and it is the reason SCD types exist as a decision rather than a default (Semantic Changes).
  • Adding versioning to a dimension that never had it changes its grain from one-row-per-entity to one-row-per-version. Every existing join needs a predicate it currently lacks, and that is a breaking change dressed as an improvement (SCD Type 2 in Practice).
  • Source-key changes during a system migration are absorbed by surrogate keys and are catastrophic without them (Surrogate Keys).
How to re-run this safely
  • Dimensions rebuilt from retained source snapshots recover their *current* state fully. They do not recover versions that were never captured — if the source overwrote a value and no change history was retained, that version is gone from every system (Keeping Raw History: The Recovery Position and the Liability).
  • A duplicate-key incident is repaired by fixing the dimension and rebuilding every fact partition loaded while the duplicate existed. Finding those partitions is a lineage question, not a guess (Lineage Debugging).
  • Rebuilding a dimension in place while facts are being loaded against it produces a window where keys do not resolve. Build to a new table and swap (Atomic Publish).

What can go wrong

Failure modes
  • A duplicate row for one business key, fanning out every fact joined to it.
  • A dimension load that ran after the fact load, so an inner join silently dropped the newest rows (Missing Rows).
  • An attribute overwritten in place, changing signed-off historical reports with no audit trail (SCD Type 2 in Practice).
  • Two facts joined to two different customer dimensions with different key spaces, so a cross-process question is quietly answered on a partial intersection.
  • The mitigation failing: an unknown member that absorbs every unresolved key so smoothly that nobody notices the unresolved rate climbing for a month.
Misreads
  • "Dimensions are just lookup tables." A lookup table maps a code to a label. A dimension carries a history policy, a key strategy, an unknown member and a conformance agreement, and every one of those is a decision with downstream consequences.
  • "We can use the source id as the dimension key." It works until the first migration renumbers it, or until the first versioned attribute makes it non-unique (Surrogate Keys).
  • "A date dimension is unnecessary — dates are a built-in type." Built-in date functions cannot express your fiscal calendar, your holiday list, or a row for a day on which nothing happened.
  • "The dimension is small, so it does not matter." It is joined by every query in the model. A defect in a table of ten thousand rows propagates into every number the platform produces (Grain: What Does One Row Represent?).

Operating it

How you see it in production
  • Row count and distinct key count per dimension per load, on one axis. Separation means duplicates (Pipeline Metrics).
  • Unresolved dimension-key rate on each fact load, as a percentage rather than a count, so it is comparable across days (The Data Quality Dashboard).
  • Share of fact rows assigned to the unknown member, over time. It should be flat and small; a step change means a source id space moved (Volume Anomalies).
What changes at 10x and 100x
  • At 10x facts, dimensions usually do not grow at all, which is the point of the split. What grows is the cost of joining them, which is a layout and join-strategy question (Clustering and Sort Order).
  • At 100x, or when versioning is added, a dimension can outgrow broadcast. That changes every star query from a cheap map-side join into a shuffle, and it is a step change rather than a gradient.
  • Consumer growth turns conforming into the hard part. Two teams with two customer dimensions is a modelling inconvenience; twelve teams with twelve is a platform that cannot answer a cross-process question at all (Data Ownership).
What drives cost here
  • Dimensions are small and cheap to store. Their cost is in join behaviour: a dimension small enough to broadcast is nearly free, and one that is not forces a shuffle on every query touching it (Broadcast Joins).
  • Versioning multiplies dimension row count by the number of changes per entity, which turns a small table into a medium one and can cross the broadcast threshold (SCD Type 2 in Practice).
  • A date dimension is the cheapest table in the warehouse — a few thousand rows for decades of coverage — and removes date arithmetic from every query that would otherwise compute it per row (Compute Waste).
What this approach costs
  • Separating dimensions from facts costs a join per attribute group and buys the ability to restate an attribute in one place and keep facts narrow. On columnar engines with small dimensions this trade is almost free; it is not free on every engine.
  • A date dimension costs a table and a date_key column on every fact, and buys fiscal calendars, holiday flags, gap-free reporting and dialect independence.
  • An unknown member costs a reserved key and some care in every join, and buys visible gaps instead of silently dropped facts. It also makes it easier to ignore the gap, which is why the unresolved rate must be monitored.

Modeling lab — one grain, ten questions

Change an input and watch which number moves — and which one does not. Everything here comes from a model in this repository, not from a measurement.

Modeling lab — one grain, ten questions
Pick the grain of the fact table. The questions do not change; what the table can honestly say about them does.
Fact grain
The transaction and its total. The individual products are gone.
Answered
3
With care
1
Confidently wrong
4
Unanswerable
2
4 of these questions get an answer at this grain that is wrong, and none of them raise an error. That is the whole difficulty: the unanswerable ones announce themselves, and these do not.
Business questionAt this grainWhy
What was revenue by country last month?
Wants: One order, or one order line — either works, provided the measure is additive at that grain and is not summed twice.
answeredThe measure is additive at this grain and each order is counted once.
What is the average order value?
Wants: One order. An average over lines answers a different question entirely.
answeredThe denominator is orders, which is exactly what one row is.
What was revenue by customer country at the time of each order?
Wants: One order, joined to the version of the customer that was current when the order was placed.
WRONG
no history
With a Type 1 dimension every historical order is attributed to the customer's current country. A customer moving from Poland to Germany silently rewrites last year's regional reports, and last month's report no longer reproduces.
What is net revenue after refunds?
Wants: One order, with refunds either netted into the measure or held as a separate signed fact at the same grain.
answeredRefunds net into the measure, or sit beside it as a signed fact at the same grain.
What was the total account balance on each day last year?
Wants: One account-day. A balance is a state, not an event, and cannot be reconstructed by summing transactions unless every transaction since account opening is retained.
WRONGSumming transactions per day gives the daily change in balance, not the balance. The chart has the right shape and the wrong y-axis.
What is month-three retention by signup cohort?
Wants: One user-month of activity, joined to the user's signup month.
WRONGUsers who were active but did not buy are invisible, so retention is understated by exactly the non-buyers.
What share of sessions ended in a purchase?
Wants: One session — which requires a session window over events, because no source system emits a session.
unanswerableNo source system emits a session. Without a session window over events there is no denominator to divide by.
Which products are most often bought together?
Wants: One order line, with the order key retained so lines can be grouped back into baskets.
unanswerableThe most instructive failure in this lab: the model is not wrong, it is at the wrong resolution, and no query can recover what was aggregated away.
What was yesterday's revenue, asked at 06:00 this morning?
Wants: One order, in a period that is not yet closed.
with careThe grain is right and the period is not closed. Orders that happened yesterday and arrive later today are still missing at 06:00.
What was global revenue, across markets that bill in different currencies?
Wants: One order, with both the transaction amount and the converted amount stored, plus the rate and the date the rate applied.
WRONG
no history
Converting at query time with today's rate makes every historical report change daily. Converting once with no record of the rate makes the number unreproducible. Both pass every type check there is.
answeredThe grain is the thing the question is about.
with careIt works, and there is one specific way to get it wrong.
WRONGIt returns a plausible number that is not the answer, and nothing raises.
unanswerableThe resolution needed was aggregated away. No query recovers it.
SIMPLIFIEDA single fact table against ten questions. A real model has several, and a question that one answers badly another may answer exactly — which is the argument for more than one fact table, not for a finer one.

Where this applies

Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.

  • GENERALSeparating descriptive context from measurements, and joining it many-to-one, is a property of analytical querying rather than of any product. The failure modes — duplicate keys, late dimensions, overwritten attributes — appear identically in a warehouse, a lakehouse and a set of Parquet files.
  • WAREHOUSE-SPECIFICWhether keeping dimensions separate is nearly free depends on join strategy: engines that broadcast small tables to every worker make star joins cheap, while an engine that shuffles both sides pays network cost on every query, which is why the same advice about normalising dimensions reverses between engines.
  • ORG-SPECIFICConformed dimensions are an agreement between teams about key space and meaning, not a technical artefact. In one team it is trivially satisfied; across a dozen autonomous teams it needs explicit ownership and a shared identifier strategy, which is where mesh-style platforms spend most of their governance effort.

Where the depth lives

This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.

Domains that do not exist yet
  • DevOps / Production Engineering owns the deployment ordering that makes "dimensions load before facts" an enforced property of the release rather than a convention in a DAG.