---
title: 'Monorepo and Polyrepo'
source: 'https://academia.sh/en/courses/advanced-git/monorepo-and-polyrepo'
course: 'Advanced Git'
language: en
updated: '2026-08-17T18:10:42+00:00'
license: 'CC BY-SA 4.0'
---

# Monorepo and Polyrepo

The same work under two schemes: total objects are 22600 in both, but the cost per repository is 22600 versus 5650, bisect is 11 versus 36 steps, and of four cross-component questions the monorepo answers 4 while the polyrepo answers 1.

This topic's four lessons made four separate interventions on the same repository: they
embedded a dependent repository, moved a binary body outside, duplicated the working tree,
and configured the transformation layer. All four held one thing fixed — the repository's
**boundary**.

This lesson makes that boundary a variable. The same work, the same commits, and the same
objects; the only difference is whether all of it stands inside one repository or inside
four separate ones. The institutional discussion of scale and ownership trade-offs was
established in Enterprise Context and Integration in the Software Architecture course and
in the DevOps and Platform Engineering course, and it is not repeated here. What is measured
here is only this: **objects, steps, and answered questions.**

## Two Schemes, the Same Work

**Monorepo** is the scheme where more than one component stands in a single repository and
a single history. **Polyrepo** is the scheme where each component stands in its own
repository and its own history.

The side of the distinction that enters the measurement is built in one sentence: **in a
monorepo, components share a common order; in a polyrepo, they do not.** In a monorepo, two
components can be touched in the same commit, and history records this; in a polyrepo,
every repository has its own order, and there is no comparable common point between two
orders.

The measurement therefore models splitting not as a rewrite but as the same commit sequence
distributed across separate histories. The objects are the same, the commits are the same;
the only thing that changes is the boundary.

## The Measurement's Assumptions

- **LR27** — The same work is the shared setup's **4000**-commit scale. The polyrepo scheme
  splits this sequence into **4** equal parts and renumbers each part on its own; no commit
  is added, removed, or changed.
- **LR28** — The object count in each part is unaffected by the split; the **total** objects
  of the two schemes are equal by definition. What is measured is not the total, it is the
  cost paid **per repository**.
- **LR29** — The oracle works with its own rule in every history: the commit that introduced
  the defect is at the three-quarters point of that history. The split scheme has four
  separate oracles.
- **LR30** — The "repository unknown" row is the case where which component the defect is in
  is not known beforehand; the search runs separately in each repository and the steps are
  summed.
- **LR31** — A question is answered if all the components it requires stand in **a single
  history**. In a monorepo, four components stand in one history; in a polyrepo, one.
- **LR32** — The first part's unit is **touched object**, the second part's unit is
  **step**. The third part does not measure cost, it counts answerability.
- **LR33** — The measurement does not include axes like team size, ownership, permissions,
  or release scheme; those are the subject of other courses, and no organizational
  conclusion can be drawn from this table.

## Measurement

```python
"""Monorepo and polyrepo: the same work under two schemes.

Part 1 - clone cost, unit touched object.
Part 2 - bisect and blame, unit step.
Part 3 - how many cross-component questions get answered.
"""
SEED = 20260814
FILES = ("metrics.py", "report.py", "identity.py", "config.py", "document.md")
TOTAL = 4000
COMPONENTS = 4


def rng(seed):
    d = seed % 2147483646 + 1

    def draw(n):
        nonlocal d
        d = (d * 48271) % 2147483647
        return d % n
    return draw


def history(n, seed=SEED):
    """n-commit linear history; each commit touches one file."""
    draw, commits = rng(seed), []
    for i in range(n):
        file = FILES[draw(5)]
        binary = draw(11) == 0
        commits.append({"no": i + 1, "file": file, "binary": binary,
                         "objects": 2 + (40 if binary else 0)})
    return commits


def faulty_commit(t):
    """Oracle: the commit that introduced the defect is known because we wrote the setup."""
    return len(t) * 3 // 4


def linear_search(t):
    target, step = faulty_commit(t), 0
    for x in t:
        step += 1
        if x["no"] >= target:
            return step
    return step


def bisect(t):
    target, low, high, step = faulty_commit(t), 1, len(t), 0
    while low < high:
        mid = (low + high) // 2
        step += 1
        if mid >= target:
            high = mid
        else:
            low = mid + 1
    return step


def blame(t, file):
    return sum(1 for x in t if x["file"] == file)


def clone_cost(t, include_binary=True):
    return sum(x["objects"] for x in t if include_binary or not x["binary"])


def split(t, count):
    """The same work, split into `count` separate repositories: objects are the
    same, histories are separate."""
    size = len(t) // count
    return [[{**x, "no": i + 1} for i, x in enumerate(t[p * size:(p + 1) * size])]
            for p in range(count)]


QUESTIONS = (("isolating a defect in one component", 1),
             ("two components changing together", 2),
             ("a change spreading to three components", 3),
             ("the shared state of all components", 4))


def answerable(needed, components_in_repo):
    """A question is answerable if all the components it needs stand in a single history."""
    return needed <= components_in_repo


mono = history(TOTAL)
poly = split(mono, COMPONENTS)

print("scheme      repos  objects per repo  1 component  2 components  all components")
print(f"  monorepo      1 {clone_cost(mono):18d} {clone_cost(mono):11d} "
      f"{clone_cost(mono):13d} {clone_cost(mono):14d}")
one = clone_cost(poly[0])
two = sum(clone_cost(p) for p in poly[:2])
total = sum(clone_cost(p) for p in poly)
print(f"  polyrepo  {COMPONENTS:3d} {total // COMPONENTS:18d} {one:11d} "
      f"{two:13d} {total:14d}")
print()
print("scheme                              bisect  linear search"
      "  blame (config.py)")
SEARCH = (("monorepo, 4000 commits", bisect(mono), linear_search(mono),
           blame(mono, "config.py")),
          ("polyrepo, defect's repo known", bisect(poly[0]),
           linear_search(poly[0]), blame(poly[0], "config.py")),
          ("polyrepo, repo unknown", sum(bisect(p) for p in poly),
           sum(linear_search(p) for p in poly),
           sum(blame(p, "config.py") for p in poly)))
for name, bi, li, bl in SEARCH:
    print(f"  {name:34s} {bi:7d} {li:14d} {bl:18d}")
print()
print("question                                needed components  monorepo  polyrepo")
for name, needed in QUESTIONS:
    m = "yes" if answerable(needed, COMPONENTS) else "no"
    p = "yes" if answerable(needed, 1) else "no"
    print(f"  {name:39s} {needed:16d} {m:>9s} {p:>9s}")
print()
print(f"monorepo answered {sum(answerable(n, COMPONENTS) for _, n in QUESTIONS)}/"
      f"{len(QUESTIONS)}, polyrepo answered "
      f"{sum(answerable(n, 1) for _, n in QUESTIONS)}/{len(QUESTIONS)}")
print(f"total objects the same in both schemes {total}; what changes is per-repository "
      f"cost: {clone_cost(mono)} and {total // COMPONENTS}")
```

