Skip to content
About
Main content

Cost of a dimension change: star against OBT

Measure your machineNeeds Python with DuckDB

Problem

A keeps descriptive attributes (a product's category, brand) in small tables that the facts join to. One big table () 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 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

On the 5-way analytic query, how much faster is the one big table than the star schema?

Run it

  1. 1Install uv once. It fetches Python and the lab's packages for you.

    Terminal
    curl -LsSf https://astral.sh/uv/install.sh | sh
  2. 2Get the script. It is the whole lab: read it, then download it.

    cost-of-a-dimension-change-star.py66 linesDownload
    View 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)
    
  3. 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
Knobs
Knob · defaultWhat 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

Rename one category, then read
Star: update (ms)OBT: rewriteOBT: MB writtenStar: readOBT: read
0.4571.06 s171142 ms143 ms
Reference runApple M2 · 8 cores · 8 GB
Output · uv run cost-of-a-dimension-change-star.py
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)
macOS 26.6.2 · Python 3.13.3 · duckdb 1.5.6 · run 2026-10-20

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.