I. The decision was reasonable, which is the only reason it's worth writing about
I committed the screenshots.
Diagnostic captures from a debugging run, rendered figures out of a build pipeline, a few PDFs. All of it went into the repository next to the code that produced it, and I had four reasons.
Versioning: a figure and the code that rendered it moved together, so any commit handed me the exact pair. That one isn't theoretical. When a chart looks wrong three weeks later, the question is always whether the chart is stale or the code changed, and a repo where both live in the same commit answers it in one command instead of an afternoon.
Portability: anybody who cloned the repo got everything, with no second download and no step where they ask me for the assets. One source of truth, which is a standing rule around here and a good one, because the alternative is a bucket that drifts out of sync with the code and nobody notices until a build ships the wrong picture. And no second system, meaning no lifecycle policy to maintain, no separate credentials that expire on a schedule nobody tracks, and no class of bug where the reference survives and the file doesn't.
All four are still true today. Nothing I learned later made any of them false.
I didn't get sloppy, and I didn't skip a review. I ran the review I knew how to run, and every check came back clean, because every check I ran was a benefit check. A tool's benefit and its cost show up on different schedules and in different units, and the benefit is the one you can see on the day you adopt it.
The cost arrived months later, in bytes, on a disk that ran out.
Not dramatically, either. There was no moment where something exploded. A build failed on a step that had worked a hundred times, the error had nothing to do with storage on its face, and it took a while to work back from the failure to a drive with nothing left on it. By the time the cause was legible, the cost had been accruing for months in a place I had no reason to look, which is the defining property of this kind of cost rather than an incidental detail of my particular week.
The shape of what happened matters more than the specifics. Committed media is cheap in the moment and permanent afterward. Git's storage model makes it permanent in a way I didn't understand. And the way I work multiplies whatever's in a checkout by the number of checkouts I keep open, which on a busy week is dozens. Three facts, each one boring alone. Together they took a few gigabytes of screenshots and turned them into most of a drive.
None of the three is a git defect. Git does exactly what it says. The gap was in me: I evaluated what the tool would give me and never once wrote down what it would charge me, because on the day I decided, the charge was zero.
That gap has a name and a long paper trail, and I'll get to both, because a lesson with real prior art behind it beats one I invented last month. First the mechanism, since the mechanism is what carries the lesson over to whatever tool you're adopting this week.
II. What git actually does with a binary
Git is cheap for text because of delta compression. Git stores one version of a file in full when it packs objects, then describes the other versions as differences against it. A thousand-line source file edited two hundred times doesn't cost you two hundred file-sized objects. It costs one, plus two hundred small descriptions of what moved.
That mechanism does close to nothing for a PNG.
A PNG is already compressed. So is a JPEG, an MP4, most PDFs, and every asset a render pipeline emits. Compression works by removing redundancy, so a compressed file has already had its redundancy removed, and what's left looks like noise to a differ. Change one pixel and re-encode, and the bytes on disk have almost nothing in common with the bytes from the previous version. Git can't describe the second one as a small edit of the first, because it isn't one. Each version lands as its own full-sized object. Twelve rounds of a figure is twelve figures on disk, not one figure and eleven notes.
Then the second half, which is the part that cost me: history is permanent.
Delete the file. Commit the deletion. Push it. You have freed exactly nothing. The blob still sits in every commit that ever carried it, because that's what a version control system is for, and every one of those commits still has to be reconstructible. The only way to get those bytes back is to rewrite history, which means every collaborator re-clones and every existing reference to a rewritten commit breaks.
So the cost profile is unusual and worth stating plainly. It's monotonic. It never goes down on its own. Ordinary maintenance doesn't touch it. And it accrues from the first commit while staying invisible until it's large, because nothing in the tool says anything at the moment you add the file. git add poster.png produces the same silence as git add handler.py.
One correction before I overstate it, since overstating a mechanism is how you end up with a confident wrong model of a tool. Git does still compress packed objects, and a binary isn't literally stored as raw bytes every time. What fails is the delta step, the one that makes text nearly free. The practical result is the part that matters: repeated versions of media accumulate at close to full size, and no amount of git gc recovers what deltas would have saved on text.
The platforms know this, which is the best available evidence that it's a class of problem rather than my personal error. GitHub warns on any file over 50 MiB, blocks anything over 100 MiB outright, recommends repositories stay under 1 GB, strongly recommends under 5 GB, guides 10 GB for the on-disk .git directory, and enforces a hard 2 GB limit on a single push. GitLab.com defaults to a 10 GB repository size including LFS objects and a 5 GiB push limit, configurable per project, group, or instance, and rejects the push when you exceed it.
| Limit | GitHub | GitLab.com (default) |
|---|---|---|
| Single file | 50 MiB warning, 100 MiB hard block | Governed by the repository limit |
| Repository | Under 1 GB recommended, under 5 GB strongly recommended | 10 GB including LFS objects |
On-disk .git | 10 GB guidance | Configurable per project, group, or instance |
| Single push | 2 GB | 5 GiB |
Nobody writes limits like these for a hypothetical. They exist because enough people committed enough binaries to make them necessary.
I was nowhere near a single-file limit. Not one asset came close to 50 MiB. Every individual decision I made sat comfortably inside every published guideline, which is precisely how the total got where it got: the limits are stated per file and per repository, and what cost me was neither.
III. Worktrees, and the multiplier I hadn't priced at all
A git worktree lets one repository have several working directories checked out at once, each on its own branch. One shared object store, many working copies. That's the whole feature, and it's genuinely good.
I use them constantly. Every parallel lane of work gets its own worktree, because a worktree is the cleanest scoping primitive I've found: the files under that path are exactly that lane's working set, so two lanes editing the same file stop being a merge problem and start being two directories. When several lanes run at once, that isolation is what keeps the arrangement tractable. I'd recommend the practice to anybody. I still do.
Now hold that next to a repository carrying a few gigabytes of committed media.
The mechanism deserves precision, because I had it wrong in my own head for a while, and I had it wrong in the direction that made me look worse rather than better. A worktree does not copy history. The object store, meaning every commit and every blob, is shared once across all the worktrees attached to a repository. What each worktree gets is its own checked-out copy of the tracked files plus a small amount of metadata pointing back at that shared store.
So the accurate sentence is: one object store, N working directories. Not N repositories.
That correction makes the accounting less bad than my first read of it, and it fixes nothing, because checked-out files are exactly where the media lives. Every tracked PNG on that branch exists as a real file on disk in every worktree that has it checked out. History gets shared. Pictures don't.
Then it's arithmetic, and the arithmetic is boring, which is the problem. A few gigabytes of committed media, times dozens of open checkouts, is most of a drive. Not a spike, not an incident, nothing that looks like a failure while it's happening. Just a number climbing at a rate nobody set an alarm on, until a build failed for a reason that had nothing to do with the build.
Two things about that number make it hard to see coming. It's superlinear in a way that doesn't feel superlinear: adding one worktree costs whatever the working tree currently weighs, so the marginal cost of a worktree grows every time somebody commits another asset, and the marginal cost of an asset grows every time somebody opens another worktree. Both operations feel free at the moment you perform them, because each one is individually small. And neither shows up anywhere you'd routinely look. git count-objects -vH reports the object store, which is shared and therefore fine. The repository on the hosting side looks fine, because it is fine. The number that matters is the sum of directories on a local disk, and no git command reports it, because from git's point of view it isn't one thing.
Committing the media was defensible, and I'd defend it again on a repository of pure text. Running a worktree per lane is house discipline, and it's the right discipline. Both halves passed review. The cost didn't live in either half.
Neither decision was wrong. The cost lived in the interaction between them, and I never reviewed the interaction, because I'd reviewed each half on its own and each half came back clean.
Reviews are organized around decisions, and costs don't respect that boundary. A per-decision review catches a bad decision and structurally cannot catch two good decisions whose product is expensive. Multiply anything by anything and you get a third number that neither factor's review ever looked at.
It gets worse when the two decisions are separated in time, which they usually are. The media went in during one stretch of work. The worktree-per-lane practice arrived later, for unrelated reasons, and was evaluated on its own merits against the problem it solved. Nobody re-opens a settled decision because a new and unrelated one just landed, and there's no review that fires on "these two existing things now compose." Both decisions were correct when made and remained correct afterward. The product of them was never anybody's agenda item.
The general form: whenever you adopt a tool that multiplies something, go find out what it multiplies. Worktrees multiply the working tree. Containers multiply the image. Parallel CI multiplies the cache. Replicas multiply the storage. Every one of those is a fine thing to adopt, and every one of them turns some other quantity you already have into a bigger quantity, and the multiplier is invisible on the day you adopt it because the thing it multiplies is small that day.
IV. The checkout that made the point
I wrote this note in a fresh worktree.
Creating it took minutes, most of that spent writing twelve thousand eight hundred files to disk, because that's what checking out a branch carrying committed media does. I sat and watched the counter for a while.
V. Every fix moves the cost, and one of them barely moves it at all
There's a standard answer to all of this, and the standard answer is Git LFS. Large files get replaced in the repository by small pointer files, the real bytes live on a separate server, and history stops carrying them. It works. It's the right call for plenty of teams.
It also has a meter, and the meter has two dials.
GitHub bills LFS on storage, charged hourly, and on bandwidth, charged per gibibyte downloaded. Free and Pro accounts include 10 GiB of each per month; Team and Enterprise Cloud include 250 GiB of each. Past that it's $0.07 per GiB-month for storage and $0.0875 per GiB for bandwidth. If the spending budget sits at $0, LFS usage gets blocked for the rest of the month once the included quota is gone, which converts a money problem into an availability problem at the worst moment.
Look at which dial is the dangerous one. Storage tracks how much you keep, which is roughly the number you were already thinking about. Bandwidth tracks how much gets downloaded, and most of that downloading is CI, pulling the same assets on every fresh checkout, every run, forever. A repository that's cheap to keep can be expensive to move, and the move happens on a schedule set by your merge rate rather than by anything you decided.
So LFS doesn't remove the cost. It converts a disk-space cost into a metered-bandwidth cost, and metered costs scale with automation while disk costs scale with content. For a team merging all day, that trade can go either way.
The same thing is true of every other option, which is the part I'd want somebody to tell me before I picked one.
Shallow clone (--depth=N) truncates history, so the clone is small. It also limits which commands work at all, produces behaviour people find surprising, and pushes stress onto later fetches. GitHub's own guidance discourages it for day-to-day developer use and points at CI as the place it belongs.
Partial clone (--filter=blob:none) keeps every commit and tree and defers file contents until something asks for them. Nothing is lost, and nothing is free either: checkout, blame, or reading an old file triggers a fetch for the missing objects. The bill is deferred to first access rather than removed, and a blobless clone used broadly enough drifts back toward the size of a full one. It also needs the server to support serving those objects on demand, so some of the work moves to the host.
Sparse-checkout reduces what gets written into the working tree without reducing what gets transferred. Which makes it the one that matches my problem, since my problem was never transfer. My problem was N copies of the working tree.
An external store, whether that's object storage, a package registry, or something purpose-built like DVC or git-annex, takes the bytes out of git entirely. The price is atomicity: one commit no longer describes the full state of code plus assets. You now maintain a second lifecycle policy, a second set of credentials, and a class of failure where the pointer outlives the thing it points at.
| Option | What it actually fixes | Where the cost lands instead |
|---|---|---|
| Git LFS | History stops carrying large blobs | Metered storage plus metered download bandwidth, scaling with CI rather than with content |
| Shallow clone | Initial transfer size | History-dependent commands break; later fetches carry the stress |
| Partial clone | Initial transfer size, with history intact | Deferred fetches on first access; server serves objects on demand |
| Sparse-checkout | Working-tree size on disk | Transfer is unchanged; you have to know which paths you need |
| External store | Git stays small and fast | Code and assets stop being atomic; second auth, second lifecycle, reference rot |
My own thesis doesn't fully hold here, and I'd rather write that down than route around it.
I went into this expecting to find that every mitigation is a trap. It isn't. Partial clone combined with sparse-checkout is close to genuinely low-friction, provided the workflow is narrow and stays narrow. If a lane only ever needs a slice of the tree and a shallow slice of history, both mechanisms do what they say with very little coming back at you later. The deferred-fetch cost only materializes when somebody wanders outside the slice, and a disciplined CI job never does.
So "every benefit has a paired cost curve" is too neat as a slogan, and I'm not going to pretend otherwise to keep a tidy argument. The defensible version is narrower and more useful: these mitigations are cost transpositions rather than escapes, and the ambush comes from the new cost being invisible at the moment you adopt. Sometimes the new position is much better than the old one. That's a good trade, and it's still a trade, and knowing which dimension you just moved the cost into is the difference between choosing a trade and discovering one.
VI. The pattern already has a name, and it isn't mine
I went looking for prior art expecting to find a gap. There isn't one. What I ran into was a well-worn idea with a century of paperwork behind it, and the useful move is to stand on the citation instead of coining something.
Total cost of ownership is the umbrella and it fits almost exactly. Its claim is that purchase price is one component of a lifecycle cost that also includes support, maintenance, training, integration, migration, downtime, and ongoing operations, and that those components frequently dominate. Swap "purchase price" for "the benefit I evaluated at adoption" and that's this entire note in one sentence. TCO is broad and a little bloodless as a phrase, which is probably why it kept not occurring to me while I was busy being surprised.
Externalities sharpens it for the case where the bill doesn't land on whoever adopted the tool. The classical formulation, associated with Pigou, is that an actor's decision can impose costs on parties the price never accounted for. That's the exact structure of a developer committing assets and an infrastructure budget absorbing it, or an engineering team turning on verbose logging and finance meeting the result a quarter later. Private incentive underprices system cost. Nobody has to behave badly for it to happen.
Unit economics, and FinOps as its operational discipline, is the practice half. Cost per unit of output, measured continuously, attributed to whoever generates it, rather than reconstructed from an invoice after the fact. This is the one with an actual method attached.
Two more get cited around this idea and neither is the right label, so worth being precise about the fit.
Jevons paradox, from William Stanley Jevons in The Coal Question (1865), says efficiency gains can raise total consumption because the lower cost per use expands the number of uses. It's a real neighbour of this problem and it describes something specific: a rebound effect driven by efficiency. It isn't a general theory of unmetered adoption cost. Worktrees made checkouts cheap enough that I kept far more of them open than I otherwise would have, so there's a genuine Jevons component in my story. It's a component, not the frame.
Technical debt, Ward Cunningham's coinage, is about a deliberate shortcut creating a future obligation that accrues interest. It rhymes, and it's a partial fit at best, because my problem wasn't a shortcut. I did the thorough version of the wrong analysis. Nothing was deferred; a whole dimension was never opened.
So the defensible sentence, which I'd rather write than a slogan with my name on it: this is a total-cost-of-ownership problem with an externality attached, where the first-order benefit is immediate and visible and the operating cost curve arrives later, in a different meter, on a different budget.
What I'd add to the prior art, and it's small, is where it bites in engineering tooling specifically. TCO literature grew up around procurement, where somebody signs something and a finance function is watching. Developer tooling has no signing moment. You adopt a tool by typing a command, and there's no purchase order, no vendor review, no line item, and no meeting where anybody asks what this costs at scale. The decision that starts the meter looks exactly like a decision that starts nothing.
VII. The same shape, in tools that aren't git
Once the shape is visible it turns up everywhere, and the tools below are ones I use and like.
Docker layer caching. Adopted for build speed, and it delivers. The cost curve is retained layers, registry storage that grows in a place nobody checks, and pull times that creep. Some providers meter it directly: CircleCI charges 200 credits per job for Docker Layer Caching, which puts the number on the invoice as its own line item. The same cost exists everywhere else and is just harder to find.
`node_modules` copied into an image. Adopted for reproducibility and install speed. Paid in layer size, registry storage, and CI bandwidth on every pull, which is why so much Docker advice is really advice about .dockerignore and multi-stage builds.
CI caches. Adopted to avoid recomputing what hasn't changed. The cost is storage plus a pruning policy that has no owner, because cache governance is nobody's favorite work and a stale cache does its damage quietly, sometimes as a wrong build rather than a big bill.
Observability retention. Adopted for debugging power, which is worth paying for and worth designing deliberately. The structure is what makes it dangerous: ingest, indexing, custom metrics, and retention are separate meters. Datadog, as one documented example, bills log ingest and log indexing as different lines, so usage can climb on a dial nobody is watching while the dial everybody is watching looks fine. High-cardinality metrics are the classic escalation, since cardinality grows from a code change rather than from a decision anyone recognized as a cost decision. A developer adds a tag carrying a user ID or a URL, which is an obviously good idea for debugging, and the number of distinct time series goes from hundreds to however many users you have. No line in that diff says "cost."
Notice what those four have in common, because it's the same property that got me. In every case the multiplier is a thing the team deliberately adopted and would adopt again: caching, reproducibility, parallelism, visibility. None of them is waste. Each one takes an existing quantity and repeats it, and repetition is exactly the behavior that was wanted. The cost isn't a side effect of the feature working badly. It's the feature working.
One piece of the research changed what's in this section, and it's worth handing over directly.
I went looking for named incidents with dollar figures. The famous bill-shock stories are all over the internet and almost none of them trace to a primary source. The pattern is a number in a vendor-comparison blog post, cited by another blog post, cited by a third, with no original invoice, ticket, talk, or postmortem anywhere underneath. I found a widely repeated five-figure overage attributed to unique-URL metrics and could not get it back to anybody who was actually there.
So there are no incident numbers in this note. The incidents almost certainly happened. I just couldn't verify the ones I found, and a number I can't trace is worth less to you than no number at all.
That's the transferable part. Vendor-documented pricing mechanics are citable, checkable, and dated. Anonymous horror-story totals are content marketing, and they're specifically the content marketing of companies selling the alternative. When somebody quotes you a number for what a tool cost a team, ask where the number came from. The mechanics are the real evidence. The totals are usually somebody's funnel.
VIII. What to do at adoption, while the cost is genuinely zero
The whole difficulty is that adoption day is the worst possible day to think about cost, and it's also the only day the thinking is cheap. The tool is exciting, it just solved something, the meter reads zero, and every number you'd want to reason about is a number you'd have to make up. Later, when the numbers are real, the tool is load-bearing and the analysis has turned into a migration.
Five moves, in order. None of them take long, and the first one does most of the work.
- Name the meter, then name the second one. Write down the unit this tool bills in: bytes stored, bytes moved, requests, seats, runs, retention days, concurrent instances. Then keep going, because almost every tool that surprised anybody had more than one meter, and the ambush is never the meter on the pricing page's headline. LFS quotes storage and gets you on bandwidth. Observability quotes hosts and gets you on cardinality. Git doesn't quote anything at all, which is its own answer to the question.
- Model the curve, not the value. The value today is zero and tells you nothing. What you want is the shape: what does this cost at ten times my current usage, and what drives the ten. If the driver is something you control, fine. If the driver is automation, merge rate, or team size, the curve is going to move without anybody deciding to move it. Write the driver down next to the meter, because the driver is what you'll actually watch, and it's usually a number you already track for other reasons.
- Set the alarm now. A budget alert, a disk-usage check, a repo-size check in CI. At adoption it's a two-minute task nobody argues about. After the first surprise it's a retro, a remediation, and a policy. Same alarm, wildly different price. An alarm is also the only kind of owner that survives, which is the argument that every artifact class needs a mechanical owner rather than somebody's standing discipline.
- Price the exit. What does leaving cost, in migration work and in whatever gets rewritten. Tools accumulate stickiness quietly, and history rewriting is the git-flavored version of an exit cost that starts near zero and grows monotonically. Worth noticing which exit costs grow with usage and which stay flat: a flat exit cost means you can defer the decision cheaply, and a growing one means deferring is itself a decision.
- Decide how much of the above this deserves. Bezos's framing is the useful filter: a two-way door gets a quick look and a reversible trial, and a one-way door gets the full pass. Committing binaries is a one-way door wearing a two-way door's clothes, because
git addis trivially reversible and the history it creates is not.
Klein's pre-mortem is worth running on anything in the one-way column: assume it's a year later and this went badly, and say why. The answer is almost always a meter nobody named in step one, which is what makes the ritual worth the fifteen minutes.
For git specifically, the version of this I'd hand to somebody starting a repo today.
Decide at the first binary, not the hundredth. The first one is a decision and the hundredth is a habit, and nothing between them will feel like a moment worth stopping for. If the assets are derived, meaning some renderer or build step produces them from something else in the repo, then the renderer is the source of truth and the output doesn't belong in history at all. Ignore it, commit the thing that generates it, and accept that a fresh clone has to build before it can look. If the assets are source, meaning nothing in the repo can regenerate them, then they're real inputs and they need a real home, which is either LFS or an external store, chosen by whether you'd rather pay in bandwidth or in atomicity.
And if you're running worktrees over a repo that already carries media, sparse-checkout is the lever that matches the problem, because it's the only one of the five that reduces the working tree rather than the transfer.
IX. Where this leaves me
The mechanism, in one paragraph, for handing to somebody else. Git can't delta-compress already-compressed files, so every version of a binary lands at close to full size. History is permanent, so deleting the file frees nothing without a rewrite. Nothing warns you at commit time, so the cost accrues from the first commit and stays invisible until it's large. And any practice that multiplies working copies, worktrees being the one that got me, multiplies whatever is in them. Four properties, all documented, none of them defects, and I'd checked exactly zero of them before deciding.
I still commit media in some repos. The four benefits I opened with are real, and I'd take them again on a small repo with one checkout and no CI pulling assets on every run. What changed is that I write down what it costs before I do it, and I know which lever to reach for when the answer comes back wrong.
If you've never had a tool arrive with a bill you didn't see coming, this note has nothing for you and I'd rather you spend the time elsewhere. And if you came here for a list of tools to avoid, I don't have one and wouldn't trust it if I did. That list changes every year. Name the meter on the day you adopt, while it reads zero and looking costs you nothing.
Sources worth your time: the `git worktree` documentation at git-scm.com, for the shared-object-store-many-working-trees model stated by the people who built it. GitHub's "About large files on GitHub" and "Repository limits" pages for the file, repository, and push limits, and GitLab's account and limit settings documentation for its defaults. GitHub's "About billing for Git Large File Storage" for the two-dial LFS meter and the current per-GiB rates. The GitHub blog's "Get up to speed with partial clone and shallow clone" for what each mechanism does and what it costs you afterward, and the partial-clone documentation at git-scm.com for the promisor-remote side of it. The openFrameworks wiki page on moving binaries out of the repo, and inovex's writeup on migrating binary bloat, for two independent accounts of the same slow failure. Jevons, The Coal Question (1865), for the rebound effect, which is a neighbour of this problem and not its name.