Skip to content
academia.sh

Lesson 08 / 12

Star and Snowflake Schema

Measuring what the degree of normalization gains on the dimension side in rows and what it makes the query pay in joins: when the region is moved to a separate table, dimension rows go from 212 to 217, dimension cells go from 636 to 646, and joins double from 2200 to 4400, while the region total the two schemas produce stays exactly the same; the cost rises from 5040 to 5170, and 120 of the difference is the per-file constant; when the region table carries a single attribute, normalization loses 10 cells, at the second attribute it starts to gain 185 cells, and correcting a region name touches 1 row instead of 46.

Published Updated

In this lesson

  • Builds the same fact table with flattened and normalized dimensions and counts the dimension rows and the number of joins.
  • Shows that the two schemas produce the same result and measures separately the effect of normalization on cost and on the update surface.
  • Determines after how many attributes in the dimension table normalization starts to gain cells.
Contents

The previous lesson built the dimension tables flattened: the subscriber’s region was written as the region name itself in each of the 200 rows. The set of regions has five elements, so the same five texts repeat forty times in the dimension table. The repetition taken out of the fact reappeared inside the dimension, only at a scale eleven times smaller.

It is possible to apply the same operation once more: moving the region to a separate table and leaving only a reference in the subscriber dimension. This puts a second link between the fact and the region name and changes the shape of the schema. The arrangement in which single-layer dimensions stand around the fact is called a star schema, and the arrangement in which the dimensions branch into their own subtables is called a snowflake schema. This lesson builds the two schemas from the same data and counts how many rows the degree of normalization gains on the dimension side and how many joins it costs on the query side.

  • MF9. The source, the pipeline and the store are fictional; no real data warehouse, data lake, file format or query engine is called. The seed is 20260218, and the measurement does not depend on the run.
  • MF10. The theory of normalization, functional dependency and the normal form procedure were measured in the Data Modeling and Relational Theory course and are not repeated. The question here is only what the degree costs in dimension rows and in the number of joins.
  • MF11. The two schemas share the same fact table; the only thing that changes is the arrangement of the dimensions. The grain of the fact is the pair of subscriber and period, and it is 2200 rows.
  • MF12. The key of the region table is the region index. A key is a cell; its width is not measured.
  • MF13. The number of joins is how many times in total the query looks at the dimension tables. In the snowflake, for every fact row the subscriber dimension is looked at first, then the region table.
  • MF14. Cost reads as files x 120 + cells. The per-file constant of 120 is a choice, and a separate dimension table means at least one file.
  • MF15. The baseline is the star schema: two dimension tables, a single layer, no branching.
  • MF16. Resolution: the step of a row share is 0.0005. Totals are written as the values themselves, not as shares.

Building the Two Schemas

The first block sets up the shared definition. The star function gives the layout of the previous lesson: the fact carries three fields, and the subscriber dimension carries the region name and the type. snowflake starts from the same data, turns the region name in the subscriber dimension into the region index and opens a region table of five rows. region_total_query answers the same question in both layouts and counts how many times it looked at a dimension along the way; in the snowflake one extra look is made for every row, because the index that comes out of the subscriber dimension is not a name.

# FICTIONAL. Meter readings, pipeline and store are defined inside the lesson; no real
# data warehouse, data lake, file format or query engine is called.
SEED, M32 = 20260218, 0xFFFFFFFF


def rng(t):
    x = ((t ^ (t >> 16)) * 2246822507) & M32
    x = ((x ^ (x >> 13)) * 3266489909) & M32
    s = [(x ^ (x >> 16)) & M32]

    def next_value():
        s[0] = (s[0] * 1664525 + 1013904223) & M32
        return s[0] / 4294967296
    return next_value


REGION = ["east", "west", "north", "south", "central"]
TYPE = ["residential", "commercial"]
PERIODS = [f"2026-{a:02d}" for a in range(1, 13)]
SUBSCRIBERS = {}
for i in range(200):
    r = rng(SEED + 37 * i)
    SUBSCRIBERS[f"A{i + 1:03d}"] = {"region": REGION[int(r() * 5)], "type": TYPE[int(r() * 2)],
                                    "start": 10000 + int(r() * 60000)}


