A monorepo is not a big repository. Those two things are correlated and they are not the same claim, and conflating them causes teams to adopt monorepo tooling for a problem it does not solve.
A monorepo is one Git repository containing multiple logical projects or components that an organisation chooses to version together.
The operative word is chooses. A ten-year-old application with 400,000 commits is enormous and contains one project. It has Git-performance problems, and nothing in this article applies to it. A repository with five small services clones in three seconds and is a monorepo.
What a monorepo actually contains
Section titled “What a monorepo actually contains”A node labelled one repository branching to six contents: service A, service B, library C, frontend, infrastructure and tooling.
One Repository │ ├── Service A → deployed independently ├── Service B → deployed independently ├── Library C → consumed by A and B ├── Frontend → different language, different pipeline ├── Infrastructure → the environments they run in └── Tooling → the build and dev scriptsThe components have independent lifecycles. Service A deploys on Tuesday and Service B on Thursday. They share a repository and not a release.
That independence is the point and the difficulty. One repository means one commit history, one set of refs, one clone — and several deployment cadences, several owners, several test suites.
The property that justifies it
Section titled “The property that justifies it”Almost every argument for a monorepo reduces to one thing: atomic cross-component change.
A change to Library C and its two consumers is one commit, one pull request, one review, one merge. The repository never contains a state where the library changed and its callers did not.
In a polyrepo the same change is three pull requests, with an ordering somebody must get right, a version bump in between, and a window during which the library has shipped and its consumers have not.
Everything else follows from that, and the secondary benefits are real:
One version of everything. No repository is on an eighteen-month-old copy of the shared library, because there is only one copy.
Refactoring is possible. Renaming a function used by forty components is a single mechanical change. In a polyrepo it is a migration programme with a deprecation period.
Discovery is trivial. Every engineer has every project on disk. Grep works.
One toolchain. One linter configuration, one CI system, one way to run tests.
What the repository does not give you
Section titled “What the repository does not give you”The central point of this article. Git provides version control. It provides none of the following, and a monorepo without them is worse than the polyrepo it replaced.
Six supporting systems listed: CODEOWNERS, sparse checkout, partial clone, path-aware CI, build graph and repository policy.
Git gives you: one history, one clone, atomic commits You must build: CODEOWNERS → who reviews which paths Sparse Checkout → what a developer materialises on disk Partial Clone → what gets downloaded at all Path-Aware CI → what runs for a given change Build Graph → what genuinely needs rebuilding Repository Policy → what is permitted, per pathEach is a real engineering investment, and skipping any one produces a recognisable failure.
Ownership: without it, review collapses
Section titled “Ownership: without it, review collapses”The first thing to break, and the one that breaks silently.
In a polyrepo, ownership is the repository. Who reviews a change to the payments service? Whoever has access to the payments repository. The boundary does the work.
In a monorepo everybody can change everything, which is the point and which removes the implicit reviewer assignment. Without a replacement, a pull request touching payments code gets reviewed by whoever was available, and that person does not know the payments domain.
CODEOWNERS is the replacement, and in a monorepo it is load-bearing rather than a convenience:
# Default — the platform team sees anything unclaimed* @example-org/platform
/services/payments/ @example-org/payments/services/checkout/ @example-org/checkout/libs/shared-auth/ @example-org/security @example-org/platform/frontend/ @example-org/web/infrastructure/ @example-org/platform/tooling/ @example-org/developer-experience
# Cross-cutting, wherever it appears**/*.tf @example-org/platform**/Dockerfile @example-org/platform/.github/workflows/ @example-org/platform-leadsOnly the last matching pattern applies — the rules do not combine. A file matched by both /services/payments/ and **/Dockerfile gets whichever pattern appears later, which is why the cross-cutting rules sit at the bottom.
Routing is not enforcement. CODEOWNERS requests reviewers; a ruleset requiring code owner review is what makes it a control. Teams add the file, watch reviewers get requested, and reasonably conclude a control exists when none does.
A shared library needs two owners. /libs/shared-auth/ above requires both the security team and the platform team, because a change there reaches every consumer.
Working-tree scale: sparse checkout
Section titled “Working-tree scale: sparse checkout”The second thing to break, and it breaks visibly.
Every engineer gets every file. A frontend developer checks out the machine-learning training pipeline, the infrastructure definitions and eleven services they will never open. git status walks all of it.
Sparse checkout is the answer, and deploying it to hundreds of engineers is a programme rather than a command — which is why it has its own article.
The short version of the design: define personas, generate a cone per persona, ship the configuration in the onboarding tooling, and measure whether checkout times actually improved.
git sparse-checkout set --cone \ services/payments \ libs/shared-auth \ toolingCone mode is the mode to use. Git’s documentation is direct that non-cone mode is deprecated and that users should switch to cone mode.
Download scale: partial clone
Section titled “Download scale: partial clone”The complement, and the one most often confused with sparse checkout.
Sparse checkout changes what is written to disk. Partial clone changes what is downloaded. A sparse checkout of a 40 GB repository still downloads 40 GB.
git clone --filter=blob:none --sparse https://github.com/example-org/monorepo.git--filter=blob:none fetches commits and trees but no file contents; blobs arrive on demand. History remains complete, which matters because shallow clone would not preserve it.
Together these are the standard monorepo developer setup: full history, few objects downloaded, few files on disk.
CI: the cost nobody predicts
Section titled “CI: the cost nobody predicts”The third thing to break, and the most expensive.
In a polyrepo, CI scope is free. A push to the payments repository runs the payments pipeline. Nothing else knows.
In a monorepo every push is a push to everything, and the naive implementation runs every test suite for every change. A one-line frontend change triggers the machine-learning integration tests.
Path filtering is the first fix:
on: pull_request: paths: - 'services/payments/**' - 'libs/shared-auth/**' - '.github/workflows/payments.yml'And it is not sufficient, because a change to libs/shared-auth/ affects every consumer, and path filters do not know the dependency graph. That is the shared-code problem, and there are two answers: maintain an explicit dependency map, or run everything when shared paths change. The second is cruder and much harder to get wrong.
A build graph is the real answer at scale. A build system that understands which targets depend on which files can compute the affected set precisely, and cache the rest. That is a genuine investment, and it is the point at which a monorepo requires a build-system decision rather than just a CI decision.
The economics. A polyrepo pays a small CI cost per repository per change. A monorepo pays either a large cost per change or the cost of building the machinery to avoid it. There is no configuration that makes it free, and the machinery is the cheaper of the two at any meaningful scale — but it is a capital cost paid up front against an operating cost paid continuously, and organisations consistently underestimate how quickly the second overtakes the first.
Clone cost compounds too. Every CI run clones the monorepo, which is the largest repository the organisation has. That is the specific case CI clone optimization addresses, and in a monorepo it is frequently the single largest line in the CI bill.
Repository-level limits still apply
Section titled “Repository-level limits still apply”A constraint people meet later than they expect.
GitHub documents a recommended maximum on-disk repository size of 10 GB for the compressed .git directory, a hard 100 MB limit on individual objects, a 2 GB push limit, and a 5,000-branch maximum. It also documents recommended maximums of 3,000 entries in a single directory and 50 levels of directory depth.
A monorepo approaches several of these simultaneously, and the directory-width one surprises people — a services/ directory with 3,000 entries is a plausible large-organisation shape.
The 5,000-branch limit matters more than it sounds. A monorepo receives every team’s branches. An organisation with 400 engineers and no branch cleanup reaches four figures quickly, and stale branch pruning becomes an operational task rather than tidiness. Repository health covers the full set and the remediation options.
Security boundaries get harder
Section titled “Security boundaries get harder”An honest cost, and the one most often glossed over.
In a polyrepo, repository access is the boundary. A contractor with access to one repository cannot read the others. It is coarse and it is genuinely enforced.
In a monorepo everybody with read access reads everything. There is no path-level read restriction in Git, and none in GitHub. A repository is readable or it is not.
What this means practically:
Contractors and vendors are a problem. If they need one service, a monorepo gives them all of them — every other team’s code, every internal tool, and whatever is in the infrastructure directory. The usual answer is that such work stays out of the monorepo in a separate repository, which is a real constraint on how you organise and one that arrives unexpectedly the first time somebody wants to engage an agency.
Secrets in history are worse. A leaked credential in a monorepo’s history is visible to everybody with repository access — which is the whole engineering organisation rather than one team.
Write restrictions do exist through CODEOWNERS plus required review, and through push rulesets restricting which paths a push may touch. Read restrictions do not.
The mitigation is not to try to partition reads. It is to accept that a monorepo’s read boundary is the organisation, and to keep genuinely restricted material — regulated data pipelines, security-sensitive tooling — in separate repositories. Most monorepo organisations have a small number of repositories outside the monorepo for exactly this reason.
Release independence
Section titled “Release independence”A misunderstanding worth correcting.
One repository does not mean one release. Components in a monorepo are deployed independently — that is the normal arrangement, not a workaround.
What changes is how a release is identified. In a polyrepo, v2.4.1 of the payments service is a tag in the payments repository. In a monorepo, tags are repository-wide, so the convention becomes prefixed tags:
payments/v2.4.1checkout/v1.9.0frontend/v3.2.2Or version identity moves out of Git entirely, into the artifact: the image digest is what identifies what was deployed, and the commit SHA is recorded as a label. That is the approach Pillar 7 recommends and it works particularly well here, because a commit SHA in a monorepo is a much less useful release identifier than in a single-project repository.
Tag count feeds the ref limit. Prefixed tags across forty components with weekly releases accumulate, and tag retention becomes another operational task.
Organisational implications
Section titled “Organisational implications”The part that decides whether a monorepo succeeds, and it is not technical.
A monorepo makes the organisation’s coupling visible. Teams that were implicitly coupled through a shared library now share a repository, a CI pipeline and a review surface. That coupling always existed; the monorepo stops hiding it.
It requires a platform team. Somebody must own the build system, the CI scoping, the sparse checkout tooling and the repository’s health. In a polyrepo that work is distributed and mostly not done; in a monorepo it is concentrated and unavoidable. An organisation without the appetite to fund that team should not adopt a monorepo.
It changes what “my code” means. Engineers accustomed to owning a repository now own a directory. Some find that liberating and some experience it as loss of control, and the transition is a cultural change rather than a migration task.
It centralises decisions that were local. Which linter, which test framework, which language version. In a polyrepo each team chose; in a monorepo the shared toolchain implies a shared answer, and the negotiation is real.
It makes cross-team contribution possible and does not make it happen. The barrier drops from “get access to another repository” to “open a pull request”, and teams still need a reason to do it. This is the innersource problem, and a monorepo helps with the mechanics and not the culture.
Conway’s law applies in both directions. An organisation with strongly independent teams and a monorepo will experience constant friction; one with genuinely interdependent teams and a polyrepo will experience constant coordination overhead. The repository structure that works is the one matching how the organisation actually collaborates, which is the subject of the next article.
Migrating into a monorepo
Section titled “Migrating into a monorepo”A programme, and the ordering matters more than the mechanics.
-
Build the tooling first. Sparse checkout configuration, path-aware CI, the build graph if you are having one. A monorepo that receives code before the machinery exists is a bad experience that colours everything after.
-
Decide the directory structure, and get it reviewed by the teams moving in. Renaming a top-level directory later invalidates every sparse checkout configuration and every
CODEOWNERSpath. -
Write
CODEOWNERSbefore the first migration, not after. It is the review model, and a period without one teaches people that anybody reviews anything. -
Move one team first, ideally a willing one with a self-contained component. Learn from it.
-
Preserve history where it matters.
git subtreeor a filter tool can bring a repository’s history into a subdirectory. Whether it is worth it depends on whether anybody usesgit logon that code — for a five-year-old service, usually yes. -
Do not migrate abandoned repositories. A monorepo migration is a good moment to archive rather than move. Bringing dead code in makes it permanently everybody’s.
-
Move teams in waves, with the tooling improving between them.
-
Leave genuinely restricted material out. Regulated pipelines, security tooling, anything a contractor must not read.
-
Archive the source repositories rather than deleting them, once nothing references them.
Step 1 is the one that gets skipped, because migrating code is visible progress and building tooling is not. Every organisation that skipped it reports the same thing: the first six months were unpleasant and several teams campaigned to go back.
Common mistakes
Section titled “Common mistakes”Calling a large single-project repository a monorepo. Different problem, different remedies.
Adopting a monorepo without the apparatus. One repository, no ownership model, no path-aware CI — the costs without the benefits.
Assuming code sharing happens automatically. A monorepo makes sharing possible; teams still have to do it, and vendored copies inside a monorepo are common.
Confusing sparse checkout with partial clone. Enabling one and wondering why the other problem persists.
Path-filtered CI that ignores shared code. A library change appears to affect nothing.
No branch or tag hygiene. The 5,000-branch limit is reachable.
Expecting path-level read restrictions. They do not exist. Read access is repository-wide.
CODEOWNERS without required review. Routing, not enforcement.
Migrating everything at once. A monorepo migration is a programme, and the tooling should exist before the code arrives.
Repository policy inside a monorepo
Section titled “Repository policy inside a monorepo”Governance changes shape when one repository holds everything.
Repository-level settings become organisation-wide settings. A branch protection rule on the monorepo’s default branch applies to every team’s code. A required status check applies to every change. There is no per-directory equivalent.
Push rulesets can restrict paths. A ruleset can restrict which file paths a push may modify, which is the closest thing to path-scoped write control. Combined with CODEOWNERS and required review, it gives a workable write model even without read partitioning.
Required checks become a negotiation. In a polyrepo each team chose its own required checks. In a monorepo, a check required on the default branch runs for everybody — so the required set is either the intersection of what every team wants (very small) or a source of complaint.
The usual resolution is a small mandatory set applied repository-wide — no force push, required review, code owner approval, secret scanning — plus per-path checks that only run when relevant paths change, and are required only conditionally. Getting that conditional-requirement behaviour right is one of the harder CI configurations in a monorepo.
Bypass becomes higher-stakes. A bypass actor on the monorepo can bypass protections on everybody’s code. The list should be shorter than it would be for a single-team repository, and its use should be monitored.
The cross-link worth making: everything in enterprise rulesets applies, and a monorepo is the case where getting the layering right matters most, because there is no repository boundary to contain a mistake.
When a monorepo is the wrong answer
Section titled “When a monorepo is the wrong answer”The cases where the trade does not pay, stated plainly.
Genuinely independent products. Two businesses sharing an engineering department but no code. A monorepo gives them a shared CI pipeline, a shared review surface and a shared 5,000-branch budget in exchange for nothing.
Strict read-access requirements. Regulated code, contractor-developed components, anything where “the whole engineering organisation can read this” is unacceptable. There is no path-level read control, and designing around its absence is harder than keeping the code elsewhere.
No platform capacity. The apparatus is a real ongoing cost. An organisation that cannot fund somebody to own the build system and CI scoping will get the costs and none of the benefits.
Wildly different toolchains. A repository containing a Rust service, an iOS app, a data warehouse and a Windows desktop client shares almost no tooling, and the single-toolchain benefit evaporates while the coordination cost remains.
Open-source components. Code intended for public release inside a private monorepo requires an export process, and export processes drift.
An organisation in the middle of a reorganisation. The directory structure encodes an ownership model, and encoding one that is about to change is expensive to undo.
The honest summary: monorepos suit organisations whose components genuinely depend on each other and whose teams genuinely need to change each other’s code. Where those are not true, the boundary the repository provided was doing useful work, and removing it is a cost with no corresponding benefit.
Mental model
Section titled “Mental model”A monorepo is a decision to trade repository boundaries for atomic change, and to rebuild in tooling everything the boundary was previously doing for free.
Ownership was the boundary; now it is CODEOWNERS. Access control was the boundary; now it is partly gone. CI scope was the boundary; now it is a build graph. Working-tree size was bounded by the boundary; now it is sparse checkout.
The trade is frequently worth making. It is never free, and organisations that adopt a monorepo expecting the benefits without building the replacements get a large repository and a worse experience than they started with.
What you learned
Section titled “What you learned”- A monorepo contains multiple logical projects versioned together; size alone does not make one
- Atomic cross-component change is the property that justifies it; everything else follows
- The repository provides none of the ownership, CI scoping, or working-tree management it requires
CODEOWNERSreplaces the repository boundary for review routing, and only the last matching pattern applies- Sparse checkout manages the working tree; partial clone manages the download; they are different
- Path-filtered CI misses shared-code dependencies unless you handle that explicitly
- GitHub’s limits — 10 GB, 5,000 branches, 3,000 directory entries — are all reachable in a monorepo
- There are no path-level read restrictions; a monorepo’s read boundary is the organisation
- Components still release independently, using prefixed tags or artifact-based identity
Exercise
Section titled “Exercise”Use a disposable repository. No production repositories.
-
Create a repository with
services/a/,services/b/,libs/shared/,frontend/andtooling/, each containing a few files. -
Add a
CODEOWNERSwith a default rule, per-directory rules, and a cross-cutting**/Dockerfilerule at the bottom. Add a Dockerfile underservices/a/. -
Open a pull request touching that Dockerfile. Predict: which owner is requested — the service team, the platform team, or both?
-
Move the cross-cutting rule above the service rules and repeat. Predict: does the routing change?
-
Clone the repository with
--filter=blob:none --sparse, thengit sparse-checkout set --cone services/a libs/shared. List the working tree. Predict: what is present? -
Run
git log --onelinein the sparse clone. Predict: is the full history available? -
Add a workflow with a
paths:filter forservices/a/**. Change a file inlibs/shared/. Predict: does the service A workflow run, and should it? -
Delete the repository.