skip to content

One Big Table & Denormalized Analytics

On a columnar engine a single wide, pre-joined table often beats a star: no joins, cheap column pruning, and an easy surface for analysts. The trade is duplication, wider rebuilds, and losing conformed reuse.

on this pageshow

questions

6

What is a one-big-table (OBT) analytics model and what problem does it solve?

level: juniorimportance: must knowfreq 55%

answer

  1. one table, no joins for the reader
  2. the joins happened once, upstream
  3. grain is still declared first
  4. width is cheap; duplication is not free
  5. serving layer, not model of record

basics

~20 s

A one-big-table model materialises a fact and all its dimension attributes into a single wide table, so analysts query it without joins. It buys simplicity and consumer safety, and pays in duplication, rebuild cost and lost dimension reuse.

solid answer

~50 s

A one-big-table (OBT) mart is the pre-joined form of a star: the transform joins the fact to its dimensions once and materialises the result, so `orders_wide` carries `order_amount` next to `customer_segment`, `product_category` and `store_region`. Consumers filter and group one object with no joins to get wrong, and on a columnar engine a query is charged roughly for the columns it touches, so extra width costs readers little. The grain is unchanged and still declared first — one row per order, per line, per session. What you pay is duplication of every attribute across all fact rows, restatement cost when an attribute is corrected, and the loss of a single shared dimension other marts could reuse. In practice OBT works best as a **serving layer generated from** a modelled core, not as the model of record.

code

text · 11 lines
text
-- star: 4 tables, joined at query time
fact_orders(order_id, order_ts, customer_key, product_key, store_key, order_amount)
dim_customer(customer_key, customer_id, customer_segment, customer_country)
dim_product(product_key, sku, product_category, product_brand)
dim_store(store_key, store_code, store_region)

-- OBT: 1 table, joined once at build time
orders_wide(order_id, order_ts, order_amount,
            customer_id, customer_segment, customer_country,
            sku, product_category, product_brand,
            store_code, store_region)

go deeper

for a junior

Be able to say plainly that a one-big-table mart is a fact with its dimension attributes already joined on, queried without joins, and that the price is duplicated values in every row.

for a middle

Explain how the table is built — many-to-one joins from fact to dimensions, materialised once — and why a join that fans out breaks the declared grain and inflates measures.

for a senior

Show the operational judgment: what a correction to one attribute costs on a table with years of history, how you rebuild incrementally, and when you refuse to flatten because a dimension is shared.

for a principal

Own the position that a wide mart is a generated serving artifact over a modelled core, and be able to argue when the reuse and conformance you give up is worth the consumer simplicity you buy.

