
Hyrum's Law in Go: When an Error String Becomes an Immutable API Contract
Table of Contents
Some comments in the Go standard library make you close the laptop for a minute. Not because they are hard to understand, but because they are brutally honest about something most of us prefer to ignore.
I was digging through net/http/request.go to see how Go rejects oversized request bodies, and found this:
func (e *MaxBytesError) Error() string {
// Due to Hyrum's law, this text cannot be changed.
return "http: request body too large"
}
No // TODO, no //nolint, no apology. It is a written acknowledgment that those 28 characters are no longer a string. They are a contract. A contract that even the Go team no longer has the right to edit, despite having written it.
That was when Hyrum’s Law stopped being a cute saying about API design for me. It is a law describing how software accumulates obligations over time, the way a function accumulates area under its curve. You will not feel it inside one sprint. You only meet it at the release where everything conforms to your spec and someone still takes production down.
1. The law, restated for backend engineers
The original formulation, by Hyrum Wright of Google:
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.
The word to underline is observable. Hyrum’s Law is not about what you publish in documentation. It is about what other people can measure: with their eyes, with log, with tcpdump, with diff, with a test that compares two byte strings.
I usually model the real contract of a service like this:
Real_contract = Spec_in_docs ∪ Observable_behavior
The second term is the dangerous one, because it is an infinite set. Nobody can enumerate it. Users enumerate it slowly, with their own code.
Split behavior into three layers:
| Layer | Concrete Go example | Who depends on it | Cost to change |
|---|---|---|---|
| Explicit promise | MaxBytesReader returns *MaxBytesError on overflow | Devs reading docs, errors.As | Low, this is what we designed |
| Observable behavior | The string "http: request body too large" | Alert rules, log parsers, SDKs, snapshot tests | High, needs deprecation |
| Accidental behavior | JSON field order, database row order | Client bugs, snapshot tests, internal runbooks | Very high, sometimes permanent |
What makes this a law rather than advice is inevitability. It is not the probability that one user depends on one accidental detail. It is that, given enough users and enough time, that probability converges to 1. At a million users, you are no longer allowed to say “nobody would do that”, because somebody already did, and they are running it in production behind a 99.99% SLA.
flowchart LR
A["An observable behavior
appears by accident"] --> B["One dev writes a test
or an alert on top of it"]
B --> C["Dependency spreads:
SDKs, dashboards, runbooks"]
C --> D["Cost of changing
exceeds cost of keeping"]
D --> E["Behavior documented in code
as immutable"]
E --> A
The loop has no exit. Every time you preserve a behavior for compatibility, you buy more time for new users to depend on it. That is why Hyrum’s Law and backward compatibility are two novels about the same protagonist.
2. Three scars in the Go standard library
Go is lucky, or unlucky, to have a standard library used by millions of programs, so the scars of Hyrum’s Law are unusually visible. All three examples below live in source you can open yourself with go env GOROOT.
2.1 net/http: 28 characters that cannot be edited
http.MaxBytesReader is the canonical way to cap a request body. When the limit is exceeded it returns *http.MaxBytesError:
type MaxBytesError struct {
Limit int64
}
func (e *MaxBytesError) Error() string {
// Due to Hyrum's law, this text cannot be changed.
return "http: request body too large"
}
Say you run an image upload service with a 10MB cap. A client sends 20MB. You get exactly that error. Looks ordinary. But notice: the MaxBytesError type only arrived in Go 1.19. Before that there was just an errors.New("http: request body too large") buried inside maxBytesReader, and a long tail of codebases had been doing this for years:
// PLEASE DON'T DO THIS.
if strings.Contains(err.Error(), "request body too large") {
http.Error(w, "file too large", http.StatusRequestEntityTooLarge)
return
}
Or worse, inside an alert rule:
log: status=400 AND message~"request body too large"
Or in a runbook: “if you see this log, open the upload dashboard”. By then the string is no longer an implementation detail. It is a name. And names cannot be changed casually.
Here is my favorite part: the Go team did everything right. They added a structured error with a Limit field so consumers could use errors.As instead of string matching. That is good design. And they still had to freeze the old string, because changing a colon to a comma would make some service return 500 instead of 413, and nobody would find the cause within an afternoon.
The lesson: you can add a correct API, but you cannot retract a string that already escaped to the internet.
Warning
If your codebase compares err.Error() against a literal anywhere, that is not error handling. That is a dependency on the English rendering of an event. It will break exactly when you upgrade a dependency or add a middleware that wraps errors.
2.2 crypto/rsa: when Go deliberately breaks determinism
The second scar is much subtler. In crypto/rsa, EncryptOAEP carries this comment:
func EncryptOAEP(hash hash.Hash, random io.Reader, pub *PublicKey, msg []byte, label []byte) ([]byte, error) {
// Note that while we don't commit to deterministic execution with respect
// to the random stream, we also don't apply MaybeReadByte, so per Hyrum's
// Law it's probably relied upon by some. It's a tolerable promise because a
// well-specified number of random bytes is included in the ciphertext, in a
// well-specified way.
What is MaybeReadByte? It lives in crypto/internal/randutil and looks like a prank:
var (
closedChanOnce sync.Once
closedChan chan struct{}
)
// MaybeReadByte reads a single byte from r with ~50% probability. This is used
// to ensure that callers do not depend on non-guaranteed behaviour, e.g.
// assuming that rsa.GenerateKey is deterministic w.r.t. a given random stream.
func MaybeReadByte(r io.Reader) {
closedChanOnce.Do(func() {
closedChan = make(chan struct{})
close(closedChan)
})
select {
case <-closedChan:
return
case <-closedChan:
var buf [1]byte
r.Read(buf[:])
}
}
Look closely at the select. Two cases reading from the same closed channel. Both are always ready. The Go runtime picks uniformly at random among ready cases, so this function consumes one extra byte with probability exactly 50%. No RNG, no synchronization, just a correct understanding of select semantics. It is one of the most elegant snippets in the standard library, and it exists solely to break other people’s code.
Break in a good way. If you call rsa.GenerateKey(rand.Reader, 2048) twice and expect two different keys, you are fine. If you wrote a fake io.Reader that counts exactly how many bytes were consumed, or you reconstruct a private key from a fixed byte stream, you break immediately. That is the point. Go destroys the determinism before anyone can turn it into a contract.
Now read the second half of the comment: EncryptOAEP does not call MaybeReadByte. Why? Because for OAEP the number of random bytes is specified by RFC 8017: the seed is exactly the hash size and sits at a known offset in the ciphertext. Perturbing it would break libraries correctly implementing the spec, such as an HSM emulator or a test-vector suite. The Go team classified the behavior: if determinism is accidental, break it; if it is implied by the spec, keep it and record it as a “tolerable promise”.
This is the most valuable design lesson in the article: defending against Hyrum’s Law is not about making everything random. It is about classifying precisely which behaviors deserve to be stable and which do not.
2.3 internal/weak: closing the door before anyone knocks
The third scar is a package-level comment rather than code:
/*
Using go:linkname to access this package and the functions it references
is explicitly forbidden by the toolchain because the semantics of this
package have not gone through the proposal process. By exposing this
functionality, we risk locking in the existing semantics due to Hyrum's Law.
If you believe you have a good use-case for weak references not already
covered by the standard library, file a proposal issue at
https://github.com/golang/go/issues instead of relying on this package.
*/
internal/weak implements weak pointers: pointers that do not keep an object alive and may return nil at any moment after the GC decides the object is unreachable. That “any moment” is a goldmine of unresolved questions. Does the weak pointer become nil before or after the finalizer runs? If an object is resurrected by a finalizer, does the old weak pointer compare equal to a new one? What if two weak pointers target different fields of the same struct?
Go did not want to answer those questions ceremonially yet. And the only way not to answer them forever is to let nobody in. Because the moment go:linkname opens the door, the answer gets frozen by the first user, not by the designer.
In Go 1.24, weak became a public package, but only after its semantics went through the proposal process and were written down. That is the correct order: debate first, freeze later. Not freeze first and debate after, because by then it is too late.
3. Go 1.23 and the //go:linkname war
If the previous examples are Hyrum’s Law in stealth mode, //go:linkname is the weaponized form. It is a compiler directive that lets you declare a function without a body and tell the linker to bind it to a symbol in another package, bypassing the type system, internal/, and every guardrail Go gives you against yourself.
package fastpath
import (
_ "unsafe" // required, only to satisfy the compiler
)
//go:linkname nanotime runtime.nanotime
func nanotime() int64
func Now() int64 {
return nanotime()
}
Two lines are enough to call a function that was never exported, never documented, and can be renamed at any time. You get no compiler error when Go changes the signature. You get an undefined symbol at link time, or worse: it links, calls with the wrong ABI, and crashes with an unreadable stack trace.
3.1 Push, Pull, Handshake
Russ Cox described three usage patterns in golang/go#67401:
- Push: package A annotates its own symbol with
//go:linknameto rename it intoB.foo, then package B declaresfunc foo()with no body. A explicitly offers it to B. - Pull: package A defines
foonormally, package B uses//go:linknameto grab it. A may never have heard of B. This is the dangerous one. - Handshake: both sides declare
//go:linknameand agree on the name. This is the only form that deserves to be called a contract.
flowchart TB
subgraph "Pull: one-way, implicit, fragile"
R1["package runtime
func nanotime()"]
F1["package fastjson
go:linkname nanotime runtime.nanotime"]
F1 -->|"reaches across internal/
without consent"| R1
end
subgraph "Handshake: two-way, recorded"
R2["package runtime
go:linkname nanotime
func nanotime()"]
F2["package fastjson
go:linkname nanotime runtime.nanotime"]
F2 <-->|"both declare it,
linker allows it"| R2
end
3.2 The goccy/go-json incident
In May 2024, an internal runtime change broke github.com/goccy/go-json. Not because the library used some secret API, but because it had copied most of the runtime’s internal type API into its own tree. The Go team was suddenly unable to change anything on that list without breaking a library used by Kubernetes.
From Russ Cox in the issue:
Overuse of //go:linkname to reach into Go standard library internals (especially runtime internals) means that when we do change the standard library internals in ways that should not matter, we can end up breaking packages that are depended on by a large swath of the Go ecosystem.
Note what this is: Hyrum’s Law acting on symbols that were never exported. internal/ in Go is a social convention backed by the compiler, not a physical wall. //go:linkname downgrades that convention to a polite request. Once somebody walks through, the request becomes a contract.
3.3 How Go 1.23 handled it, and what it cost
Go 1.23 turned on -checklinkname=1 by default in the linker. From now on, linking to a standard library internal symbol requires that symbol to be annotated with //go:linkname at its definition, meaning Handshake form. Sounds decisive. Read the release note carefully:
For backward compatibility, existing usages of //go:linkname found in a large open-source code corpus remain supported. Any new references to standard library internal symbols will be disallowed.
This is where I stopped for a while. To write that sentence, the Go team had to scan the entire open-source Go corpus, find every Pull usage of internal symbols, and add annotations to the standard library so those usages keep working. The mechanism built to stop Hyrum’s Law was bent by Hyrum’s Law: they could not forbid existing violations, only new ones.
That is a very real system-design lesson. When you discover a bad coupling that already exists, you have two options: break it now and absorb one painful release, or freeze it and stop it from spreading. Go chose the second plus a valve:
go build -ldflags=-checklinkname=0 ./...
The valve permits experiments, internal forks, and a startup that consciously accepts the risk. But it is annoying enough that nobody accidentally puts it in the CI of a serious project. Hard default plus a documented escape hatch usually beats an absolute ban, because an absolute ban just pushes people to fork the compiler or pin an old version.
3.4 Who pays the debt?
High-performance JSON libraries such as sonic, jsoniter, and goccy/go-json all have a legitimate reason to reach into the runtime: encoding/json uses reflection, reflection uses interfaces, interfaces allocate, and at 100K requests per second that gap is real money. They optimized the right thing. But they borrowed against the future of the language, and you are the one repaying, on the day you upgrade Go and the service refuses to boot with undefined: runtime.xxx.
My practical advice: if you use those libraries, pin the Go version in go.mod and your Dockerfile, run a CI matrix with at least one Go version ahead of production, and read the Linker section of the release notes before upgrading. Ten minutes of reading is cheaper than a night of rollback.
4. Map iteration: active defense
This is the example I use most when explaining Hyrum’s Law to new engineers, because everybody has seen it and almost nobody knows why.
In runtime/map.go, mapiterinit picks the starting point of iteration:
// decide where to start
r := uintptr(rand())
it.startBucket = r & bucketMask(h.B)
it.offset = uint8(r >> h.B & (bucketCnt - 1))
To see why it takes two lines instead of one, recall the layout: a Go map is an array of buckets, each bucket holds at most 8 key/value pairs (bucketCnt = 8) plus an overflow chain when hashes collide. h.B is log2 of the bucket count, and bucketMask(h.B) is 2^B - 1, used to take the low bits of the random number.
The first line picks the starting bucket. The second picks the offset inside the bucket. With only the first line, a small map (one bucket, B = 0) would always iterate slots 0, 1, 2 in insertion order, and every test would pass reliably until the map grew and failed in a way nobody understands. The second line plugs exactly that hole.
flowchart LR
subgraph "Bucket array, B = 2"
B0["bucket 0
8 slots"]
B1["bucket 1
8 slots"]
B2["bucket 2
8 slots"]
B3["bucket 3
8 slots"]
end
R["rand()"] --> S["startBucket = r AND 3"]
R --> O["offset = (r >> 2) AND 7"]
S --> B2
O --> B2
B2 --> N["iteration order: 2, 3, 0, 1
each bucket starts at offset"]
The result kills three beliefs developers keep rediscovering:
- Small maps preserve insertion order. False. The offset is randomized.
- Two consecutive
for rangeloops over the same map agree. False. Each call gets its ownrand(). - If you do not write to the map, order is stable for the process lifetime. False, same reason.
Go 1.24 rewrote maps on top of Swiss tables, a completely different data structure. The randomization stayed, because it is a commitment: “no order” is part of the spec. That is the beautiful paradox of Hyrum’s Law. The only thing you are allowed to promise stably is instability.
This is also why I run tests with -count=20 -shuffle=on before merging anything that touches maps or sets. If a test fails 1 in 20 runs, you are almost certainly depending on iteration order. It is a free gift from the runtime, so use it.
5. The contract surface of a Go backend
Once you see Hyrum’s Law as a law, API design becomes surface management. You cannot control what users latch onto, but you control how much there is to latch onto.
flowchart TD
API["Surface of a Go service"]
API --> A1["Exported names, types, interfaces"]
API --> A2["Error values and error strings"]
API --> A3["JSON or protobuf bytes"]
API --> A4["Row order from the database"]
API --> A5["Log field names, metric labels"]
API --> A6["Timing, allocation, goroutine order"]
A1 --> C1["Cheap: add, version"]
A2 --> C2["Expensive: alerts, runbooks, SDKs"]
A3 --> C2
A4 --> C3["Very expensive: client bugs, snapshots"]
A5 --> C3
A6 --> C4["Uncontrollable: you can only stop promising"]
The key insight: change cost does not scale with technical complexity. It scales with the number of observers. Renaming a JSON field is a five-minute task. Renaming it after 200 partners parse it is a quarter of work.
6. A practical playbook
These are the rules I actually apply, not theory.
6.1 Errors: use values, not prose
The principle: error strings are for humans, error values are for programs.
var (
ErrUserNotFound = errors.New("user not found")
ErrQuotaExceeded = errors.New("quota exceeded")
)
type ValidationError struct {
Field string
Rule string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed on %s: %s", e.Field, e.Rule)
}
At the check site:
if errors.Is(err, ErrUserNotFound) {
return nil, status.Error(codes.NotFound, "user not found")
}
var ve *ValidationError
if errors.As(err, &ve) {
return nil, status.Errorf(codes.InvalidArgument, "field %s: %s", ve.Field, ve.Rule)
}
errors.Is walks the Unwrap chain, compares with == first, then calls an Is method if present. errors.As walks the same chain looking for a type. So you can wrap as many layers as you like and the logic above still works, as long as you use %w and not %v.
// Correct: keeps the chain intact.
return fmt.Errorf("loading profile %d: %w", id, ErrUserNotFound)
// Wrong: flattens the error into a dead string.
return fmt.Errorf("loading profile %d: %v", id, ErrUserNotFound)
When comparison must be attribute-based, implement Is yourself:
func (e *ValidationError) Is(target error) bool {
t, ok := target.(*ValidationError)
return ok && t.Field == e.Field
}
At the HTTP or gRPC boundary, never let err.Error() be the only signal. Return an envelope with a stable code, a free-form message, and a retryable flag:
type APIError struct {
Code string `json:"code"` // contract, never changes
Message string `json:"message"` // free text, may change
Retryable bool `json:"retryable"` // contract
}
Clients read code. Humans read message. That is the only way you keep the right to reword anything without breaking someone.
6.2 Ordering: do not let the database choose for you
A query without ORDER BY is a Hyrum’s Law time bomb. PostgreSQL may return the same order for six months because the current plan uses an index scan, then switch to a bitmap scan once the table grows 20% and the order changes. Nothing is broken. You just promised something you never promised.
-- Bad: order depends on the plan
SELECT id, name FROM users WHERE status = 'active' LIMIT 20;
-- Good: explicit order, with a unique tiebreaker
SELECT id, name FROM users
WHERE status = 'active'
ORDER BY created_at DESC, id DESC
LIMIT 20;
The id DESC is not redundant. If 500 users were created in the same second, created_at ties, and without a tiebreaker the order among them is arbitrary. With pagination, that means a row can appear twice or vanish between pages. I have debugged exactly this in a 40-million-row system, and the root cause was a missing tiebreaker.
In Go, same principle:
keys := maps.Keys(m) // or slices.Collect(maps.Keys(m)) depending on version
slices.Sort(keys)
And sort.Slice is unstable. Since Go 1.19 it uses pdqsort, faster but free to permute equal elements however it likes. If you need to preserve the relative order of ties, use sort.SliceStable, or better, add a tiebreaker to the comparator so stability is irrelevant.
6.3 Serialization: bytes on the wire are the contract
Three things I always do.
First, never marshal internal structs directly. Always have a DTO at the boundary. Internal structs will change, and every change becomes a breaking change.
type userRow struct { // database layer
ID int64
Email string
DeletedAt sql.NullTime
}
type userResponse struct { // HTTP layer, the contract lives here
ID int64 `json:"id"`
Email string `json:"email"`
}
Second, explicit tags on every field. No tag means the Go field name becomes the JSON field name, and an internal refactor becomes a public breaking change.
Third, understand that omitempty is a contract, not a convenience. json:"name,omitempty" makes “field absent” indistinguishable from “field is the zero value”. For PATCH or partial updates that is a logic bug, not a formatting preference.
On ordering: encoding/json sorts map keys alphabetically, so that part is safe. Struct field order follows declaration order, though, and strict parsers or snapshot tests may depend on it. For long-lived contracts, protobuf with field numbers is a better choice than JSON, because the number is the real identity and the name is just a comment.
6.4 Time and concurrency: where Hyrum hides best
Go 1.23 changed time.Timer and time.Ticker channels from buffered (cap 1) to unbuffered (cap 0). Sounds internal. But len(t.C) and cap(t.C) are observable, and code existed that used len to guess whether a receive would block.
// Correct on Go 1.22, wrong on Go 1.23+.
if len(timer.C) > 0 {
v := <-timer.C
_ = v
}
Go handled it with the toolkit described earlier: the new behavior activates only when go.mod says go 1.23.0 or later, plus GODEBUG=asynctimerchan=1 to restore the old behavior. That is a complete compatibility ratchet, and a template you can copy for your own internal APIs.
Same family: do not depend on goroutine completion order, do not depend on which select case wins when several are ready, do not depend on runtime.GOMAXPROCS inside a benchmark. All of it is observable, all of it can change.
6.5 Contract tests
A contract without a test is a contract your own team will break in six months. I use three layers:
- Golden tests for payloads: serialize a fixed struct, compare against a
.goldenfile. When someone changes a tag or adds a field, the test goes red and forces a deliberate decision. - Round-trip tests:
decode(encode(x)) == x, plus hostile inputs (null, huge numbers, Unicode) to catch implicit assumptions. - Fuzz tests for parsers and error paths.
go test -fuzz=FuzzDecoderegularly finds cases you never imagined, especially around string matching on errors.
Run at least one CI job with -race -count=20 -shuffle=on. The -shuffle flag is underrated: it exposes dependencies on test order, a close cousin of dependencies on map order.
6.6 Defensive perturbation: break yourself first
This idea is borrowed from MaybeReadByte. If you know where users will latch on, make those places unstable in test environments, so your own tests break before someone else’s production does.
A cheap and effective example:
// A valid io.Reader that returns a random number of bytes per read.
type jitterReader struct{ r io.Reader }
func (j jitterReader) Read(p []byte) (int, error) {
if len(p) > 1 && rand.Intn(2) == 0 {
p = p[:1+rand.Intn(len(p)-1)]
}
return j.r.Read(p)
}
Per the io.Reader spec, a reader may return fewer than len(p) bytes. Plenty of code assumes one Read is enough. Wrap your reader in jitterReader inside tests and you will know immediately which camp you are in.
Three more:
- In fake repositories, return rows in random order (still honoring
ORDER BYif the query has one). - In the test build, shuffle keys before serializing to catch order dependencies.
- Add 20% latency jitter to simulated dependencies to expose assumptions about timing and retries.
Tip
The goal of perturbation is not to make tests flaky. The goal is to turn a class of bug that “only happens in someone else’s production” into a red test in your CI. If a test fails 1 in 20 runs, do not retry until it goes green. Read it as evidence that you depend on a behavior nobody promised you.
6.7 Compatibility ratchet
When you must change a behavior that already has users, do not change it directly. Build a ratchet:
- Announce: changelog entry, deprecation note in docs, a warning log when the old path is used.
- Gate: support both behaviors, selected by config or by the version in
go.mod. - Instrument: measure what percentage of traffic still uses the old behavior. No number, no decision.
- Remove: delete only after that number is zero for at least one release cycle.
This is exactly what Go does with GODEBUG. Every changed behavior has a name in the godebugs table, can be toggled, is documented, and defaults are selected by the go directive in go.mod. Most of us do not have that infrastructure, but the idea copies fine: every behavior change needs a name, a flag, and an expiry date.
7. Trade-offs: you cannot win, only choose
If you have read this far and concluded “encapsulate everything, export nothing”, that conclusion is wrong, and it is why many Go APIs are needlessly unpleasant.
| Strategy | Benefit | Cost |
|---|---|---|
| Total encapsulation, expose nothing | Maximum freedom to refactor | Users resort to reflection or forks; performance drops |
| Return interfaces instead of structs | Hides implementation | Harder to debug, harder to mock, no field access |
| Freeze everything exposed | You never break anyone | API bloats and carries old mistakes forever |
| Change often, bump major version | Clean in theory | Ecosystem fragments, users leave |
The truth is that high-performance JSON libraries exist precisely because tight encapsulation is expensive. encoding/json is correct, safe, and maintained, but reflection costs. People accept //go:linkname risk for 30% throughput. That is a rational trade for a company with 1,000 servers. It is not rational when you copy it into a three-server service without knowing what you borrowed.
Here is how I think about it: you have a finite surface budget. Every symbol you export, every string you return, every field you name is an annuity you pay forever. Spend that budget where it matters: error codes, schemas, wire field names, status codes. Do not spend it on message wording, row order, or the exact moment a goroutine exits.
And remember: adding API is one-way. Removing API is nearly impossible. So the one rule that is always right is: ship less, later, and only after you can write down the semantics in prose.
8. Checklist
Before merging anything that touches a service boundary, I run through this:
- Any
err.Error()compared to a string literal? Convert toerrors.Isorerrors.Asbefore doing anything else. - Does the client-facing error have a stable
codeseparate frommessage? - Does every list query have
ORDER BYwith a unique tiebreaker? - Does anything return or iterate a map outward without sorting?
- Do response structs have explicit tags on every field, and are they separate DTOs?
- Is any test passing because of ordering, timing, or one
Readreturning enough bytes? - If behavior changes, is there a flag, a metric, and a removal date?
- Any use of
//go:linkname,unsafe, orreflectfield access I do not know about? (go vetandstaticcheckcatch most of it.)
Closing
Back to that comment in request.go. What struck me was not the technique but the attitude. A team with the authority to change anything in their language chose to write down: “this is no longer ours”.
Hyrum’s Law sounds pessimistic, but it is actually a positive statement about responsibility. If every observable behavior will become a contract, then choosing which behaviors to expose is system design. You cannot control what users latch onto. You only control how much you put within reach.
And if you are writing an API, remember: you do not own your API. Your users own it. Your job is to choose, very carefully, what they get to own.
Further reading
- Hyrum’s Law - the original, short and worth it.
- Go 1.23 Release Notes, Linker section - the new
//go:linknamepolicy. - golang/go#67401: cmd/link: lock down future uses of linkname - Russ Cox’s full reasoning, worth reading verbatim.
- Hyrum’s Law in Golang - the post that inspired this one.
- Go 1 compatibility promise - why all of the above matters so much to Go.