def generate_source():
    records = []
    for i, (sub, a) in enumerate(SUBSCRIBERS.items()):
        e = a["start"]
        for p in PERIODS:
            r = rng(SEED + 101 * i + 7 * PERIODS.index(p))
            e += 4 + int(r() * 46)
            records.append({"subscriber": sub, "period": p, "region": a["region"], "type": a["type"],
                            "index": e, "unit": "m3"})
    return records


def s1_screen(records):
    REQUIRED = ("subscriber", "period", "region", "index", "unit")
    keep = [k for k in records if all(a in k for a in REQUIRED)]
    return keep, len(records) - len(keep)


def s2_dedupe(records):
    seen, keep = set(), []
    for k in records:
        key = (k["subscriber"], k["period"])
        if key in seen:
            continue
        seen.add(key)
        keep.append(k)
    return keep, len(records) - len(keep)


def s3_consumption(records):
    table = {(k["subscriber"], k["period"]): k for k in records}
    out, dropped = [], 0
    for k in records:
        i = PERIODS.index(k["period"])
        if i == 0:
            continue
        prev = table.get((k["subscriber"], PERIODS[i - 1]))
        if prev is None:
            dropped += 1
            continue
        try:
            f = int(k["index"]) - int(prev["index"])
        except (TypeError, ValueError):
            dropped += 1
            continue
        out.append(dict(k, m3=f))
    return out, dropped


COLUMNS = ("subscriber", "period", "region", "type", "index", "unit", "m3")
FILE_CONSTANT = 120          # the fixed cost of touching each file , in cells


def rows():
    v, _ = s1_screen(generate_source())
    v, _ = s2_dedupe(v)
    v, _ = s3_consumption(v)
    return v


def write(records, partitioning=()):
    layout = {}
    for k in records:
        key = tuple(k[a] for a in partitioning)
        layout.setdefault(key, []).append(k)
    return layout


def query(layout, partitioning, selected, filters=None, columnar=True):
    filters = {} if filters is None else filters
    pruning = {a: v for a, v in filters.items() if a in partitioning}
    remaining = {a: v for a, v in filters.items() if a not in partitioning}
    files, cells, matching = 0, 0, 0
    read_columns = len(selected) if columnar else len(COLUMNS)
    for key, records in layout.items():
        if pruning:
            pair = dict(zip(partitioning, key))
            if any(pair[a] != v for a, v in pruning.items()):
                continue
        files += 1
        cells += len(records) * read_columns
        matching += sum(1 for k in records if all(k[a] == v for a, v in remaining.items()))
    return {"files": files, "cells": cells, "matching_rows": matching,
            "cost": files * FILE_CONSTANT + cells}


def star(records):
    """Flattened dimensions: the region name sits inside the subscriber dimension."""
    fact = [{"subscriber": k["subscriber"], "period": k["period"], "m3": k["m3"]} for k in records]
    subscriber_dim = {k["subscriber"]: {"region": k["region"], "type": k["type"]} for k in records}
    period_dim = {d: {"year": d[:4], "month": d[5:]} for d in PERIODS}
    return fact, {"subscriber": subscriber_dim, "period": period_dim}


def snowflake(records):
    """The same data , the region moved out to a separate table."""
    fact, dim = star(records)
    subscriber_dim = {a: {"region_no": REGION.index(v["region"]), "type": v["type"]}
                      for a, v in dim["subscriber"].items()}
    region_dim = {i: {"name": b} for i, b in enumerate(REGION)}
    return fact, {"subscriber": subscriber_dim, "region": region_dim, "period": dim["period"]}


def region_total_query(fact, dim, snow=False):
    """Total per region. One more join in the snowflake."""
    joins, total = 0, {}
    for o in fact:
        a = dim["subscriber"][o["subscriber"]]
        joins += 1
        if snow:
            b = dim["region"][a["region_no"]]["name"]
            joins += 1
        else:
            b = a["region"]
        total[b] = total.get(b, 0) + o["m3"]
    return total, joins

The Row Gained, the Join Paid

The second block builds the two schemas, counts the dimension side and asks the same query of both.