## What "one big table" means A one-big-table (OBT) model is an analytics table that carries, on every row, both the measures of a business event and the descriptive attributes that would otherwise sit in separate dimension tables. Instead of `fact_orders` joined to `dim_customer`, `dim_product`, `dim_store` and `dim_date`, the analyst is handed a single `orders_wide` whose columns include `order_amount`, `quantity`, `customer_segment`, `customer_country`, `product_category`, `product_brand`, `store_region` and `order_month` side by side. The joins were performed once, in the transform, and their results were written down. Two things remain true and are worth saying out loud in an interview. First, an OBT still has a **grain** — one row per order, or per order line, or per session — and that grain is still the first thing you declare and the first thing you test. "Wide" is not permission to mix grains in one table. Second, the numbers do not change: an OBT is arithmetically the same star, pre-joined. Anything a star could answer, the OBT can answer, provided the flattening did not silently change the grain. ## How it is built The usual construction is a single transform that reads the fact, joins each dimension on its key, projects the attributes analysts actually use, and materialises the result: ```sql CREATE TABLE orders_wide AS SELECT f.order_id, f.order_ts, f.order_amount, f.quantity, c.customer_segment, c.customer_country, p.product_category, p.product_brand, s.store_region FROM fact_orders f JOIN dim_customer c ON c.customer_key = f.customer_key JOIN dim_product p ON p.product_key = f.product_key JOIN dim_store s ON s.store_key = f.store_key; ``` Every join here must be many-to-one on the dimension key. The moment one of them fans out — a dimension with more than one row per key, or a many-to-many relationship — the row count grows and the measures on the fact side start repeating, which quietly changes the grain. ## Why teams reach for it Four reasons, roughly in order of how often they are the real reason: - **Consumer simplicity.** Analysts, BI tools and SQL-light users get one object. No join to write, no join to get wrong — no fan-out from a many-to-many dimension, no forgetting the filter that selects the current row of a versioned dimension. - **Column economics.** On a columnar engine a query is charged roughly in proportion to the columns it reads, not the columns the table has. A 300-column mart is not 300 columns of cost for a query touching four, which is why the wide-table style became viable on analytical engines and never caught on for row-oriented operational databases. - **Predictability.** One materialised table has one refresh, one grant, one set of documentation, one cache. Latency does not depend on whether the planner picked a good join strategy today. - **Tool constraints.** Some downstream tools model one table well and several tables badly; an OBT hands them the shape they want. ## What it costs - **Duplication.** `customer_segment` is stored on every one of a customer's orders. Compression on a columnar engine makes the raw byte cost far smaller than the row count suggests, so storage is rarely the honest objection. - **Restatement.** Correcting an attribute is no longer a one-row dimension update; it is a rewrite of every fact row that carries the copy. Cost scales with history, not with the change. - **Lost conformance and reuse.** The attribute's logic now lives in each OBT that copied it. Two teams' wide marts, built at different times or with slightly different definitions, will disagree — and there is no shared dimension to reconcile them against. - **Frozen history semantics.** Whatever you chose at build time — the value as of the event, or today's value — is baked in. A star lets a consumer pick by changing the join; an OBT does not. - **Schema churn and sprawl.** Adding an attribute means altering and backfilling a huge table rather than adding a column to a small dimension that every fact already points at. Wide tables also become dumping grounds: nobody deletes a column, nobody documents the 180th one. ## Where it fits The mainstream position is that OBT is a **serving artifact**, not a modelling philosophy. You still model facts and shared dimensions with declared grains and versioned history; you then generate one or a few wide, disposable marts from that core for the consumers who benefit. That keeps the definition of `customer_segment` in one upstream place while still giving the dashboard a join-free table, and it means a wide mart can be dropped and rebuilt without losing anything that is not reproducible. OBT is the wrong default when dimensions are shared across many business processes, when relationships are genuinely many-to-many, when attributes change frequently, when consumers need both as-of and current views of the same attribute, or when a full rebuild of years of history no longer fits the batch window.

  • Does building an OBT let you skip declaring a grain?
    No. The grain is still the first decision and the first test. An OBT has exactly one row per business event at a stated grain, and every dimension join used to build it must be many-to-one on that grain. A join that fans out repeats the fact's measures and silently changes the grain, which is the most common way a wide mart starts double-counting.
  • If an OBT is arithmetically the same as the star, why not always build one?
    Because the star keeps each attribute in exactly one place. That makes corrections a one-row update, lets many facts reuse the same dimension, and lets a consumer choose between as-of and current attribute values by changing the join. An OBT trades all of that away for join-free reads, so it is worth it for a specific serving need, not as a blanket replacement.
  • Is storage duplication the main argument against a one-big-table mart?
    Usually not. Columnar compression handles a repeated low-cardinality attribute well, so bytes are rarely the deciding factor. The real costs are logical: the same business definition duplicated across marts and drifting, rewrites to correct one attribute, and rebuild time that grows with history rather than with the size of the change.

A star schema is a recipe with the ingredients in separate jars; an OBT is the meal already plated. Faster to eat, but you cannot swap an ingredient without cooking the whole thing again.

saying these in an interview costs you the question

  • Says a one-big-table mart has no grain to declare
  • Claims denormalization is always faster, on any engine
  • Treats storage duplication as the main cost of OBT
  • Assumes OBT replaces dimension modelling entirely
  • Thinks pre-joining changes the numbers a query returns

context

open as a page

In a one-big-table mart, what does copying every dimension attribute onto each fact row cost you?

level: middleimportance: must knowfreq 58%

basics

~20 s

Duplicated attributes make every correction a rewrite of history instead of a one-row dimension update, spread the same business definition across marts where copies drift, and make rebuild cost scale with history rather than with the size of the change.

open as a page

When should a wide analytics table nest child records in an array column instead of one row per child?

level: middleimportance: should knowfreq 42%

basics

~20 s

Nest children when the table's grain must stay one row per parent and the children are bounded and always read with the parent. Flatten to one row per child when children carry their own measures or filters, and accept that parent measures then repeat.

open as a page

In a one-big-table mart, how do you serve both the as-of-event and the current value of a dimension attribute?

level: seniorimportance: should knowfreq 38%

basics

~20 s

Carry the frozen as-of value as its own named column, built by joining the fact to the dimension version effective at event time, and keep the durable business key on the row so consumers can join to a small current-attribute table for today's value.

open as a page

Every dashboard team wants its own one-big-table; how do you stop the warehouse model fragmenting?

level: principalimportance: should knowfreq 30%

basics

~20 s

Treat wide tables as generated, disposable serving artifacts over one modelled core. Business definitions live upstream and are selected, never restated; each mart must be reproducible from the core, owned, lineage-tracked and retirable. Duplicated values are fine; duplicated definitions are not.

open as a page

In a wide analytics table, when should sparse attributes live in a key-value map column instead of typed columns?

level: seniorimportance: nice to knowfreq 22%

basics

~20 s

Use a key-value column when attributes are sparse, per-tenant or high-churn and a typed column each would mean constant schema changes and mostly-null columns. Promote an attribute to a typed column once many consumers filter, group or document it.

open as a page