Every deprecation guide tells you how to signal that a module is going away. None of them tell you who to signal. Deprecating an internal library is a consumer-census problem, and the census is the part nobody hands you.
Someone on your team did everything the guides tell you to do.
They had built an internal library years ago. Call it @acme/auth, a shared package half the org imports for token handling. It had outlived its design, a cleaner replacement existed, and it was time to pull it. So they did it by the book. They added the @deprecated tag to the exported functions. They cut a major version so the removal would land on a semver boundary nobody could miss. They wrote a migration guide with before-and-after snippets. They posted in the engineering announcements channel, twice, a month apart. They set a sunset date a full quarter out. And on the date, they removed it and shipped the major.
For a fortnight, nothing. Then a payments service fell over in staging on a Tuesday afternoon, and the on-call engineer spent two hours tracing a build failure back to a package that, as far as anyone in the channel knew, no longer had any consumers. The service had pinned the old major eighteen months earlier. It had never seen the editor warning, because nobody was actively developing it. It had never read the announcement, because the team that owned it had reorganised and the new owners were not in the channel when the message went out. It had simply kept building against the version it was locked to, quietly, until the version went away.
The part I keep coming back to is that this deprecation was not done badly. It was done well. Every step in the standard playbook was executed, and the playbook still let a consumer break. Because the playbook is about one half of the problem, and the half that actually bit was the other one.
The deprecation playbook is all signal
I want to be fair to the playbook, because most of it is correct and hard-won, and the people who wrote it down were solving a real problem.
The advice converges across ecosystems. In JavaScript and TypeScript you add a @deprecated JSDoc tag, you reach for console.warn in the function body, and you remove the symbol only on a major version because that is the boundary semver tells consumers to expect breakage on. In Python you raise a DeprecationWarning, or you wrap the thing in one of the deprecation decorator libraries that write the boilerplate for you. In Go you put a // Deprecated: line in the doc comment. Across all of them you write a migration guide, you announce it where your consumers can see it, and you give people a sunset window rather than yanking the thing from under them. The npm docs put the underlying ethic plainly: deprecate rather than unpublish, because unpublishing pulls the package with no warning to anyone who relied on it.
This is genuinely good practice. Do all of it. None of what follows is an argument against signalling a deprecation properly.
But read back over that list and notice what every item has in common. The @deprecated tag annotates a symbol your consumers import. The migration guide is written for your consumers. The announcement is addressed to your consumers. The sunset date is a promise to your consumers. Every single step takes the set of consumers as an input it already has. The playbook starts at the point where you know who they are.
Every step assumes you already have the list
Here is the quiet conflation. We talk about deprecation as one task, and it is two.
One task is signalling: telling the consumers of a thing that it is going away, on a timeline, with a path off it. The other is the census: knowing who the consumers actually are. The playbook is entirely about the first and silently assumes the second is already done. And for an internal library spread across a polyrepo org, the second is the hard one. It is the one that decides whether the deprecation is safe, and it is the one nothing in the playbook does for you.
You cannot signal a consumer you cannot name. You cannot set a credible sunset date without knowing how many repos have to move and who owns them. You cannot estimate the migration effort, or stage it sensibly, or tell your own management how risky the removal is, until you have enumerated every repo that imports the thing and the version each one is pinned to. The annotation, the guide, the announcement: all of it is downstream of a list you were assumed to already hold. In a single app you do hold it, because the consumers are in the same repo and the compiler finds them. Across an organisation you do not, because the consumers are in other people’s repos and nothing walks all of them for you.
The signal is conditional, and often silent
It is tempting to think the signal itself solves the census. Surely if you mark the thing deprecated, the consumers find out. They do not, and it is worth being precise about why, because the failure is mechanical, not careless.
Take the editor warning first. A @deprecated JSDoc tag only surfaces for a developer whose tooling is configured to flag it, through a rule like eslint-plugin-import’s no-deprecated, and only when someone actually opens that repo and lints or rebuilds it. A service that is locked to the old version, building green in CI, with nobody in their editor that quarter, gets no signal at all. The annotation is sitting in a version of the package that repo is not even pulling.
Python is sharper still, and the default trips almost everyone. Since Python 3.2, DeprecationWarning is ignored by default for every module except __main__. PEP 565 re-enabled it in __main__ in 3.7, but a deprecated function imported from a library runs in the importing module, not in __main__, so the warning lands in a filter that drops it on the floor. A service can import your deprecated function, run it in production every day, and never emit a visible warning, unless it happens to run its test suite with warnings surfaced or someone has set PYTHONWARNINGS. The signal fires into silence.
npm has the same gap from the other direction. The deprecation message shows up as npm WARN deprecated during install and resolution. A repo with a committed lockfile that is not reinstalling does not resolve anything, so it does not see the warning. The message reaches new installs, not the repos that locked your old version a year ago and have not run a clean install since. For an internal package wired in through a workspace protocol or a private registry, the registry-level deprecation may not reach the consumer at all.
And then the announcement, which is the one humans trust most and should trust least. It reaches the people who read the channel and already understand that they are affected. The team that pinned your library eighteen months ago and forgot is, by definition, the team that does not know it needs to be reading. The announcement is a broadcast; the consumers you are most worried about are the ones not tuned in.
So even in the best case, where a consumer is actively developed and tested, the signal is conditional on tooling plus a rebuild. In the common case, where a consumer pinned a version and went quiet, the signal reaches no one. And the quiet consumers are exactly the ones that break, because quiet is what “we pinned it and stopped thinking about it” looks like from the outside.
So you go looking for the list
Once it is clear the signal will not assemble the census for you, you go and try to build it by hand. Every route you reach for gets you partway and stops at the same wall.
The internal package registry feels like the obvious source. If you run a private registry that records pulls per consumer, maybe you can read the list off download stats. In practice that data is rarely surfaced cleanly, CI caches inflate and distort the counts, and any repo that vendored the code, pinned a git URL, or wired the dependency through a workspace never shows up as a registry pull at all. Download numbers tell you something about traffic. They do not give you a clean set of repositories with owners.
The wiki page is worse, because it looks authoritative and is usually wrong. Someone wrote “consumers: X, Y, Z” once, eighteen months and one reorg ago, and a fourth team added itself the week after and never edited the page. This is the same decay that makes platform teams quietly abandon their service catalogues. A hand-maintained list of consumers is only ever as accurate as the last person who remembered to update it, and “remember to update the consumers page” is precisely the discipline that does not survive contact with a busy quarter.
Code search is closer, and for one ecosystem across a handful of repos, grep genuinely works. Across an org it becomes a string of false starts. The import can be aliased, re-exported from a barrel file, pulled in transitively through another internal package that depends on yours, or referenced by a version range rather than a literal you can match on. Walking that down by hand, repo by repo, is the work the Find Every Consumer series exists to document, one ecosystem at a time, and the recurring lesson of that series is that grep is where you start and not where you finish.
GitHub’s dependency graph looks like it should just answer this. It has a Dependents view built for exactly this question. Except GitHub only computes dependents for public repositories. Your internal library lives in a private org, which is the one case the feature does not cover.
And the reverse-dependency tools that do exist are scoped to the wrong thing. apt-cache rdepends and repoquery --whatrequires answer reverse-dependency questions for the packages installed on one machine, not for the repos in your org. jdeprscan and the eslint deprecation rules scan a single repo that already depends and recompiles, to find uses of deprecated APIs inside it. They are good tools. None of them answers “which repositories across my organisation consume this library,” because that question is not a fact about any one repo. It is a fact about the relationship between your library and every other repo, and not one of these tools is looking at all the repos at once.
Even automated migration needs the list first
The sophisticated end of this is real and deserves credit before I draw the line. There is a whole tier of tooling built to perform the migration across many repositories once you know where it has to land. Renovate and Dependabot open the version bump in each repo they are configured on. OpenRewrite applies structured, type-aware code transformations across a codebase. Allegro combined Dependabot and OpenRewrite into an in-house system to run migrations across more than two thousand microservices, precisely because doing it by hand when a company-wide library makes a breaking change is brutal and error-prone.
That is impressive engineering, and it is also the mechanical-edit layer, not the census. Renovate and Dependabot keep dependencies current and tell you nothing about who consumes what; they give you the bump, not the blast radius. Every one of these systems operates on the repos it is pointed at, and so it presupposes the list. The repos it was never pointed at, the ones that pinned the old version and dropped out of the automation, are the same quiet consumers from the scene at the top of this post. Automating the edit makes a known migration faster. It does nothing for the consumer you did not know you had, because that consumer was never in the set the automation was handed.
The list is a graph query, not a search
Strip the problem back and the thing you actually need before you deprecate anything is small and specific. Every repository that declares a dependency on this library, directly or transitively. The version each one pins, so you can separate the repos already off it from the ones stranded on the old major. The team that owns each repo, so you know who to route the migration to. And the order, so you migrate the internal package that re-exports your library before the leaf services that pull it in through that package.
That is a query against a graph of dependency edges, and the only honest way to build the graph is to parse it. Read the dependencies in every package.json, the require in every go.mod, the import in every pyproject.toml and requirements file, the source in every Terraform module block, the chart references in every Helm values file, the include in every GitLab CI config. Resolve the version each one pins. Normalise the aliases, the re-exports, and the workspace protocols back to the package they point at. Connect the edges. Parsed, not inferred. Not guessed from names that look similar, not reconstructed from a wiki someone edited last year, not pieced together from a Slack thread. Read from the manifests that already declare the dependency, because those files are the source of truth and they are also exactly where the migration is going to land.
The ecosystem changes the syntax, not the shape of the problem. The mechanics of doing this properly differ enough per ecosystem that they each deserve their own walkthrough, which is why the Find Every Consumer series takes them one at a time: internal npm packages, internal Python packages, Go modules, Terraform modules, and Helm charts each fight back in their own way. But the destination is the same in every case. A set of repositories, with versions and owners, derived from source.
The debt you keep because you cannot see the list
Here is the part that makes this more than an incident story, and it is the reason the census matters even when nothing is actively breaking.
When you cannot enumerate consumers cheaply, the rational response is to never remove anything. Think about what the alternative asks of you. To delete the old auth library, you have to be willing to say “nothing depends on this any more,” and if you are wrong, a payments service falls over and it is your name on the change. “I am fairly sure nothing uses this” is not a sentence anyone wants to be holding when the pager goes off. So the safe move, the locally rational move, is to leave it. Leave the deprecated library in place. Leave the forked Terraform module nobody has touched in two years. Leave the old endpoint running just in case. Mark it deprecated, maybe, and never actually pull it.
This is how internal platforms accrete years of undead code. Not because nobody wants to clean it up. Because removal is unprovable-safe, and the cost of being wrong is paid in production at an inconvenient hour. The module stays, the maintenance burden stays, the mental overhead of “is this still load-bearing” stays, and the next engineer inherits all of it plus the same fear that kept the last one from acting.
A cheap, trustworthy consumer census changes the calculus completely. When you can answer “what depends on this” in one query, with versions and owners, removal stops being a gamble and becomes a decision. You see the four repos still on the old major, you route the migration, you watch the number go to zero, and then you delete with confidence instead of hope. The census is not only the thing you reach for in an incident. It is the thing that lets a codebase shrink at all. Without it, the only safe direction is accretion.
Two halves of deprecation
So, stripped down. Deprecation is two jobs wearing one word, and the departure of that payments service showed which one the playbook forgot.
One job is signalling: tell your consumers the thing is going away, on a timeline, with a path off it. The playbook is all about this half, and it is good at it. The @deprecated tags, the semver majors, the migration guides, the announcements. Do every bit of it.
The other job is the census: know who your consumers are in the first place. Nothing in the playbook does this half, and every part of the playbook silently assumes it is already done. The signal turns out to be conditional and frequently silent, the obvious sources of the list are stale or scoped to the wrong thing, and even the automation that performs the migration has to be handed the list before it can run.
The signal tells your consumers. The census tells you who they are. Those are different artefacts, built from different sources, and on the morning a forgotten service falls over, the second one is the only one that would have prevented it. You can deprecate a library in an afternoon. Knowing who was still standing on it is the part you never had.
This is the query Riftmap is built to answer. Point it at your GitHub or GitLab organisation with one read-only token and it parses the dependency edges across every repo. npm, Python, Go, Terraform, Helm, Docker, GitHub Actions, GitLab CI, and the rest. “What depends on this library, at which version, owned by whom” becomes one query instead of one archaeology dig. Before you mark anything deprecated, you get the census the playbook assumed you already had: every repo still on it, the version each one pins, the team to route the migration to. The annotation tells your consumers. Riftmap tells you who they are.
Riftmap maps cross-repo dependencies across your entire GitLab or GitHub organisation — Terraform, Docker, CI templates, Helm, npm, Go, Python, and more. One read-only token. No YAML to maintain. The free tier is here.
Appendix: the argument in short
Claim. Deprecating an internal library is treated as a single task and is actually two. Signalling (telling consumers it is going away) is what every guide documents and what the tooling is good at. The consumer census (knowing who the consumers are across your repos) is the part nothing in the playbook does, and it is the part that decides whether the removal is safe. The signal is conditional and often silent, the manual sources of the list are stale or wrong-scoped, and even automated migration presupposes the list. The census can only be built by parsing the dependency edges your manifests already declare across every repo.
Consumer census. The set of repositories that depend on a given library, with the version each one pins and the team that owns it, derived from the manifests across an organisation rather than from a registry, a wiki, or an announcement.
FAQ.
- Will a deprecation warning reach consumers who pinned an old version? Often not. An npm deprecation surfaces as
npm WARN deprecatedduring install and resolution, so a repo with a committed lockfile that is not reinstalling never sees it. Python’sDeprecationWarningis ignored by default for every module except__main__, so a service that imports a deprecated function and runs it in production emits nothing visible unless it runs tests with warnings surfaced. A JSDoc@deprecatedtag only flags for a developer whose linter is configured for it and who rebuilds the repo. The consumers you most need to reach are the quiet, pinned ones, and they are the least likely to see any signal. - How do I find every repo that depends on an internal library before deprecating it? Not from the warning, which is conditional and silent for inactive consumers. Not from a hand-maintained consumers page, which goes stale the next time anyone adds a dependency. Not from GitHub’s Dependents view, which only computes dependents for public repositories. You parse the dependency edges your manifests declare (
package.json,go.mod,pyproject.toml, Terraformsource, Helm references, CI includes) across every repo, and query the resulting graph. - How do I find consumers of a private package across repositories? Reverse-dependency tools like
apt-cache rdependsanswer for one machine, and GitHub’s dependency graph only covers public repos. For a private org, the consumer set has to be parsed from the manifests in each repo and assembled into a cross-repo reverse-dependency graph. - What is the first step in safely deprecating a shared internal library? The consumer census, before any annotation or announcement. You cannot set a credible sunset date, estimate the migration effort, or stage the rollout until you know which repos consume the library and at which version.
Related reading.
- How to find every consumer of your internal npm package — the per-ecosystem mechanics for the most common version of this problem.
- How to find every consumer of your internal Python package — the same census, for Python’s packaging and import surface.
- A CVE just hit your base image. Your scanner won’t tell you which repos to fix — the reactive twin of this post: same cross-repo-versus-single-artifact gap, forced by an external shock instead of a planned removal.
- Your senior engineer just left. Your bus factor was measuring the wrong thing — what happens when the person who held the consumer map in their head walks out.
- The catalog maintenance trap — why the hand-maintained consumers page is wrong by the time you read it.
- Monorepo vs polyrepo: the debate is measuring the wrong thing — why “what depends on this” being queryable matters more than where the code lives.
- What is cross-repo dependency mapping? — the glossary definition of the parsed graph this post keeps pointing at.