GODEBUG: The Knobs Go Gives You to Turn Up the Volume
GODEBUG is two different mechanisms wearing one name: a table of compatibility settings that defaults change against, and a set of runtime debug knobs, all wired through one environment variable that can even be changed while the program runs.
TL;DR:
GODEBUGis two mechanisms wearing one name: a table of compatibility settings (panicnil,httplaxcontentlength) whose defaults change every release, and the runtime’s own debug knobs (efence,gccheckmark). Both read the same environment variable, mid-run.
Go has a reputation for never breaking your code. That reputation is mostly true, and the mechanism behind it is not subtle: when a change would break existing programs, Go ships a switch that turns the new behaviour off, flips the default in a later release, and eventually deletes the switch. That switch is a GODEBUG setting.
The other thing GODEBUG does is give you debug knobs for the runtime and the
standard library — GODEBUG=efence=1 to find memory corruption,
GODEBUG=gctrace=1 to watch the garbage collector, GODEBUG=asyncpreemptoff=1
to make the scheduler behave.
This is a walk through how that actually works in the Go 1.28 tree.
Two mechanisms, one variable
The runtime’s own documentation starts with the debug-knob flavour:
// src/runtime/extern.go:35
The GODEBUG variable controls debugging variables within the runtime.
It is a comma-separated list of name=val pairs setting these named variables:
clobberfree: setting clobberfree=1 causes the garbage collector to
clobber the memory content of an object with bad content when it frees
the object.and the knobs are documented inline, right next to the code that reads them:
// src/runtime/extern.go:94
efence: setting efence=1 causes the allocator to run in a mode
where each object is allocated on a unique page and addresses are
never recycled.
gccheckmark: setting gccheckmark=1 enables verification of the
garbage collector's concurrent mark phase by performing a
second mark pass while the world is stopped. If the second
pass finds a reachable object that was not found by concurrent
mark, the garbage collector will panic.
gcpacertrace: setting gcpacertrace=1 causes the garbage collector to
print information about the internal state of the concurrent pacer.These are internal to the runtime. They are parsed into a table of plain
int32 fields:
// src/runtime/runtime1.go:358
var dbgvars = []*dbgVar{
{name: "adaptivestackstart", value: &debug.adaptivestackstart},
{name: "asyncpreemptoff", value: &debug.asyncpreemptoff},
{name: "cgocheck", value: &debug.cgocheck},
{name: "clobberfree", value: &debug.clobberfree},
{name: "containermaxprocs", value: &debug.containermaxprocs, def: 1},
{name: "dataindependenttiming", value: &debug.dataindependenttiming},
{name: "decoratemappings", value: &debug.decoratemappings, def: 1},
{name: "disablethp", value: &debug.disablethp},
{name: "dontfreezetheworld", value: &debug.dontfreezetheworld},
{name: "checkfinalizers", value: &debug.checkfinalizers},
{name: "efence", value: &debug.efence},
{name: "gccheckmark", value: &debug.gccheckmark},
{name: "gcpacertrace", value: &debug.gcpacertrace},
{name: "gcshrinkstackoff", value: &debug.gcshrinkstackoff},
{name: "gcstoptheworld", value: &debug.gcstoptheworld},
{name: "gctrace", value: &debug.gctrace},
{name: "harddecommit", value: &debug.harddecommit},
{name: "inittrace", value: &debug.inittrace},
{name: "invalidptr", value: &debug.invalidptr},
{name: "madvdontneed", value: &debug.madvdontneed},
{name: "panicnil", atomic: &debug.panicnil},
{name: "profstackdepth", value: &debug.profstackdepth, def: 128},
{name: "sbrk", value: &debug.sbrk},
{name: "scavtrace", value: &debug.scavtrace},
{name: "scheddetail", value: &debug.scheddetail},
{name: "schedtrace", value: &debug.schedtrace},
{name: "traceadvanceperiod", value: &debug.traceadvanceperiod},
{name: "traceallocfree", atomic: &debug.traceallocfree},
{name: "tracecheckstackownership", value: &debug.traceCheckStackOwnership},
{name: "tracebackancestors", value: &debug.tracebackancestors},
{name: "tracebackcrash", value: &debug.tracebackcrash},
{name: "tracebacklabels", atomic: &debug.tracebacklabels, def: 1},
{name: "tracefpunwindoff", value: &debug.tracefpunwindoff},
{name: "updatemaxprocs", value: &debug.updatemaxprocs, def: 1},
}Notice the def: field on a few of them. Those are the settings whose default
is not zero, and the default is applied before the environment is parsed:
// src/runtime/runtime1.go:395
func parseRuntimeDebugVars(godebug string) {
// defaults
debug.cgocheck = 1
debug.invalidptr = 1
debug.adaptivestackstart = 1 // set this to 0 to turn larger initial goroutine stacks off
if GOOS == "linux" {
// On Linux, MADV_FREE is faster than MADV_DONTNEED,
// but doesn't affect many of the statistics that
// MADV_DONTNEED does until the memory is actually
// reclaimed. This generally leads to poor user
// experience, like confusing stats in top and other
// monitoring tools; and bad integration with
// management systems that respond to memory usage.
// Hence, default to MADV_DONTNEED.
debug.madvdontneed = 1
}The madvdontneed story is a nice illustration of a knob that is genuinely
two-sided: on Linux the default is MADV_DONTNEED (memory is returned to the
OS immediately, RSS drops, but it costs more); setting madvdontneed=0 uses
MADV_FREE, which is cheaper but means RSS numbers stay high until the
kernel is under pressure. On the BSDs the default is the other way round. The
setting exists so the same binary can be tuned per platform.
Then the environment is parsed on top:
// src/runtime/runtime1.go:413
// apply runtime defaults, if any
for _, v := range dbgvars {
if v.def != 0 {
// Every var should have either v.value or v.atomic set.
if v.value != nil {
*v.value = v.def
} else if v.atomic != nil {
v.atomic.Store(v.def)
}
}
}
// apply compile-time GODEBUG settings
parsegodebug(godebugDefault, nil)
// apply environment settings
parsegodebug(godebug, nil)The compatibility table
The other half of GODEBUG is the compatibility mechanism, and it has its own
package: internal/godebugs. The whole thing is one slice of structs.
// src/internal/godebugs/table.go:10
// An Info describes a single known GODEBUG setting.
type Info struct {
Name string // name of the setting ("panicnil")
Package string // package that uses the setting ("runtime")
Changed int // minor version when default changed, if any; 21 means Go 1.21
Old string // value that restores behavior prior to Changed
Opaque bool // setting does not export information to runtime/metrics using [internal/godebug.Setting.IncNonDefault]
Immutable bool // setting cannot be changed after program start
}Six fields, and they are the whole story of Go’s compatibility policy.
Name is the key in the environment variable. Package says who owns it.
Changed is the Go minor version in which the default flipped — 21 means
Go 1.21. Old is the value that restores the old behaviour. Opaque means
the setting cannot report itself to runtime/metrics. Immutable means it
cannot be changed after the program starts.
Here is the actual table:
// src/internal/godebugs/table.go:43
{Name: "http2client", Package: "net/http"},
{Name: "http2debug", Package: "net/http", Opaque: true},
{Name: "http2server", Package: "net/http"},
{Name: "httpcookiemaxnum", Package: "net/http", Changed: 24, Old: "0"},
{Name: "httplaxcontentlength", Package: "net/http", Changed: 22, Old: "1"},
{Name: "httpmuxgo121", Package: "net/http", Changed: 22, Old: "1"},
{Name: "httpservecontentkeepheaders", Package: "net/http", Changed: 23, Old: "1"},
{Name: "httpservecontentmaxranges", Package: "net/http", Changed: 26, Old: "1"},
{Name: "installgoroot", Package: "go/build"},
{Name: "jstmpllitinterp", Package: "html/template", Opaque: true}, // bug #66217: remove Opaque
//{Name: "multipartfiles", Package: "mime/multipart"},
{Name: "multipartmaxheaders", Package: "mime/multipart"},
{Name: "multipartmaxparts", Package: "mime/multipart"},
{Name: "multipathtcp", Package: "net", Changed: 24, Old: "0"},
{Name: "netdns", Package: "net", Opaque: true},
{Name: "netedns0", Package: "net", Changed: 19, Old: "0"},
{Name: "netmarshal", Package: "net", Changed: 30, Old: "0"},
{Name: "panicnil", Package: "runtime", Changed: 21, Old: "1"},
{Name: "randautoseed", Package: "math/rand"},
{Name: "randseednop", Package: "math/rand", Changed: 24, Old: "0"},
{Name: "rsa1024min", Package: "crypto/rsa", Changed: 24, Old: "0"},
{Name: "tarinsecurepath", Package: "archive/tar"},
{Name: "tlsmaxrsasize", Package: "crypto/tls"},
{Name: "tlsmlkem", Package: "crypto/tls", Changed: 24, Old: "0", Opaque: true},httplaxcontentlength is a good one to understand. Go 1.22 changed how
net/http handles a Content-Length on a request whose body is empty or
whose length disagrees with the body. If your program depended on the old
lenient behaviour, you set httplaxcontentlength=1 and nothing changes. When
you are ready, you delete it. The Changed: 22, Old: "1" fields say exactly
that: the default changed in Go 1.22, and "1" is the value that gets you
back to Go 1.21 behaviour.
netmarshal is the interesting one because it points the other way. Changed: 30 means the default does not change until Go 1.30 — Go 1.28 and 1.29 both
ship netmarshal=0, and only in 1.30 does it flip to 1. The documentation
in doc/godebug.md explains why: Go 1.28 and 1.29 want their JSON output to
remain readable by older versions of Go.
Registering a setting
Any package can add a setting, but it has to do it by the book. The rule is
that you declare a global and call godebug.New:
// src/internal/godebug/godebug.go:79
// New returns a new Setting for the $GODEBUG setting with the given name.
//
// GODEBUGs meant for use by end users must be listed in ../godebugs/table.go,
// which is used for generating and checking various documentation.
// If the name is not listed in that table, New will succeed but calling Value
// on the returned Setting will panic.
// To disable that panic for access to an undocumented setting,
// prefix the name with a #, as in godebug.New("#gofsystrace").
// The # is a signal to New but not part of the key used in $GODEBUG.
//
// Note that almost all settings should arrange to call [IncNonDefault] precisely
// when program behavior is changing from the default due to the setting
// (not just when the setting is different, but when program behavior changes).
// See the [internal/godebug] package comment for more.
func New(name string) *Setting {
return &Setting{name: name}
}The panic on an unknown name is deliberate and is what keeps the table honest:
// src/internal/godebug/godebug.go:157
func (s *Setting) Value() string {
s.once.Do(func() {
s.setting = lookup(s.Name())
if s.info == nil && !s.Undocumented() {
panic("godebug: Value of name not listed in godebugs.All: " + s.name)
}
})
v := *s.value.Load()
if v.bisect != nil && !v.bisect.Stack(&stderr) {
return ""
}
return v.text
}If you add a setting and forget to add it to internal/godebugs/table.go, the
program panics the first time anybody reads the setting. There is a test in
internal/godebugs that enforces the convention that the global variable is
named after the setting.
The Value method is designed to be called on a hot path. The cache is a
sync.Map of *atomic.Pointer[value], so all settings with the same name
share one atomic pointer, and updating GODEBUG at runtime is a single atomic
store per changed setting.
Parsing, and re-parsing
The parser is small and a little clever. It scans the string backwards, so later settings win and earlier ones are ignored without ever briefly taking effect:
// src/internal/godebug/godebug.go:260
// parse parses the GODEBUG setting string s,
// which has the form k=v,k2=v2,k3=v3.
// Later settings override earlier ones.
// Parse only updates settings k=v for which did[k] = false.
// It also sets did[k] = true for settings that it updates.
// Each value v can also have the form v#pattern,
// in which case the GODEBUG is only enabled for call stacks
// matching pattern, for use with golang.org/x/tools/cmd/bisect.
func parse(did map[string]bool, s string) {
// Scan the string backward so that later settings are used
// and earlier settings are ignored.
// Note that a forward scan would cause cached values
// to temporarily use the ignored value before being
// updated to the "correct" one.
end := len(s)
eq := -1
for i := end - 1; i >= -1; i-- {That #pattern suffix is a nice touch: it lets a setting apply only on
certain call stacks, which is what golang.org/x/tools/cmd/bisect uses to
narrow down which of your dependencies is responsible for a behaviour change.
The update function decides which of the two sources wins:
// src/internal/godebug/godebug.go:230
// update records an updated GODEBUG setting.
// def is the default GODEBUG setting for the running binary,
// and env is the current value of the $GODEBUG environment variable.
func update(def, env string) {
updateMu.Lock()
defer updateMu.Unlock()
// Update all the cached values, creating new ones as needed.
// We parse the environment variable first, so that any settings it has
// are already locked in place (did[name] = true) before we consider
// the defaults. Existing immutable settings are always locked.
did := make(map[string]bool)
cache.Range(func(name, s any) bool {
if info := s.(*setting).info; info != nil && info.Immutable {
did[name.(string)] = true
}
return true
})
parse(did, env)
parse(did, def)The environment wins over the compiled-in defaults, always. And because the
runtime keeps a pointer to the environment string, os.Setenv("GODEBUG", ...)
at runtime works:
// src/runtime/runtime.go:199
//go:linkname syscall_runtimeSetenv syscall.runtimeSetenv
func syscall_runtimeSetenv(key, value string) {
setenv_c(key, value)
if key == "GODEBUG" {
p := new(string)
*p = value
godebugEnv.Store(p)
godebugNotify(true)
}
}That is a genuinely unusual capability: you can flip a compatibility setting in
a running process. It is why the godebug package has to be careful about
re-entrancy, and why IncNonDefault uses a sync.Once to register its
metric exactly once.
//go:debug and the go.mod block
Environment variables are awkward for deployed software. Go 1.21 gave main packages a source-level directive:
//go:debug default=go1.21
//go:debug panicnil=1
//go:debug asynctimerchan=0The toolchain parses these out of the source and bakes them into the binary as
the default GODEBUG string, which is what godebugDefault is in the
runtime. The parsing lives in cmd/go:
// src/cmd/go/internal/load/godebug.go:24
func ParseGoDebug(text string) (key, value string, err error) {
if !strings.HasPrefix(text, "//go:debug") {
return "", "", ErrNotGoDebug
}
i := strings.IndexAny(text, " \t")
if i < 0 {
if strings.TrimSpace(text) == "//go:debug" {
return "", "", fmt.Errorf("missing key=value")
}
return "", "", ErrNotGoDebug
}
k, v, ok := strings.Cut(strings.TrimSpace(text[i:]), "=")
if !ok {
return "", "", fmt.Errorf("missing key=value")
}
if err := modload.CheckGodebug("//go:debug setting", k, v); err != nil {
return "", "", err
}
return k, v, nil
}and the assembled default is computed from the module’s Go version:
// src/cmd/go/internal/load/godebug.go:134
func godebugForGoVersion(v string) map[string]string {
if strings.Count(v, ".") >= 2 {
i := strings.Index(v, ".")
j := i + 1 + strings.Index(v[i+1:], ".")
v = v[:j]
}
if !strings.HasPrefix(v, "1.") {
return nil
}
n, err := strconv.Atoi(v[len("1."):])
if err != nil {
return nil
}
def := make(map[string]string)
for _, info := range godebugs.All {
if n < info.Changed {
def[info.Name] = info.Old
}
}
return def
}This is the whole backwards-compatibility story in one function. If your
go.mod says go 1.21, every setting whose default changed in Go 1.22 or
later is set to its Old value. Your 2021 code keeps getting 2021 behaviour
even when you build it with a Go 1.28 toolchain.
Since Go 1.23 you can also do it from go.mod directly:
// src/cmd/go/internal/load/godebug.go:81
// Add directives from main module go.mod.
for _, g := range ld.MainModules.Godebugs(ld) {
if m == nil {
m = make(map[string]string)
}
m[g.Key] = g.Value
}and the default=go1.21 key lets you decouple “which Go version’s defaults
do I want” from “which Go language version am I using”.
The toolchain also enforces that you do not contradict yourself:
// src/cmd/go/internal/work/exec.go:1078
func (b *Builder) checkDirectives(a *Action) error {
var msg []byte
p := a.Package
var seen map[string]token.Position
for _, d := range p.Internal.Build.Directives {
if strings.HasPrefix(d.Text, "//go:debug") {
key, _, err := load.ParseGoDebug(d.Text)
if err != nil && err != load.ErrNotGoDebug {
msg = fmt.Appendf(msg, "%s: invalid //go:debug: %v\n", d.Pos, err)
continue
}
if pos, ok := seen[key]; ok {
msg = fmt.Appendf(msg, "%s: repeated //go:debug for %v\n\t%s: previous //go:debug\n", d.Pos, key, pos)
continue
}Two //go:debug panicnil=1 lines in the same package is a build error.
Counting non-default behaviour
The final piece is observability. If you set a GODEBUG in production, how do you know it is actually changing behaviour? The answer is a metric per setting.
Every setting gets a /godebug/non-default-behavior/<name>:events metric,
generated from the same table:
// src/runtime/metrics/description.go:514
func init() {
// Insert all the non-default-reporting GODEBUGs into the table,
// preserving the overall sort order.
i := 0
for i < len(allDesc) && allDesc[i].Name < "/godebug/" {
i++
}
more := make([]Description, i, len(allDesc)+len(godebugs.All))
copy(more, allDesc)
for _, info := range godebugs.All {
if !info.Opaque {
more = append(more, Description{
Name: "/godebug/non-default-behavior/" + info.Name + ":events",
Description: "The number of non-default behaviors executed by the " +
info.Package + " package " + "due to a non-default " +
"GODEBUG=" + info.Name + "=... setting.",
Kind: KindUint64,
Cumulative: true,
})
}
}
allDesc = append(more, allDesc[i:]...)
}The counter is only incremented when the behaviour actually changes, not merely
when the setting is set — that is why the godebug package’s documentation is
so insistent about it:
// src/internal/godebug/godebug.go:115
// IncNonDefault increments the non-default behavior counter
// associated with the given setting.
// This counter is exposed in the runtime/metrics value
// /godebug/non-default-behavior/<name>:events.
//
// Note that Value must be called at least once before IncNonDefault.
func (s *Setting) IncNonDefault() {
s.nonDefaultOnce.Do(s.register)
s.nonDefault.Add(1)
}The runtime cannot import internal/godebug (circular dependency), so it
shims it:
// src/runtime/runtime.go:155
// A godebugInc provides access to internal/godebug's IncNonDefault function
// for a given GODEBUG setting.
// Calls before internal/godebug registers itself are dropped on the floor.
type godebugInc struct {
name string
inc atomic.Pointer[func()]
}and the metric registration is a go:linkname hop into the runtime:
// src/runtime/metrics.go:571
//go:linkname godebug_registerMetric internal/godebug.registerMetric
func godebug_registerMetric(name string, read func() uint64) {
metricsLock()
initMetrics()
d, ok := metrics[name]
if !ok {
throw("runtime: unexpected metric registration for " + name)
}
d.compute = metricReader(read).compute
metrics[name] = d
metricsUnlock()
}Removing a setting
Eventually a setting is no longer needed. The table has a second list for that:
// src/internal/godebugs/table.go:87
// A RemovedInfo describes a GODEBUG setting that has been removed.
//
// Per the policy for removing GODEBUG settings (go.dev/issue/76163), using any non-"old"
// value for the removed setting is permissible. Using an old value, indicating a non-final
// default value for that setting, leads to a build or vet error.
// To support multiple "old" values (see asynctimerchan), the Old field is a predicate.
type RemovedInfo struct {
Name string // name of the removed GODEBUG setting.
Removed int // minor version of Go, when the removal happened
Old func(string) bool // reports whether the GODEBUG value (argument) is an old (invalid) value
}
// Removed contains all GODEBUGs that we have removed.
//
// Every removed GODEBUG must have a corresponding section in doc/godebug.md,
// since cmd/go links to that document when a removed GODEBUG is used.
var Removed = []RemovedInfo{
{Name: "x509sha1", Removed: 24, Old: one}, // Old: "1"
{Name: "tlskyber", Removed: 24, Old: zero}, // Old: "0"
{Name: "gotypesalias", Removed: 27, Old: zero}, // Old: "0"
{Name: "tlsunsafeekm", Removed: 27, Old: one}, // Old: "1"
{Name: "tlsrsakex", Removed: 27, Old: one}, // Old: "1"
{Name: "tls3des", Removed: 27, Old: one}, // Old: "1"
{Name: "tls10server", Removed: 27, Old: one}, // Old: "1"
{Name: "x509keypairleaf", Removed: 27, Old: zero}, // Old: "0"
{Name: "asynctimerchan", Removed: 27, Old: func(s string) bool { return s == "1" || s == "2" }}, // Old: "1" or "2"
}This is the endgame of the compatibility cycle. tls10server was the switch
that let you re-enable TLS 1.0 on the server; it was removed in Go 1.27
because TLS 1.0 is dead. asynctimerchan was the switch that kept the old
unbuffered time.Timer channel behaviour; it was removed in Go 1.27, and it
needed a predicate rather than a single value because it had two old values
(“1” and “2”).
Crucially, setting a removed GODEBUG to a non-old value is fine — only asking for the behaviour that no longer exists is an error. And that error is raised at startup:
// src/runtime/proc.go:219
// If we encountered a removed GODEBUG during startup we can panic now.
if k := invalidGODEBUG.key; k != "" {
v := invalidGODEBUG.value
r := strconv.Itoa(invalidGODEBUG.removed)
fatal(`removed GODEBUG "` + k + `" set to old value "` + v + `" in environment (https://go.dev/doc/godebug#go-1` + r + `)`)
}with a link to the history section of doc/godebug.md so you know which
version changed it.
Trade-offs
What GODEBUG buys you. A language can change behaviour without breaking
anyone, as long as it tells you how to get the old behaviour back. It also
gives operators a set of runtime knobs that would otherwise require a rebuild
— efence=1 to find a use-after-free, gcpacertrace=1 to understand why the
GC is pacing badly, madvdontneed=0 to trade RSS reporting accuracy for
scavenger throughput.
What it costs. Every setting is permanent API surface. The table is
append-only in practice, the documentation has to track it, the go.mod
version logic has to stay consistent with it, and the metrics have to be
generated from it. Go has been stuck with http2server and http2client for
years precisely because removing a GODEBUG is a breaking change of a different
kind.
Why the environment is not the only interface. //go:debug and the
go.mod block exist because a deployment often cannot set environment
variables, and because a setting that has been vetted in source should not
depend on the environment of the machine it happens to run on.
Why the metrics matter. Without /godebug/non-default-behavior/...:events
you have no idea whether a setting you set in production is doing anything at
all. The counter turns “I set a flag” into “the flag fired 4 million times”,
which is the difference between a real hypothesis and a guess.
Caveats
Unrecognised GODEBUGs are silently ignored. GODEBUG=typo=1 does nothing
and does not warn you. There is no “unknown GODEBUG” diagnostic.
A setting that is not in the table panics. If you call
godebug.New("something") for a name that is not in internal/godebugs.All,
New succeeds but Value() panics. That is the mechanism that keeps the
table honest, and it also means a package that adds a setting without adding
it to the table crashes at first use, not at build time.
Opaque: true means no metric. A handful of settings — netdns,
http2debug, dataindependenttiming — are marked opaque because it is
infeasible to count their non-default behaviour. The table comment says why
for one of them:
// src/internal/godebugs/table.go:69
// Mark tracebacklabels as Opaque so we don't generate a metric that we can't increment.
// IncNonDefault uses a sync.Once, which involves sync.Mutex, and is not safe from a signal handler.
// (Tracebacks are generated in signal-handlers.)
{Name: "tracebacklabels", Package: "runtime", Changed: 27, Old: "0", Opaque: true},Immutable: true settings cannot be changed at runtime. fips140 is one.
It is read once at startup and the rest of the program relies on that.
GODEBUG is not a configuration system. There are no typed values, no
validation beyond “is this an integer that parses”, and no way to enumerate
what is currently set — except by reading runtime/metrics for the
non-default counters.
Runtime debug knobs are for debugging, not for tuning. efence=1 will
make your program allocate a page per object. sbrk=1 replaces the entire
allocator with one that never reclaims memory. These are diagnostic tools.
The go.mod godebug block requires Go 1.23 or later. Older toolchains
reject it outright, and the default=goX.Y key does nothing before Go 1.21.
Wrapping up
The one thing to remember: GODEBUG is the mechanism that makes Go’s
backwards-compatibility promise actionable. When a behaviour changes, Go
ships a switch, records the old value in internal/godebugs/table.go, and the
toolchain computes your defaults from the Go version in your go.mod.
The part people get wrong: a GODEBUG set in the environment overrides the
compiled-in default, but the compiled-in default already reflects your
go.mod version. Setting GODEBUG=panicnil=1 on a program whose go.mod
says go 1.21 is redundant; setting it on one that says go 1.22 is not.
What to go do about it: when a dependency or a Go upgrade changes
behaviour, read doc/godebug.md — it has a section per release listing every
setting that changed and the exact value that restores the old behaviour. Then
add the //go:debug line to your main package so the fix travels with the
source.
Further reading
src/internal/godebugs/table.go— the table of every known GODEBUG setting and every removed one.src/internal/godebug/godebug.go—Setting,New,Value,IncNonDefault, and the backwards parser.src/runtime/runtime1.go—dbgvars, the runtime’s own debug knobs, andparsegodebug.src/runtime/runtime.go—godebugInc,godebugNotify, and theos.Setenv("GODEBUG", ...)hook.src/runtime/extern.go— the runtime’s GODEBUG documentation:efence,gccheckmark,gcpacertrace,madvdontneedand the rest.src/runtime/metrics.go— the runtime-side metric table andgodebug_registerMetric.src/runtime/metrics/description.go— the generated descriptions, including the per-setting non-default-behaviour counters.src/cmd/go/internal/load/godebug.go—ParseGoDebug,defaultGODEBUGandgodebugForGoVersion.src/cmd/go/internal/work/exec.go—checkDirectives, which rejects malformed or duplicated//go:debuglines.doc/godebug.md— the user-facing history: what changed in which release, and how to get the old behaviour back.src/internal/godebugs/godebugs_test.go— the test that enforces the conventions: the table must be sorted, every setting must be documented indoc/godebug.md, and every non-opaque setting must callIncNonDefault.