Your monkey-patching test works fine with go test. You move it to Bazel, and it silently stops patching. The function you’re trying to replace got inlined. You add -N -l to the go_test target. Still broken. You add it globally via --gc_goopts. Now it works, but your build takes three times as long and a large package starts failing to compile.
Welcome to the world of Go compiler and linker flags in Bazel. There are three layers where flags can live, each with different scope, semantics, and failure modes. Understanding which layer to use – and why the others don’t work – saves you from whack-a-mole debugging.
The Three Layers
When you set a compiler or linker flag for Go in Bazel, it enters one of three layers:
- Plain Go – the
cmd/gotool, what you’d use without Bazel - rules_go – Bazel’s Go ruleset, which wraps the Go toolchain
- Bazel/C++/OS toolchain – global knobs that affect everything, including Go
Each layer has its own flags, its own syntax, and its own blast radius. Mixing them up is where things break.
Go Compiler Flags
The two flags you’ll encounter most are -N (disable optimizations) and -l (disable inlining). They go into gc_goopts in rules_go.
| Flags | Effect | Use Case | Notes |
|---|---|---|---|
| None | Optimized + inlined | Normal builds, fast tests | Best default |
-N |
Disable optimizations | Rare, low-level debugging | Warning: still inlines functions |
-l |
Disable inlining | Sometimes enough for patching | Warning: optimizations still apply |
-N -l |
No optimizations, no inlining | Mocking / monkey-patching tests | Slow, heavy; may fail on large pkgs |
The combination -N -l is the one you want for monkey-patching frameworks. But it’s heavy – it bloats compilation time and memory usage. On large packages, the compiler can choke entirely.
The key insight: use these per-target, not globally. More on that below.
Linker Flags: Layer by Layer
This is where the three-layer model really matters. The same concept – say, building a static binary – has a different flag in each layer. Use the wrong one and it either silently does nothing or breaks in confusing ways.
Layer 1: Plain Go
This is what go build gives you. No Bazel involved.
# PIE binary
go build -buildmode=pie
# Static binary (pure Go, no cgo)
CGO_ENABLED=0 go build -ldflags '-extldflags "-static"'
Straightforward. But these flags don’t translate directly into Bazel – rules_go has its own abstraction.
Layer 2: rules_go
This is where you should be setting most Go-specific flags in Bazel.
# PIE binary
go_binary(
name = "myapp",
srcs = ["main.go"],
linkmode = "pie",
)
# Static binary (pure Go)
go_binary(
name = "myapp",
srcs = ["main.go"],
static = "on",
pure = "on",
)
rules_go exposes high-level attributes – linkmode, static, pure – that do the right thing across platforms. Prefer these over raw flags.
You can pass raw linker flags via gc_linkopts:
go_binary(
name = "myapp",
srcs = ["main.go"],
gc_linkopts = ["-extldflags=-static"],
)
But this is brittle. -extldflags only takes effect under external linking. If Go uses its internal linker (the default for pure Go builds), the flag is silently ignored. The static and pure attributes handle this correctly regardless of linking mode.
Layer 3: Bazel/C++/OS Toolchain
This is the nuclear option. Flags set here affect the entire build – Go, C++, everything linked by the toolchain.
# Push PIE globally (affects C++/mixed targets, not Go internal linking)
bazel build //... --linkopt='-pie'
# PIC for C/C++ dependencies
cc_library(
name = "mylib",
srcs = ["mylib.c"],
copts = ["-fPIC"],
)
This layer exists as a last-resort policy knob. It’s tempting to use for consistency – “just set it everywhere” – but it’s dangerous for four reasons:
- Blast radius. Global flags affect every target using that toolchain, including ones you didn’t think about. Go targets can get caught in the crossfire.
- Priority conflicts. Toolchain-level flags can silently override the fine-grained configuration you set in rules_go.
- Debugging is painful. When something breaks, you have to dig through Bazel’s internal action commands to figure out which layer contributed which flag.
- Portability. Low-level options behave differently across platforms and toolchain versions. What works on Linux might silently break on macOS.
Compatibility Gotchas
Some flag combinations interact badly:
| Combination | Problem |
|---|---|
-pie + -fno-PIC |
PIE expects PIC objects. Mixing them causes link failures. |
-static + large code |
Higher risk of relocation overflow with non-PIC code. |
-static + -pie |
“Static PIE” – a niche mode that’s toolchain-dependent. |
-extldflags + internal linking |
Silently ignored. Not an error, just no effect. |
The rule of thumb: prefer rules_go attributes (linkmode, static, pure) over raw flags. They handle the platform-specific interactions for you.
Global vs. Per-Target Flags
Not all flags are created equal. Some are fine to set globally; others will ruin your day.
Safe globally: -s -w
These strip symbol tables (-s) and debug info (-w) from binaries. Common in CI and release builds.
bazel test //... \
--@io_bazel_rules_go//go/config:gc_linkopts="-s -w"
Impact: smaller binaries. No behavior changes. No compilation slowdown.
Not safe globally: -N -l
Disabling optimizations and inlining across every target is a bad idea:
- Slows all compiles and tests
- Can cause compilation failures on large packages
- Most targets don’t need it
Instead, scope it to the targets that actually need monkey-patching:
go_test(
name = "my_test_with_patch",
srcs = ["my_test.go"],
deps = [...],
gc_goopts = ["-N", "-l"], # only for this test
)
This keeps the blast radius small. Except when it doesn’t.
The Propagation Problem
Here’s the gotcha that burns people: per-target gc_goopts only applies to that target’s compilation. It doesn’t propagate to dependencies.
Say you have a test in package foo_test that monkey-patches a function in package foo. You add -N -l to the go_test target. The test target itself gets compiled without optimizations – but package foo (a dependency) is still compiled with full optimizations. The function you’re trying to patch? Still inlined.
Your monkey-patch silently fails.
The fix is the global CLI flag:
bazel test //my:test \
--@io_bazel_rules_go//go/config:gc_goopts="-N,-l"
This applies -N -l to every Go compilation action in the build, including the libraries your test depends on. The function in package foo is now compiled without inlining, and your patch works.
The tradeoff: everything else in that build run also gets the slow, unoptimized treatment. For a single bazel test //my:test invocation, that’s usually fine. For bazel test //..., it’s not.
Verifying Flags Are Applied
When something looks wrong – your flag doesn’t seem to be taking effect, or the binary is bigger/slower than expected – don’t guess. Inspect.
Inspect the action command
bazel aquery shows the raw commands Bazel will execute:
bazel aquery 'mnemonic("GoCompilePkg", //my:test)'
This prints the full go tool compile invocation. You can see exactly which flags are present (or missing).
For linker actions:
bazel aquery 'mnemonic("GoLink", //my:app)'
Reproduce outside Bazel
Copy the command from aquery output and run it manually. This isolates whether the problem is Bazel-specific or a Go toolchain behavior:
# Paste the compile/link command from aquery, run it directly
/path/to/go tool compile -N -l -o output.a input.go
If it fails the same way outside Bazel, the issue is with the flags themselves, not with how Bazel passes them.
Minimal reproduction
When in doubt, create a tiny go_test with the flags in question. If the problem reproduces in a 10-line test, you’ve isolated it. If it doesn’t, the issue is in how those flags interact with your specific codebase.
More Gotchas
A few things that bit me or will bite you:
-
Flag syntax matters.
-extldflags=-static(with equals) is correct.-extldflags,-static(with comma) is not equivalent and will produce confusing errors. -
Consistent ordering.
-s -wand-w -sboth work, but pick one and stick with it. Inconsistent ordering causes unnecessary cache invalidation – Bazel sees different flag strings as different actions. -
Tests vs. binaries. Flags can behave differently in
go_testvs.go_binary. Bazel wraps test binaries with additional link steps, so a flag that works forgo_binarymight not have the same effect ingo_test.
Quick Reference
| What you want | Where to set it |
|---|---|
| Strip debug info | Global: gc_linkopts="-s -w" |
| Static binary | Per-target: static = "on", pure = "on" |
| PIE binary | Per-target: linkmode = "pie" |
| Disable inlining (one test) | Per-target: gc_goopts = ["-N", "-l"] |
| Disable inlining (with deps) | CLI: --gc_goopts="-N,-l" for that test run |
| External linker flags | Per-target: gc_linkopts (only if external linking) |
| Global link policy | Toolchain layer: --linkopt (use sparingly) |
The pattern: start at Layer 2 (rules_go attributes). Drop to Layer 1 flags (gc_linkopts, gc_goopts) when attributes don’t cover your case. Resort to Layer 3 (toolchain/global) only when you need build-wide policy – and accept the blast radius that comes with it.
That’s the mental model. Three layers, three blast radii, and a propagation behavior that will silently break your mocks if you don’t know about it. Now you know about it.