DevOps · Release Engineering
Deprecation Windows and the Machinery Behind Compatibility Shims
How an old interface is kept working while its replacement is adopted, what a shim must translate, and how to decide when removal is safe rather than merely scheduled.
Removing an interface is the only irreversible act in a release process. Everything else can be reverted, superseded, or patched forward. Once a call site’s symbol is gone, every consumer of it breaks at a moment the producer does not control. Deprecation is the procedure that converts that single cliff into a sequence of small steps, and compatibility shims are the code that lets the old and new forms coexist during the sequence.
The sequence has a shape that is the same across ecosystems, and the interesting engineering is in the second and third steps: how the old form is kept working, and when it becomes safe to delete.
The four-step sequence
Announce in the artifact, not in a document. A deprecation notice in release notes reaches consumers who read release notes, which excludes most of them. A notice in the artifact, such as a compiler warning on the symbol, a runtime warning the first time the path executes, a response header on the endpoint, a field marked deprecated in the schema — reaches consumers who build or run. The difference in reach is the difference between a deprecation that works and one that is a formality.
Keep the old form working, exactly. A deprecation is not a behaviour change. The old call must produce the old result, including edge cases and error behaviour. Any deviation during the window converts a planned migration into an unplanned outage for consumers who have not migrated yet, which is the worst possible outcome since it penalises exactly the consumers who are following the schedule.
Carry the migration in the artifact too. The warning has to name the replacement. A warning that says “this is deprecated” without saying what to use instead produces a search task, and consumers defer search tasks. Naming the replacement in the warning text is the cheapest possible reduction in migration cost.
Remove in a major. The removal is the breaking change, and it is the only step that the version number is required to advertise. Removing in a minor release breaks the promise the version number makes, which is precisely what semantic versioning as a compatibility contract forbids.
The window’s length is a policy question with no universal answer. The constraints are the release cadence and the consumers’ upgrade cadence; a library on a monthly minor release with enterprise consumers on annual upgrades needs a window of at least a year, which is the same statement as “a dozen minor releases”. A window shorter than the consumer’s upgrade interval is not a window at all, since no consumer can observe both ends of it.
What a shim has to translate
A compatibility shim is code whose only job is to accept the old call and perform the new one. The translations fall into a few categories, and each category has a characteristic bug.
Renamed parameters. The mechanical case. The bug is the default: if the old parameter had a default value that differs from the new one, the shim must not apply the new default when the old parameter is absent. Getting this wrong silently changes behaviour for every caller who relied on the old default, and is undetectable unless a test asserts the default.
Moved functionality. The old entry point now lives on a different object or a different endpoint. The bug is state: if the old entry point mutated an object and the new one returns a new object, a shim that wraps one in the other changes the aliasing behaviour that callers may depend on. Copy-on-write versus in-place is a semantic difference, not an implementation detail.
Different error signalling. Return codes becoming exceptions, or one exception type becoming another. A shim that translates the new error into the old form has to preserve the identity of the error, because callers may catch a specific type. A shim that translates it into a broader type loses information but does not break callers; a shim that lets the new type escape breaks every caller that catches the old one.
Deprecated input formats. A configuration key, a URL parameter, a file dialect. Here the shim is a parser in front of the new parser, and its bugs are all in the corner cases that the new parser handles differently. Number formatting, empty strings versus absent keys, and duplicate keys are the recurring ones; a config format that permits repeated keys and one that forbids them do not agree about what a repeated key means.
A worked example, in Python, of the parameter case with the default preserved:
import warnings
def send(url, *, timeout_ms=None, timeout=None):
if timeout_ms is not None:
warnings.warn(
"timeout_ms is deprecated; pass timeout in seconds",
DeprecationWarning,
stacklevel=2,
)
if timeout is not None:
raise TypeError("pass only one of timeout_ms and timeout")
timeout = timeout_ms / 1000.0
if timeout is None:
timeout = DEFAULT_TIMEOUT_S # same value the old signature used
...
The stacklevel=2 attribute is what makes the warning point at the caller rather than at the shim, and without it the warning is attributed to library code and is usually filtered out by the consumer’s warning configuration. This is the single most common defect in Python deprecation shims.
Version-negotiating shims in protocols
For a network API the shim is not a wrapper but a dispatcher keyed on the version the client declares. The shim’s job is to map an old request onto the new handler and to map the new response back into the old shape.
The translation in the response direction is where the semantics get hard. Adding a field is usually safe if clients ignore unknown fields. Removing one is not, because the client may require it. Changing the type of an existing field, a number becoming a string, is breaking even when it looks representable, since a client that parses strictly will reject it. Widening a field’s format is subtly breaking in the other direction: a client that validated the narrow format may reject the new value, which is why format constraints in a shared protocol are effectively part of the type.
The dispatch has to be explicit rather than inferred. A server that guesses the client’s version from the request shape produces different results for the same client depending on which fields it happened to send. A declared version in a header or path segment makes the mapping a function, and makes it testable: for each supported version, a request/response fixture pair can assert the exact mapping.
The order in which the versions retire is also a release-train question. A minor line that is still receiving patches has to keep serving the old protocol version for as long as that line is supported, which means the dispatcher’s supported-version set is a property of the support window rather than of the current code. Keeping those in step is part of the branch policy described in release trains, cut points, and hotfix branches.
The retirement question is the same one as for a function, with a different measurement: which clients still declare the old version. That count is available from request logs, and it is the only honest input to the decision. Removals scheduled by calendar rather than by observed usage remove interfaces that are still in use.
Configuration keys are the hardest surface
A configuration key is a public interface with no compiler, no type checker, and no import graph. Nothing tells a producer that a key is in use, and nothing tells a consumer that the key has been dropped: the file parses, the key is ignored, and the process starts with defaults.
This asymmetry makes configuration the surface where deprecation most often fails silently. The mitigations that exist are all variants of “warn when the old key is present”:
- Parse the old key, apply it, and emit a warning naming the new key. The warning goes to the log at startup, which is where operators look.
- Support both keys, with a documented precedence, and emit an error rather than a warning if both are present with conflicting values. A silent precedence rule is worse than a hard conflict, because the operator cannot tell which value took effect.
- Fail on unknown keys rather than ignoring them. This is the setting that determines whether a typo is caught or silently defaulted, and it is a trade against tolerance: strict parsing rejects configuration written for a newer version, which breaks rollbacks.
The rollback consideration is real and often missed. A configuration file that a new version writes is read by the old version during a rollback, and if the new version began writing a key the old version rejects under strict parsing, the rollback fails. The standard resolution is that the new version must write configuration the old version can read for at least one release, which means the old key must be retained even after the new version stops reading it. The ordering is: read both, write old, then read both, write new, then read new.
Removing when the evidence says it is safe
Removal is justified by evidence rather than by schedule, and the evidence is a count of observed use across the surface. For a library, the closest available proxy is the deprecation-warning volume from consumer builds, which most producers cannot see. For a service, it is a request log. For a configuration key, it is startup warnings in deployment logs.
Absent observation, the fallback is a declared window that is long enough for the slowest plausible consumer, and a removal that proceeds on the window rather than on usage. That is a defensible policy as long as it is stated, because a consumer who knows the window can plan; the failure is a window that is declared and then shortened under pressure, since that makes the declaration worthless for every future release.
A useful closing check on the whole sequence: at the moment of removal, every earlier step should be visible in the artifact’s own history. The deprecation warning appeared in a version that is still installable. The release notes at that version named the replacement. The shim was present and tested. If any of those is missing, the removal is not the last step of a deprecation but the first step of a break, and the release note has to say so plainly rather than inheriting the language of a planned migration.
References
- Semantic Versioning 2.0.0 — SemVer project
- RFC 9110: HTTP Semantics — IETF
- Git documentation — git-config — Git project
- Python documentation — warnings — Python Software Foundation