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: GODEBUG is 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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.

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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
//go:debug default=go1.21
//go:debug panicnil=1
//go:debug asynctimerchan=0

The 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:

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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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, and parsegodebug.
  • src/runtime/runtime.go — godebugInc, godebugNotify, and the os.Setenv("GODEBUG", ...) hook.
  • src/runtime/extern.go — the runtime’s GODEBUG documentation: efence, gccheckmark, gcpacertrace, madvdontneed and the rest.
  • src/runtime/metrics.go — the runtime-side metric table and godebug_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, defaultGODEBUG and godebugForGoVersion.
  • src/cmd/go/internal/work/exec.go — checkDirectives, which rejects malformed or duplicated //go:debug lines.
  • 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 in doc/godebug.md, and every non-opaque setting must call IncNonDefault.

GoInternalsGodebugRuntimeCompatibility