Every codebase of a certain age has them: three logging helpers, two HTTP clients, an authentication middleware marked @deprecated in 2023 with a comment pointing at a replacement that was itself deprecated in 2025. Nobody defends this state. Everybody contributed to it.
The reason is structural, not cultural. Announcing a deprecation is free and feels like a decision. Completing one is a scheduled project with an owner, a budget and a date, and almost nobody writes that part down at the same time as the announcement.
The announcement is five percent of the work
A deprecation has four phases, and teams routinely execute the first and skip the rest.
- Declare. Mark it, document the replacement, tell people. An afternoon.
- Enumerate. Find every caller. Days to weeks, and the number is always higher than anyone guessed.
- Migrate. Change every caller, including the ones owned by teams who have never heard of you. Weeks to quarters.
- Remove. Delete the code. Ten minutes, blocked on phase three for eighteen months.
The value only arrives at phase four. Until the old path is gone you carry both: two code paths to test, two behaviours to reason about during an incident, two sets of security assumptions, and a documentation surface that now describes a choice nobody wants users to have to make.
A deprecation stuck at phase three is strictly worse than never having started. You have paid the cost of divergence and collected none of the benefit of consolidation.
Deprecation is the only engineering activity where stopping at ninety percent leaves you worse off than stopping at zero.
Count callers, not versions
The usual progress metric is adoption of the new thing: how many services are on client v3. This is comfortable and misleading, because it counts the wrong population. A service can be on v3 and still call the deprecated method inside it. Version adoption tells you about packaging. You need to know about behaviour.
Instrument the deprecated path itself and emit who is calling it:
deprecation.hit
symbol auth.verify_token_v1
caller billing-worker
caller_team payments
call_site billing/worker/renew.py:212
count_24h 18400
first_seen 2026-03-02
last_seen 2026-07-29
Now the migration has a work list rather than a vibe. It also has something more useful: a distribution. You will nearly always find that the calls follow a power law. Three callers account for ninety percent of traffic and are easy to find. Then there are forty callers with a handful of requests each, and those forty are the entire remaining cost of the project.
The long tail is where deprecations die, and it dies for a boring reason: each of those forty is somebody else’s low priority. The work is small and the coordination is not.
Graduated friction, applied on a published schedule
Asking politely does not move a low priority. Changing the cost of inaction does. The mechanism that works is friction that increases on a date announced in advance, so nobody is surprised and everybody can plan.
A schedule that has worked in practice, for an internal API:
- Week 0. Log a warning once per process start. Quiet, discoverable, ignorable.
- Week 4. Warning per call, with the call site. Now it is in their logs, not yours.
- Week 8. A response header, and a weekly automated message to each owning team listing their remaining call sites. Not a broadcast. Their list, their name on it.
- Week 12. Inject latency. Fifty milliseconds, then two hundred. This is the step people find uncomfortable and it is the step that works, because it converts your deprecation into their latency graph.
- Week 16. Fail a fixed small percentage of calls, during business hours only, in non-production first.
- Week 20. Remove.
Two rules make this defensible rather than hostile. The dates are published at week zero and do not move for convenience. And every stage is reversible in one configuration change, so if something genuinely critical surfaces at week sixteen you roll the friction back and negotiate, rather than causing an incident to prove a point.
Somebody has to own the date
The most common failure is not technical. It is that the deprecation belongs to a team that has no authority over the callers, so the removal date is a suggestion made by someone who cannot enforce it.
This is worth naming explicitly at declaration time. Either the owning team has a mandate to apply the friction schedule without asking permission each step, or the deprecation needs a sponsor who does. If neither is true, the honest move is not to announce the deprecation at all. Leaving the old path in place, unmarked, with a note in the code explaining why it survives, is more truthful than a @deprecated annotation that will still be there in three years.
The external case is the same problem with worse leverage
For a public API, latency injection and partial failure are off the table. What remains is a longer schedule, a firm date, and per-customer visibility: a dashboard that shows each integrator their own remaining calls, and an email that goes to the person whose name is on the API key rather than to a support alias.
And one thing that is easy to forget: publish the removal date before you have finished the replacement. Teams routinely delay announcing until the new path is perfect, which compresses the migration window into the period where they have least slack. The date is the forcing function. Set it early, and let the replacement improve while callers are already moving.
The test for whether you meant it
At the moment you mark something deprecated, write down four things: who owns the removal, what date it is removed, how you will count remaining callers, and what escalating friction you are willing to apply. If any of the four is blank, you have not started a deprecation. You have added a comment.