```
scheme      repos  objects per repo  1 component  2 components  all components
  monorepo      1              22600       22600         22600          22600
  polyrepo    4               5650        5640         11280          22600

scheme                              bisect  linear search  blame (config.py)
  monorepo, 4000 commits                  11           3000                787
  polyrepo, defect's repo known            9            750                209
  polyrepo, repo unknown                  36           3000                787

question                                needed components  monorepo  polyrepo
  isolating a defect in one component                    1       yes       yes
  two components changing together                       2       yes        no
  a change spreading to three components                 3       yes        no
  the shared state of all components                     4       yes        no

monorepo answered 4/4, polyrepo answered 1/4
total objects the same in both schemes 22600; what changes is per-repository cost: 22600 and 5650
```

## Same Total, Different per Repository

The first table's unit is **touched object**, and the right column gives **22600** under
both schemes. This is not a coincidence, it is the setup's rule: splitting produces no
object and destroys none.

The difference is in the columns on the left. In a monorepo, someone working on a single
component also clones **22600** objects, because a clone has no smaller unit. In a
polyrepo, the same person clones **5640** objects — a quarter. If two components are
needed, **11280**; if all four, **22600** again.

The crossover point reads directly from the table: **a polyrepo is only cheap if most
workers touch a single component.** For someone touching all four, the two schemes pay the
same cost, and under a polyrepo that cost is paid through four separate operations. Partial
and sparse clone options achieve the same narrowing in a monorepo without splitting the
repository; this measurement does not use them.

## Splitting Breaks Search

The second table's unit is **step**, and the difference between the rows is the lesson's
sharpest result.

If the component the defect is in is **known**, the polyrepo wins: bisect drops from **11**
steps to **9**, linear search from **3000** to **750**, blame from **787** to **209**. The
search runs over a smaller history, and the cost shrinks.

If it is not known, the table flips. Bisect climbs to **36** steps — more than three times
the monorepo's. The reason is clear: bisect operates on a single ordered set, four separate
histories are four separate sets, and each one runs its own **9**-step search. Splitting
destroys bisect's one strength — growing slowly as the set grows — by fragmenting it.

The same row's other two columns give **3000** and **787** — identical to the monorepo's
values. Linear scan and blame are unaffected by the split, because both already touched
every commit, and total commit count did not change. **The only search form the split
damages is the one that works by halving the set.**

## The Question Left Unanswered

The third table counts not cost but **answerability**. A monorepo answers **4** of four
questions, a polyrepo answers **1**.

The one question answered is the one that stays within a single component. The remaining
three look at two, three, and four components at once, and there is no history in a
polyrepo that can do that. This is not a tooling gap, it is the definition of the boundary:
if no commit records two components changing together, there is no query that can later ask
that question.

There is a way to close the gap, and its cost was measured in this topic's first lesson:
setting up a record repository that pins the four components together. This binds the four
components as submodules and makes "the shared state of all components" answerable again.
In exchange, the identities needed to build a single commit's tree climb from **1** to
**5**, and a recursive clone returns to **22600** objects. When a polyrepo scheme wants to
take back the question it lost, it starts paying back the monorepo's cost.

