Cost of a dimension change: star against OBT
Measure your machineNeeds Python with DuckDB
Problem
A star schema keeps descriptive attributes (a product's category, brand) in small dimension tables that the facts join to. One big table (OBT) copies them into every fact row to save the join. On a columnar engine the join is cheap and the copies compress well, so reads barely differ. Changes do: renaming a category is one row in a star, and every affected fact row in an OBT, which on columnar storage means rewriting whole files. A change of one logical fact becomes many physical writes.
With DuckDB, generate a 20-million-row fact table and a 2,000-row product dimension. Build (a) a star and (b) an OBT with the product category copied into every row. Rename one category. In the star, update one dimension row and time a query. In the OBT, time the rewrite of the affected rows (a CREATE TABLE AS over the whole table is the honest measure). Compare the time and the bytes written. Then run a 5-way analytic query on both and compare read times.
The question. How much more does renaming one category cost in the OBT than in the star, and how much faster is the OBT to read?
Predict
With column pruning the OBT's advantage on reads should be small.
Run it
1Install uv once. It fetches Python and the lab's packages for you.
Terminal curl -LsSf https://astral.sh/uv/install.sh | sh2Get the script. It is the whole lab: read it, then download it.
View the codeHide the code
cost-of-a-dimension-change-star.py # /// script # requires-python = ">=3.10" # dependencies = ["duckdb"] # /// # Copyright (c) 2026 Aayush Sharma · SPDX-License-Identifier: MIT # https://thedataengineering.com/lab-code/LICENSE.txt """The cost of a dimension change: star against one big table. A 20-million-row fact table and a 2,000-row product dimension, stored as a star and as one big table (OBT) with the category copied into every row. Rename one category in each; then run the same analytic query on both. """ import argparse, json, os, tempfile, time import duckdb p = argparse.ArgumentParser(description=__doc__.splitlines()[0]) p.add_argument("--facts", type=int, default=20_000_000, help="Fact rows.") p.add_argument("--products", type=int, default=2_000, help="Products in the dimension.") p.add_argument("--categories", type=int, default=50, help="Product categories.") p.add_argument("--path", default=os.path.join(tempfile.gettempdir(), "star-vs-obt.duckdb"), help="Database file.") p.add_argument("--json", help="Also write the results to this JSON file.") args = p.parse_args() import atexit, shutil atexit.register(lambda: shutil.rmtree(args.path, ignore_errors=True) if os.path.isdir(args.path) else os.path.exists(args.path) and os.remove(args.path)) # leave nothing behind if os.path.exists(args.path): os.remove(args.path) con = duckdb.connect(args.path) con.execute(f"""CREATE TABLE dim_product AS SELECT i AS product_id, 'cat_' || (i % {args.categories}) AS category, 'brand_' || (i % 97) AS brand FROM range({args.products}) t(i)""") con.execute(f"""CREATE TABLE fact AS SELECT i AS sale_id, (hash(i) % {args.products})::INT AS product_id, (hash(i * 7) % 3650)::INT AS day, (hash(i * 13) % 10000) / 100.0 AS amount FROM range({args.facts}) t(i)""") con.execute("CREATE TABLE obt AS SELECT f.*, d.category, d.brand FROM fact f JOIN dim_product d USING (product_id)") con.execute("CHECKPOINT") size = lambda: os.path.getsize(args.path) def timed(sql): t = time.perf_counter(); con.execute(sql); return time.perf_counter() - t # Rename one category before = size() star_s = timed("UPDATE dim_product SET category = 'cat_renamed' WHERE category = 'cat_7'"); con.execute("CHECKPOINT") star_bytes = size() - before before = size() obt_s = timed("""CREATE OR REPLACE TABLE obt AS SELECT * REPLACE (CASE WHEN category = 'cat_7' THEN 'cat_renamed' ELSE category END AS category) FROM obt""") con.execute("CHECKPOINT") # the rewrite writes the whole table again: count its storage blocks (DuckDB blocks are 256 KB) obt_written = con.execute("SELECT COUNT(DISTINCT block_id) FROM pragma_storage_info('obt') WHERE block_id >= 0").fetchone()[0] * 262144 affected = con.execute("SELECT COUNT(*) FROM obt WHERE category = 'cat_renamed'").fetchone()[0] QUERY_STAR = """SELECT d.category, d.brand, f.day % 12 AS month, COUNT(*), SUM(f.amount) FROM fact f JOIN dim_product d USING (product_id) GROUP BY ALL""" QUERY_OBT = "SELECT category, brand, day % 12 AS month, COUNT(*), SUM(amount) FROM obt GROUP BY ALL" reads = {} for name, sql in (("star", QUERY_STAR), ("obt", QUERY_OBT)): timed(sql); reads[name] = min(timed(sql) for _ in range(3)) print(f"rename one category: star updates 1 table of {args.products:,} rows in {star_s * 1000:.1f} ms; " f"OBT rewrites {args.facts:,} rows ({affected:,} affected) in {obt_s:.2f} s, writing {obt_written / 1e6:,.0f} MB") print(f"5-way analytic query: star {reads['star']:.3f} s OBT {reads['obt']:.3f} s ({reads['star'] / reads['obt']:.2f}x)") if args.json: with open(args.json, "w") as f: json.dump({"tables": {"summary": [{"star_update_ms": star_s * 1000, "obt_rewrite_s": obt_s, "obt_written_mb": obt_written / 1e6, "affected": affected, "star_read_s": reads["star"], "obt_read_s": reads["obt"], "read_ratio": reads["star"] / reads["obt"], "update_ratio": obt_s / star_s}]}}, f)3Run it in the folder you saved it to, with the defaults first. Then change a knob from the table below and run it again.
Terminal uv run cost-of-a-dimension-change-star.py uv run cost-of-a-dimension-change-star.py --help # every knob
| Knob · default | What it changes |
|---|---|
| --facts20,000,000 | Fact rows. |
| --products2000 | Products in the dimension. |
| --categories50 | Product categories. |
| --pathos.path.join(tempfile.gettempdir(), 'star-vs-obt.duckdb') | Database file. |
Our run
Rename one category, then read
| Star: update (ms) | OBT: rewrite | OBT: MB written | Star: read | OBT: read |
|---|---|---|---|---|
| 0.457 | 1.06 s | 171 | 142 ms | 143 ms |
rename one category: star updates 1 table of 2,000 rows in 0.5 ms; OBT rewrites 20,000,000 rows (399,896 affected) in 1.06 s, writing 171 MB
5-way analytic query: star 0.142 s OBT 0.143 s (0.99x)What we found
Renaming one category took 0.457 ms in the star, one dimension table of 2,000 rows. In the OBT it took 1.06 s and wrote 171 MB to change 400K rows: 2,330× the time of the star's update. The 5-way analytic query took 142 ms on the star and 143 ms on the OBT, so the OBT's read advantage was nothing at all here, as expected.
Read it
Granularity mismatch generates amplification: an attribute stored once per product in the star is stored once per sale in the OBT, so a change to it touches every sale. On a columnar engine the join the OBT saves is one of the cheapest operations there is.
An OBT pays off for attributes that never change and for readers that cannot join (some BI tools, exports to other teams). For attributes that do change, keep them in the dimension and let the engine join; if a wide table is needed for consumers, build it as a derived view that is rebuilt, not as the system of record.