skip to content

How do GODEBUG=cgocheck=1 and GOEXPERIMENT=cgocheck2 differ in the cgo pointer violations they catch?

level: seniorimportance: nice to knowfreq 22%

answer

  1. One is cheap, one rebuilds the world
  2. Arguments at the door, or every write
  3. Build time, not run time, for strict
  4. It moved out of GODEBUG recently
  5. Same epistemics as the race detector

basics

~10 s

cgocheck=1 is the cheap default, inspecting pointer arguments at each cgo call. cgocheck2 is a build-time GOEXPERIMENT that catches Go pointers stored into non-Go memory. Both are dynamic.

solid answer

~50 s

The default, `GODEBUG=cgocheck=1`, checks pointer arguments as they cross into C: it catches a Go pointer that leads to another unpinned Go pointer, and aborts the process. It is cheap because it inspects only what is being passed, and it is blind to what C does afterwards. `GOEXPERIMENT=cgocheck2` is set at **build** time, not run time — it was moved out of `GODEBUG` in Go 1.21 — and it turns on write barriers across the whole program so that a Go pointer being stored into non-Go memory is caught at the store. That covers the class the cheap mode cannot see, at a large, program-wide slowdown, so it is a CI or soak-test build rather than a production one. `GODEBUG=cgocheck=0` disables the default checks and should never ship. Neither is static analysis: a clean run only tells you the paths you exercised were clean.

code

text · 8 lines
text
# default: cheap checks on pointer arguments at each cgo call
$ go run ./cmd/pipeline

# thorough: rebuilds the whole program with write barriers on
$ GOEXPERIMENT=cgocheck2 go run ./cmd/pipeline

# do not ship this - it turns the default checks off
$ GODEBUG=cgocheck=0 go run ./cmd/pipeline

go deeper

for a junior

Know that the runtime checks cgo pointer arguments by default and aborts on a violation, and that you should not be turning that off. Recognise the setting name when you see it in a runbook.

for a middle

Explain the split: the default inspects pointer arguments at the call, while the thorough mode is a build-time experiment that keeps write barriers on to catch Go pointers stored into non-Go memory. Say why the second cannot be a runtime switch.

for a senior

Show an investigation order for corruption you cannot localise, and be explicit that both modes are dynamic, so coverage bounds what they prove. Know that the expensive mode belongs in CI or a soak test, never in production.

for a principal

Decide the standing posture: default checks on everywhere, one CI job on the thorough build, and a documented expectation that any retaining C function is flagged at its call site. The cost is a pipeline job against a class of bug that otherwise consumes weeks.

## Why a checker is needed at all The pointer-passing rules cannot be enforced by the compiler, because the interesting half of each rule is about what C does after it has your pointer. The runtime therefore does what it can dynamically, and it comes in two strengths with very different costs. ## cgocheck=1, the default Every cgo call goes through generated code that inspects the pointer arguments before entering C. It follows a passed Go pointer and checks whether the memory it leads to contains further Go pointers to unpinned memory; if it does, the program aborts with a message naming that condition. It also checks that a Go function exported to C does not return a Go pointer. This mode is on by default, and it is deliberately cheap: it inspects only the argument being passed, at the moment of the call. That means it catches the *content* rule reliably and the *retention* rule not at all. If C stashes your `*int` in a global and dereferences it three calls later, cgocheck=1 saw a legal argument and said nothing. `GODEBUG=cgocheck=0` switches it off. There is a narrow performance argument for that in a hot cgo loop and essentially no good reason to ship it; if the checks cost enough to matter, the call itself almost certainly does too. ## cgocheck2, the thorough mode The expensive mode is enabled at build time with `GOEXPERIMENT=cgocheck2`. It used to be `GODEBUG=cgocheck=2` and moved to a build-time experiment in Go 1.21 — a detail worth knowing, because a runbook written before that change tells you to set a `GODEBUG` that no longer does anything. It works by keeping write barriers enabled on **all** pointer writes, not just the ones the collector needs, so the runtime can inspect every store of a Go pointer and complain when the destination is not Go memory. That is the class of violation the cheap mode structurally cannot see: C writing a Go pointer into a C struct, or Go code itself writing one into memory that turns out to be C-allocated. The cost is the whole program, not just the cgo calls. Every pointer write in every package pays. It is a mode you build for a CI job, a soak test, or a bisect when you have corruption you cannot localise — never a production posture. ## What both modes cannot tell you They are dynamic checks, with the same epistemics as the race detector: they report violations that **actually happened** during the run. A clean run proves nothing about a path that did not execute — the error branch that passes a different struct, the retry that reuses a stale context, the code path only a particular input reaches. "We ran the suite under cgocheck2 and it was clean" is a real signal about the covered paths and no signal at all about the rest. If the C boundary is worth checking, it is worth a test that deliberately drives the unusual paths through it. ## Diagnosing the failure they exist to prevent The underlying bug — a Go pointer retained on the C side and later invalidated — does not present as a panic. It presents as corruption: a field with an impossible value, a map that misbehaves, a crash in code that never touched cgo, all of it minutes and megabytes away from the cause. That distance is what makes the checkers worth building into a pipeline rather than reaching for after the fact. A workable sequence when you suspect it: reproduce under the default checks first, since a content-rule violation aborts immediately and is the cheapest possible answer; then rebuild with `GOEXPERIMENT=cgocheck2` and rerun the suite, which catches the storing class; then, if it is still hiding, build with `-asan` or `-msan` to bring a C-level memory checker to bear on the C side, which is where a retention violation actually manifests. Alongside all of it, re-read the C API's documentation for anything that retains what you pass — that reading often finds the bug faster than any of the tools. ## The reviewer's version of this For the pull request that adds a repository's first cgo call, the useful posture is: default checks stay on in every build, a CI job builds the package with `GOEXPERIMENT=cgocheck2` and runs its tests, and any C function that retains a pointer is called out in a comment at the call site. None of that is expensive, and it converts a class of bug that is nearly undebuggable into one that fails loudly in a pipeline.

  • Why is the thorough mode a GOEXPERIMENT rather than a GODEBUG setting?
    Because it changes how the program is compiled: it keeps write barriers on for every pointer store so the runtime can inspect each one. That is not something a process can switch on at startup, so it moved from GODEBUG to a build-time experiment in Go 1.21. A runbook that still says to set GODEBUG=cgocheck=2 is quietly doing nothing.
  • Your test suite runs clean under both modes. What have you actually established?
    That the paths your tests executed contained no detected violation. These are runtime checks, not analysis, so an unexercised error branch, retry, or rare input can still carry the bug. Treat the result the way you treat a clean race-detector run: evidence proportional to coverage, and a reason to drive the odd paths deliberately.
  • Which violation class does the default mode structurally miss?
    Retention. It inspects arguments at the moment of the call, so a pointer that is legal to pass and then stored by C for later use looks perfectly fine. Nothing in the argument tells the runtime what the callee will do with it, which is why that rule is enforced by code review and by reading the C library's documentation rather than by tooling.

saying these in an interview costs you the question

  • Setting GODEBUG=cgocheck=2 and expecting the thorough mode
  • Treating a clean run as proof the code obeys the rules
  • Running the expensive mode in production for safety
  • Disabling the default checks to make a crash go away
  • Calling either mode a static analysis of the C boundary