ROWS = rows()
STAR_FACT, STAR_DIM = star(ROWS)
SNOW_FACT, SNOW_DIM = snowflake(ROWS)
star_total, star_joins = region_total_query(STAR_FACT, STAR_DIM)
snow_total, snow_joins = region_total_query(SNOW_FACT, SNOW_DIM, snow=True)
star_rows = len(STAR_DIM["subscriber"]) + len(STAR_DIM["period"])
snow_rows = len(SNOW_DIM["subscriber"]) + len(SNOW_DIM["region"]) + len(SNOW_DIM["period"])
star_cells = len(STAR_DIM["subscriber"]) * 3 + len(STAR_DIM["period"]) * 3
snow_cells = len(SNOW_DIM["subscriber"]) * 3 + len(SNOW_DIM["region"]) * 2 + len(SNOW_DIM["period"]) * 3
print(f"{'schema':<12}{'dimension tables':>18}{'dimension rows':>16}{'dimension cells':>17}"
      f"{'joins':>13}")
print(f"{'star':<12}{2:>18}{star_rows:>16}{star_cells:>17}{star_joins:>13}")
print(f"{'snowflake':<12}{3:>18}{snow_rows:>16}{snow_cells:>17}{snow_joins:>13}")
print("result exactly identical:", star_total == snow_total)
print("result:", star_total)
schema        dimension tables  dimension rows  dimension cells        joins
star                         2             212              636         2200
snowflake                    3             217              646         4400
result exactly identical: True
result: {'central': 14536, 'north': 13338, 'east': 9695, 'west': 9164, 'south': 11721}

The table carries the whole lesson. Dimension rows go from 212 to 217: five rows, the region table itself. Dimension cells go from 636 to 646. Joins, however, go from 2200 to 4400, that is, they double. The result line says that both produce the same dictionary.

The real finding here is the opposite of what was expected. Normalization removes repetition, so it should have saved space; in this measurement it does not. In the subscriber dimension the region index takes the place of the region name, but the number of columns does not change: 200 rows still carry three cells. The only thing gained is that the name is an index instead of text, and in this measurement a cell is a cell. In return, the five-row region table and its ten cells are added from the start. Five rows and ten cells on the negative side, and on the positive side joins that double.

That the result stays exactly the same shows that this trade lies only on the cost side. The snowflake schema does not give an answer the star schema cannot give; it gives the same answer with twice the joins. The rule of the course reads this line: a layout’s number is not the rows it stores but what a query pays to touch it; what is paid for this query is 4400 joins, and without writing which query it is, this number means nothing.

This measurement is also the form that the second claim of the course takes in this lesson: a layout decision can make the work worse. A snowflake built with a region table that carries a single attribute loses ten cells, doubles the joins and gives no new answer in return. The reason the worsening goes unnoticed sits in the same line: the result does not move.

Cost, Update and the Point Where Payment Begins

The third block measures three things in a row: the files the same query touches in the two schemas and the cost it pays, the number of rows updated when a region name is corrected, and where the cell balance goes as the number of attributes the region table carries rises.

star_sub = [{"subscriber": a, **v} for a, v in STAR_DIM["subscriber"].items()]
snow_sub = [{"subscriber": a, **v} for a, v in SNOW_DIM["subscriber"].items()]
snow_region = [{"region_no": i, **v} for i, v in SNOW_DIM["region"].items()]
q_fact = query(write(STAR_FACT, ()), (), ("subscriber", "m3"))
q_star_sub = query(write(star_sub, ()), (), ("subscriber", "region"))
q_snow_sub = query(write(snow_sub, ()), (), ("subscriber", "region_no"))
q_snow_region = query(write(snow_region, ()), (), ("region_no", "name"))
star_reads = [q_fact, q_star_sub]
snow_reads = [q_fact, q_snow_sub, q_snow_region]
print(f"{'schema':<12}{'files':>7}{'cells':>7}{'cost':>9}")
for name, q in (("star", star_reads), ("snowflake", snow_reads)):
    print(f"{name:<12}{sum(x['files'] for x in q):>7}{sum(x['cells'] for x in q):>7}"
          f"{sum(x['cost'] for x in q):>9}")
print("difference:", sum(x["cost"] for x in snow_reads) - sum(x["cost"] for x in star_reads),
      " of which", FILE_CONSTANT, "is the per-file constant")