## Summary

- Splitting produces no object: total objects are **22600** in both schemes. What changes
  is the cost paid **per repository** — **22600** versus **5650**.
- Someone touching a single component clones **5640** objects in a polyrepo, someone
  touching all four clones **22600**; a polyrepo is cheap only if most workers stay within
  a single component.
- If the defect's repository is known, bisect drops from **11** to **9** steps; if it is
  not, it climbs to **36**. Splitting breaks the search that works by halving the set.
- Linear scan stays at **3000** steps and blame at **787**: splitting does not affect
  either form, because both already touched every commit.
- Of four cross-component questions, a monorepo answers **4**, a polyrepo **1**; the loss
  is not a tooling gap but the definition of the boundary, and taking it back recovers the
  cost of a submodule.

## Course Wrap-Up

The course ran on a single measure: the cost of an operation is not the length of history
but the number of objects it touches — and cost is counted in two units, **step** or
**touched object**. Twelve lessons applied this measure to separate places.

| lesson | operation measured | how cost grows |
|---|---|---|
| Interactive Rebase | objects touched by rewriting from the middle | 252 / 362 / 3002 / 11322 objects; ratio from 0.520 to 0.500 — the ratio stays fixed while scale grows eightyfold |
| Bulk History Transformation | objects touched by a transformation from the first commit | 380 / 920 / 5640 / 22600 objects, ratio 1.000 at every scale; the shared prefix drops to 0 and the cost is the entire history |
| Force Push Discipline | what rewriting makes the copies pay | the writer pays between 2 and 5640 objects, copies between 14 and 39480 — always seven times as much; scales with copy count |
| Reflog | what stays reachable after a rewrite | reachable fixed at 1000, unreachable 3 / 500 / 701 / 1000; recovery depends on the horizon and closes at 3 / 50 / 500 / 1000 |
| Bug Hunting with Bisect | the step count of finding a defect | linear 37 / 150 / 750 / 3000 steps, bisect 6 / 7 / 9 / 11; while history grows eightyfold, bisect does not even double |
| Blame and Line History | the step count of a file's line history | 8 / 36 / 209 / 787 steps; grows with the number of commits touching the file and the answer settles at 40 commits |
| Hooks | the cost at the moment the block is placed | 0 objects before commit, 140 before push, 3522 objects and seven copies six hundred commits after acceptance; a late gate distributes the cost |
| Submodules | the history the pinned version binds | 76 objects bind 378 commits; the clone climbs from 920 to 4540 (a 4.93 ratio) and totals with submodule count |
| Large File Storage | object type's share of the total | 91 commits (0.0910) produce 0.6777 of objects; the clone drops from 5640 to 2131 — cost does not track commit count |
| Multiple Working Trees | the objects an extra working area copies | an extra tree is 0 objects at every scale; 5640 instead of 22560 for four branches — cost never grows with tree count |
| Repository Attributes | the unnecessary object the absence of settings writes | line ending 162, merge driver 86, total 248 objects and a ratio of 0.0421; does not decrease as scale grows |
| Monorepo and Polyrepo | the cost of the same work under two schemes | total 22600 objects unchanged, per repository 22600 and 5650; bisect 11 and 36 steps, answered questions 4/4 and 1/4 |

Three readings come out of the table. **First: costs diverge within the same history.**
While scale grows eightyfold, linear search grows eightyfold, blame grows a hundredfold,
cloning grows sixtyfold; bisect does not even double, and an extra working tree does not
grow at all. A repository being "large" does not by itself say anything — what says
something is which operation touches which set.

**Second: the number that determines cost is, most of the time, not commit count.** Object
type produces two-thirds of objects with fewer than a tenth of the commits; copy count
forces resynchronization independent of the size of the change; where the boundary is drawn
triples the search's step count. In all three cases, looking at the wrong variable gives the
wrong result.

**Third: most of this course's tools cannot be undone, and the one who writes does not pay
the cost.** Bulk transformation zeroes the shared prefix, force push deletes without
anyone's knowledge, retroactive cleanup forces every copy to be re-cloned. A recovery path
was counted alongside each one — a backup branch, the reflog, a separate clone, a dry run —
and what they all share is that they have to be set up **before** the operation.

From here, the next course — Code Review and Team Process — takes up the same tools with a
different question. Everything measured across this course was the work the tool did: how
many objects it touched, how many steps it spent, which question it left unanswered. No
measurement said whether the operation should have been done. Knowing that rewriting history
touches 5640 objects does not decide whether that history should be rewritten; knowing at
which moment a hook places a block does not say which change should be blocked. The
distinction has to be set from the start: **the tool changes history, the human makes the
decision.** The next course's subject is how that decision gets made — who looks, what they
look at, and where the decision gets recorded. The numbers this course leaves behind are the
unit that will be used there to compute the cost of the decisions to be made.
