Hyrum’s Law: With Enough Users, Someone Depends on Every Behavior
By Sergey Nosov
24 October 2026
Michel de Montaigne closed his third book of essays with “Of Experience.” In Charles Cotton’s translation, one line reads: “Speaking is half his who speaks, and half his who hears.” Montaigne goes on to tennis, where the receiver prepares “according to the stroke itself.”
Software has the same two halves. We write the contract, and we think the contract is the promise. The people who use what we build play the ball we actually hit: the order a query happens to return, the time a call happens to take, the text an error happens to carry. Hyrum’s Law says that with enough users, every one of those accidents becomes a promise you did not mean to make, and somebody expects you to keep it.
The Law
Hyrum Wright states it on his own page:
With a sufficient number of users of an API, it does not matter what you promise in the contract: all observable behaviors of your system will be depended on by somebody.
Wright describes himself there as a Principal Scientist at Adobe and, before that, a software engineer at Google, where he “spent several years improving Google’s core C++ libraries.” The observation, he writes, “grew out of experiences when even the simplest library change caused failures in some far off system.”
He gives Titus Winters the credit for naming it Hyrum’s Law and “popularizing the concept more broadly.” His page says the observation is “colloquially referred to as” the Law of Implicit Interfaces, and a footnote in Software Engineering at Google adds that Wright “tried really hard to humbly call this” the Law of Implicit Dependencies. The shorter name stuck.
The page explains the mechanism. An interface is supposed to separate the people who use a system from the people who implement it. In practice, Wright writes, the theory breaks down as use grows and users “start to rely upon implementation details intentionally exposed through the interface, or which they divine through regular use.”
Joel Spolsky’s Law of Leaky Abstractions, the subject of an earlier article here, “embodies consumers’ reliance upon internal implementation details,” in Wright’s words. His own law is that idea “taken to its logical extreme”: “Given enough use, there is no such thing as a private implementation.”
Changes must then conform to the documented interface and to “the implicit interface captured by usage,” a constraint Wright calls by its familiar name, “bug-for-bug compatibility.” The end state is stark. Once the implicit interface matches the implementation, “the interface has evaporated: the implementation has become the interface, and any changes to it will violate consumer expectations.” The law has an entry in my Software Development Principles series, which relates it to the Law of Leaky Abstractions and to backward compatibility.
Notice what the law does not say. It does not say users are careless. Wright’s wording is “intentionally or not”: with enough users, someone finds every behavior and builds on it. Consumers, Wright notes, are generally not aware of it as it happens. He is also sober about the usual remedy: “With a bit of luck, widespread, comprehensive, and automated testing can detect these new expectations but not ameliorate them.”
What People Build On
A well-known illustration, which Wright’s page links and the book cites in a footnote, is an xkcd comic from 11 February 2013 called “Workflow.” A changelog announces: “The CPU no longer overheats when you hold down the spacebar.” A longtime user replies: “This update broke my workflow!” The user explains: “My control key is hard to reach, so I hold spacebar instead,” and has configured Emacs to read a rapid temperature rise as Control. The caption reads, “Every change breaks someone’s workflow.”
Joel Spolsky told a real story with the same shape in “How Microsoft Lost the API War,” on 13 June 2004. A SimCity developer told Spolsky about a critical bug: the game “used memory right after freeing it, a major no-no that happened to work OK on DOS but would not work under Windows.” Windows testers found the game crashing.
The Windows developers “disassembled SimCity, stepped through it in a debugger, found the bug, and added special code.” If SimCity was running, that code “ran the memory allocator in a special mode in which you could still use memory after freeing it.” That is bug-for-bug compatibility in a shipping operating system.
Where should you look in your own code? Four places are worth checking first.
- Order. The sequence a collection, a query, or an event stream happens to return.
- Timing. How fast something answers, though nobody promised a speed. This is Wright’s own example: “an interface may make no guarantees about performance, yet consumers often come to expect a certain level of performance from its implementation.”
- Defaults and formats. Dates, casing, nulls, property names, and the shape of a serialized object.
- Errors. Exception types, messages, status codes, and whatever someone parses out of them.
Microsoft’s compatibility guidance for .NET makes a related distinction from the vendor’s side. A behavioral change, it says, may be externally visible or a changed implementation, down to “a significant performance improvement.” Externally visible changes that “modify a type's public contract” are easy to evaluate because they affect binary compatibility. “Implementation changes are much more difficult to evaluate; depending on the nature of the change and the frequency and patterns of use of the API, the impact of a change can range from severe to innocuous.”
In “Backward Compatibility, Go 1.21, and Go 2,” on the Go blog on 14 August 2023, Russ Cox describes changes that were compatible by the letter of Go’s compatibility promise and still broke programs. On Linux, OS X, and the BSDs, Go 1.1 gave time.Now nanosecond precision. The change “broke a handful of tests inside Google” that saved a time at microsecond precision and expected it back unchanged.
Go 1.6 made sort about 10 percent faster by changing the algorithm, which changed the relative order of elements that compared equal. Cox writes: “Sort is clearly allowed to return equal results in any order it likes, and this change made it 10% faster, which is great. But programs that expect a specific output will break.” Go 1.8 made compress/flate produce smaller output, which broke a project inside Google that needed reproducible archives.
Cox groups most such breakages into output changes, input changes, and protocol changes. The output changes overlap my “order” and “defaults and formats”; the two schemes organize the same failures differently.
Example 1: A Test That Passes for the Wrong Reason
Here is a dictionary with three keys and a test that checks they come back in the order they were added. I checked it with xunit 2.9.3 on .NET 8.0.32 and .NET 10.0.13.
var totals =
new Dictionary<string, int>();
totals["north"] = 10;
totals["south"] = 20;
totals["east"] = 30;
// Green today: the keys come back
// in the order they were added
Assert.Equal(
new[] { "north", "south", "east" },
totals.Keys);
It passes, and it keeps passing. On both runtimes the keys came back in insertion order, and the runtime’s source shows why. Entries sit in an array in the order they were added, and enumeration walks that array. A removal puts the freed slot on a free list, and the next insertion reuses it. Nothing promises any of that. The documentation for Dictionary<TKey,TValue> says: “The order in which the items are returned is undefined.” Remove one key and add another, and the new key takes the freed slot.
totals.Remove("north");
totals["west"] = 40;
// Now the keys come back as
// west, south, east
A second assertion that expected south, east, west failed on both runtimes with “Collections differ.” The test did not catch a bug. It mistook an accident for a promise, so a correct change to the code that fills the dictionary turned the test red.
If the order matters, make it part of the promise, and choose the order you mean. For key order, sort with an explicit comparer at the point of use, or use SortedDictionary<TKey,TValue>. For insertion order, keep a list of pairs or, from .NET 9, use the generic OrderedDictionary<TKey,TValue>. Microsoft calls it “the long-requested” generic version of a type that had existed only with keys and values typed as object.
Example 2: Your Class Did Not Change. The Serializer Did.
The second example comes with a deadline. Microsoft’s guide for migrating Azure Functions from the in-process model to the isolated worker model opens by saying that support for the in-process model ends on 10 November 2026. The in-process model used Newtonsoft.Json, so a property could carry Newtonsoft’s JsonProperty attribute to map a JSON name such as order_id onto a property named OrderId. “By default,” the guide says, “the isolated worker model uses System.Text.Json for JSON serialization,” and System.Text.Json ignores that attribute.
public class Order
{
// Newtonsoft.Json
[JsonProperty("order_id")]
public string? OrderId { get; set; }
}
// Payload: { "order_id": "A-17" }
// Newtonsoft.Json reads "A-17"
// System.Text.Json reads null,
// and reports no error
The guide spells out the consequence: System.Text.Json “binds the affected property to its default value without reporting an error.” I checked the serializers directly, with Newtonsoft.Json 13.0.4 on .NET 8.0.32 and .NET 10.0.13. Newtonsoft reads the value. System.Text.Json reads null, with its default options and with its web options, and throws nothing: once the attribute is ignored, order_id matches OrderId under neither naming policy.
I also ran the payload through the isolated worker’s own default serializer, from Microsoft.Azure.Functions.Worker.Core 2.52.0, without a Functions host. The order ID is null there too, so a function that reads the property gets null. The class did not change. The serializer under it did, and with it a behavior the class relied on: that the host would read its attributes with Newtonsoft.Json.
The fix is System.Text.Json’s own attribute, plus a test that sends a real payload, so the upgrade tells you before the customer does. The guide also allows the other way around: keep Newtonsoft.Json, configured “for the layer that handles the payload.” The trap runs both ways, though: Newtonsoft.Json read null for a class that carried only System.Text.Json’s attribute.
public class Order
{
// System.Text.Json
[JsonPropertyName("order_id")]
public string? OrderId { get; set; }
}
There is a second defense worth knowing. Since .NET 8, System.Text.Json can refuse a payload whose properties it cannot map, instead of skipping them. Microsoft’s documentation says unmapped properties are “simply ignored” by default, and that “Starting in .NET 8, you can specify that all payload properties must exist in the POCO.”
With the attribute below on the class, the same payload threw a JsonException that named the unmapped property, order_id, and the type. The policy can also be set once, through the UnmappedMemberHandling setting of JsonSerializerOptions; in a function app, the guide’s section on customizing JSON serialization covers where such settings go. A silent default became a loud failure.
[JsonUnmappedMemberHandling(
JsonUnmappedMemberHandling.Disallow)]
public class Order
{
// Newtonsoft.Json
[JsonProperty("order_id")]
public string? OrderId { get; set; }
}
Use it with care. The policy rejects unknown payload names only: it does not require OrderId to be present, and it refuses a payload whose sender has added a field. An extra “source” property made the serializer throw on both runtimes.
For the one property you cannot do without, the narrower guard is C# 11’s required modifier on the property, or System.Text.Json’s JsonRequired attribute. Both threw a JsonException about missing required properties for the order_id payload. Given OrderId under its own name plus an extra field, both read the value and skipped the extra field. Even the missing-properties message changed between .NET 8 and .NET 10, which is the errors category in miniature.
When You Own the API
Say what you promise, and what you do not. A sentence such as “the order in which the items are returned is undefined” is part of the contract. Go’s compatibility document, “Go 1 and the Future of Go Programs,” does this in writing. After stating the intention that programs written to the Go 1 specification “will continue to compile and run correctly, unchanged,” it lists the ways they might not.
Two items are Hyrum’s Law by another name. On unspecified behavior: “Programs that depend on such unspecified behavior may break in future releases.” On bugs: “If a compiler or library has a bug that violates the specification, a program that depends on the buggy behavior may break if the bug is fixed.”
Pin the promise with tests, and only the promise. A test that asserts insertion order on a dictionary pins an accident. A test that asserts the sorted order you documented pins the contract, and it fails for the right reason. A contract test accepts every result the contract allows. Cox gives dependents the same advice from the other side: “write programs and tests that accept any valid output.”
Make the unpromised unreliable on purpose. A behavior that varies from run to run is hard to build on, and a dependency on it tends to surface early. Go did this with map iteration order in Go 1, released on 28 March 2012.
The release notes explain that the old specification left the order undefined and that it differed across platforms, so a test “might always pass on one machine but break on another.” In Go 1 the order “is defined to be unpredictable, even if the same loop is run multiple times with the same map.” The reasoning is pure Hyrum’s Law. Code that depends on iteration order, the notes say, “is very likely to break early and be fixed long before it becomes a problem.”
The documentation for String.GetHashCode makes a similar non-promise: “two subsequent runs of the same program may return different hash codes.” They did on this machine: two runs of the same program printed two different hash codes for the same string, on both runtimes.
Variation reduces the risk; it does not remove it. Software Engineering at Google notes that “there is code that uses hash iteration ordering as an inefficient random-number generator. Removing such randomness now would break those users.” Hyrum’s Law, the book says, “applies to every observable behavior.”
Keep the surface small, and give errors a stable shape. Every behavior a caller can observe is a candidate promise, so expose less: fewer public types, fewer fields, fewer details in what you return. Give callers stable error codes and keep messages for people, so that nobody has to parse prose. Say what you promise about timing, if anything. When you must take a behavior away, publish a deprecation path before you remove it.
Change in small steps, with a way back. Since you cannot know every dependency, plan for some to break, and arrange to find out cheaply: a change behind a flag, a canary, a rollout you can reverse. The Recoverable Change Principle covers how to prove the way back before you need it.
When You Depend on One
Build on the contract, not on what you happened to see. Before you rely on an order, a default, or a format, look for the guarantee in the documentation, the language specification, or the declared API. If it is nowhere, you are relying on an accident that may change in any release.
Pin what you must rely on with a test. Sometimes you must depend on a behavior that nobody promised. Then pin it deliberately, in a test that names the behavior and says why.
The dictionary test above and this pin look alike, and they are not. The first asserted an order without saying that nothing promised it, or why it mattered. The second is a chosen dependency, and when it fails, you know what changed and where to look. A pin detects the change; it does not make the dependency supported.
Read the breaking changes before you upgrade, behavioral changes included. Semantic Versioning measures compatibility against a declared contract: “Software using Semantic Versioning MUST declare a public API.” Its summary says to raise the minor version “when you add functionality in a backward compatible manner.” Hyrum’s Law is why a minor version can still break you: compatible with the public API is not compatible with everything you observed.
The sharper case is a patch, “when you make backward compatible bug fixes,” because a fix changes exactly the behavior a bug-for-bug dependent relies on. The Go team has had a written compatibility promise since 2012 and tests its development versions against all of Google’s internal Go code; the changes Cox describes still shipped. Read the release notes anyway.
Ask the owner to promise it. When a behavior matters to you, the best outcome is a contract that includes it. Python shows what that outcome looks like. Python 3.6, released on 23 December 2016, introduced a new dict implementation, and its release notes said: “The order-preserving aspect of this new implementation is considered an implementation detail and should not be relied upon.”
The note added that “this may change in the future,” and that the stated wish was to keep the implementation for a few releases before changing the language specification. The release notes for Python 3.7, from 27 June 2018, record the result. Dict order “has been declared to be an official part of the Python language spec.” On 3.7 and later, code that depends on it depends on a guarantee, not on an accident.
Clever as an Accusation
Software Engineering at Google is the 2020 book curated by Titus Winters, Tom Manshreck, and Hyrum Wright. Its first chapter, which Winters wrote, draws the line between code that “happens to work” and code that “is maintainable.” For anyone maintaining a project that other engineers use, it calls Hyrum’s Law “the most important lesson” about that difference.
Its hash-ordering example offers a test for your own code. “If your code is short-lived, with no changes to your hardware, language runtime, or choice of data structure, such an assumption is fine. If you don’t know how long your code will live, or you cannot promise that nothing you depend upon will ever change, such an assumption is incorrect.” The discussion closes with the line that sums the point up: “It’s programming if ‘clever’ is a compliment, but it’s software engineering if ‘clever’ is an accusation.”
A Question and a Checklist
What does your code rely on that nobody promised? An order, a default, a format, a timing, an error message. Pick one, and either pin it with a test that names it or stop relying on it. If the fix needs planning, put it on the backlog now rather than in an incident report later.
- When you own an API, write down what you promise and what you do not, keep the observable surface small, and test only the promise.
- Make non-guarantees obvious: let an unspecified order vary, keep in-process hash codes unstable between runs, and put the stable part of an error in a type or a code rather than in its text.
- Change in small, reversible steps, with a deprecation path before you take a behavior away, because a dependency you never knew about may break.
- When you depend on an API, find the guarantee before you build on the behavior.
- Pin a dependency on an accident with a test that says what it pins and why.
- Read the behavioral changes before upgrading, and send a real payload through every serializer you did not write.
- Ask the owner to document what you need. Python’s dict order shows that an implementation detail can become a guarantee.
Further Reading
- Hyrum’s Law (Hyrum Wright)
- What Is Software Engineering? (Titus Winters, chapter 1 of Software Engineering at Google, O’Reilly, March 2020)
- Workflow (xkcd 1172, 11 February 2013)
- How Microsoft Lost the API War (Joel Spolsky, Joel on Software, 13 June 2004)
- Go 1 Release Notes (The Go Programming Language, 28 March 2012)
- Go 1 and the Future of Go Programs (The Go Programming Language)
- Backward Compatibility, Go 1.21, and Go 2 (Russ Cox, The Go Blog, 14 August 2023)
- Python 3.7 Release Notes (Python documentation, 27 June 2018)
- Compatibility (Microsoft Learn, .NET documentation)
- Migrate C# Apps from In-Process to Isolated Worker Model (Microsoft)
- Semantic Versioning 2.0.0 (semver.org)
- Essays of Michel de Montaigne, Complete (translated by Charles Cotton, Project Gutenberg)