Go Compiler and Linker Flags in Bazel: The Three-Layer Model

2026/02/22

BUILDbazelgo

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:

  1. Plain Go – the cmd/go tool, what you’d use without Bazel
  2. rules_go – Bazel’s Go ruleset, which wraps the Go toolchain
  3. 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:

  1. 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.
  2. Priority conflicts. Toolchain-level flags can silently override the fine-grained configuration you set in rules_go.
  3. Debugging is painful. When something breaks, you have to dig through Bazel’s internal action commands to figure out which layer contributed which flag.
  4. 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:

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:

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.