print()
n_sub = sum(1 for v in STAR_DIM["subscriber"].values() if v["region"] == "north")
print("a region name changes: star", n_sub, "rows , snowflake 1 row")
print()
print(f"{'region attributes':>19}{'star cells':>12}{'snowflake cells':>17}{'difference':>13}")
for n in range(1, 7):
    star_h = len(STAR_DIM["subscriber"]) * (2 + n)
    snow_h = len(SNOW_DIM["subscriber"]) * 3 + len(SNOW_DIM["region"]) * (1 + n)
    print(f"{n:>19}{star_h:>12}{snow_h:>17}{star_h - snow_h:>13}")
schema        files  cells     cost
star              2   4800     5040
snowflake         3   4810     5170
difference: 130  of which 120 is the per-file constant

a region name changes: star 46 rows , snowflake 1 row

  region attributes  star cells  snowflake cells   difference
                  1         600              610          -10
                  2         800              615          185
                  3        1000              620          380
                  4        1200              625          575
                  5        1400              630          770
                  6        1600              635          965

The cost line gives a smaller difference than expected: 5040 against 5170, a gap of 130. 120 of it is the fixed cost of touching the third file, and the remaining 10 is the ten cells of the region table. The cells read hardly rise, because the region table has five rows. The cost of the additional table is this much because the per-file constant was chosen as 120; had the constant been chosen larger, the cost of the snowflake on the read side would have come almost entirely from the constant. The doubling of the joins is not visible in this measurement, because a join does not touch a file; the two things counted are separate costs, and a schema is not measured unless both are written.

The update line is what normalization actually gets in return. When a region name is corrected, 46 subscriber rows are updated in the star schema and 1 row in the snowflake. Because in the star the name sits in forty-six places, the update has to reach forty-six places; in the snowflake the name has a single copy and cannot be found anywhere else. The gain appears not in storage but in the surface where inconsistency can arise.

The relationship of the two sides to scale is opposite, and the schema decision comes out of this opposition. The extra join the snowflake charges is one per fact row: 2200 extra on 2200 rows, 4400 extra had the fact doubled. The update surface it gains is independent of the fact: the region name sits in 46 subscriber rows and, however much the fact grows, stays at 46 as long as the number of subscribers does not change. So as the data grows, the cost of branching grows and its gain stays where it is. For this reason the decision is made by looking not at how many times the dimension is repeated but at the ratio of fact rows to dimension rows; in this measurement the ratio is eleven, and every join is paid multiplied by that ratio.

The last table says where the balance goes. While the region table carries a single attribute, normalization loses 10 cells. At the second attribute the table turns: the star has 800 and the snowflake 615, a difference of 185 cells in favor of the snowflake. At the third attribute the difference rises to 380, and at the sixth to 965. Where the threshold lies depends on the repetition counts: 200 subscriber rows, 5 region rows and the attribute being written once per subscriber. If the information the region carries amounts to a single name, normalization does not pay; the moment the region starts to carry a few attributes, it starts to pay, and the join count stays doubled independently of this.

Summary

  • The star schema keeps the dimensions flattened and the snowflake schema branches a dimension into its own subtable; the two schemas share the same fact table and the same 2200 rows.
  • When the region is moved to a separate table, dimension rows go from 212 to 217, dimension cells go from 636 to 646, and joins go from 2200 to 4400, that is, they double.
  • The region total the two schemas produce is exactly the same; the snowflake does not give a new answer, it gives the same answer with twice the joins.
  • The cost rises from 5040 to 5170 and 120 of the difference is the per-file constant; the doubling of the joins is not visible in the cost, it is a separate cost and is written separately.
  • What normalization gets in return is the update surface: correcting a region name touches 46 rows in the star and 1 row in the snowflake.
  • The cell balance turns at the second attribute of the region table: with a single attribute the snowflake loses 10 cells, with two it gains 185, with six it gains 965 cells.

Next Step

Both schemas worked in this lesson with a silent assumption: the subscriber’s region and type do not change. The dimension table keeps a single row per subscriber, that row gives the subscriber’s information, and which period of the fact is being looked at does not affect it. Real dimensions do not behave this way. A residential subscriber turns into a commercial one, a subscriber moves to another region, a classification is redone. Once a dimension row is overwritten, the facts of past periods are also qualified with the new information, and the query reports this nowhere. The next lesson measures this change: how much a dimension that keeps no history slows the query, how much it changes the answer, and what keeping history costs in dimension rows.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close