Go Build Works But VS Code Shows Errors: Why Go Tooling Shows Inconsistent State

If go build works but VS Code shows errors — or your module graph runs fine locally but fails in CI, or Delve skips breakpoints on code you’re looking at — none of this is a setup problem.

These are four separate tools: gopls, go build, go mod, and Delve. Each maintains its own state snapshot. None of them synchronize automatically. When those snapshots diverge, you get gopls wrong diagnostics, go modules inconsistent versions, delve breakpoint not working, and build cache behavior that makes no sense.

This page explains the internal mechanism behind each failure — why the states diverge, which tool’s snapshot to trust, and the
correct diagnostic sequence for each case.


TL;DR

  • gopls maintains its own asynchronous workspace index — it does not call go build, so its diagnostics can and do diverge from compiler output
  • Module graph in CI differs from local when replace directives, GOFLAGS, or GOPROXY values differ between environments
  • Delve skips lines and hides variables because the compiler inlines functions and register-allocates variables before writing DWARF debug info
  • go clean -cache and go clean -modcache target different caches and fix different problems — running the wrong one changes nothing
  • All four failure modes share one root cause: gopls, go build, go mod, and Delve resolve state independently, on demand, with no shared synchronization layer

Gopls Not Showing Errors Correctly: How the Language Server Loses Sync

The most searched symptom in Go development: gopls wrong diagnostics, or the inverse — gopls shows code error but build works. Both come from the same place. The language server runs its own type-checking and static analysis pipeline against a workspace index it builds and updates asynchronously. It does not invoke go build. It does not read compiler output. It resolves packages against its own cached view of the module graph.

When you save a file, gopls receives a textDocument/didSave LSP notification and triggers a partial re-analysis. Partial means: if your change touches a package boundary — an exported type, an interface definition, a build tag — the re-analysis doesn’t propagate through the full dependency graph. Stale type information persists in the index until a full workspace reload fires.

Gopls Diagnostics vs go build: Two Independent Sources of Truth

The Go compiler resolves your package graph fresh at every invocation — it reads go.mod, walks the module graph, compiles. gopls resolves against a cached snapshot that lags behind disk state by design. Fast incremental analysis trades accuracy for responsiveness. The cost shows up when you refactor an interface and the language server keeps reporting implementors as non-compliant for 30 seconds.

// You just added Flush() to this interface — 20 seconds ago
type Writer interface {
 Write(p []byte) (n int, err error)
 Flush() error // gopls index hasn't re-resolved implementors
}

// go build: compiles clean — reads current disk state
// gopls: still shows "does not implement Writer" — stale snapshot
// this is not a bug — it is the expected LSP partial-update behavior

The diagnostic rule: if go build ./... and go vet ./... both exit 0, the error exists only in the language server’s snapshot. The binary is correct. The IDE is showing a stale state.

When gopls Workspace Reload Fixes the Problem — and When It Doesn’t

“Go: Restart Language Server” drops the workspace index and rebuilds it from disk. This fixes stale snapshots caused by file edits and refactors. It does not fix errors caused by a corrupted or inconsistent module cache, because gopls reads type information from compiled module artifacts in GOMODCACHE. Reloading the workspace against a broken cache just re-indexes the broken state — same errors, different reload.

If a language server restart doesn’t clear diagnostics that go build doesn’t reproduce, the module cache is the actual problem. The fix sequence: go mod verify first to confirm, then go clean -cache, then go mod tidy, then rebuild, then restart the language server. In that order.

Go Modules Work Locally But Fail in CI: The Dependency Graph Mismatch

Go modules inconsistent versions between local and CI is the canonical “it works on my machine” failure in Go projects. The module graph is not a static artifact — it’s computed per environment, per the set of active replace and exclude directives, per go env output at resolution time. Your machine and the CI runner almost certainly have different values for GOPROXY, GONOSUMDB, GOPRIVATE, and GOFLAGS. Any one of these produces a different resolved graph from the same go.mod file.

Deep Dive
Go Runtime Pitfalls

Hidden Performance Traps in Go That Mid-level Devs Keep Hitting Most Go codebases that end up slow weren't written by juniors who didn't know what they were doing — they were written by competent developers...

Replace Directives: Why CI Resolves a Different Dependency

replace directives pointing to local filesystem paths are the most common divergence source. They work locally because the path exists. In CI, the checkout contains only the main repository — the local path doesn’t exist, the directive silently falls through, and Go resolves the original module version from the proxy.

// go.mod — valid locally, silent failure in CI
replace (
 github.com/yourorg/internal-lib => ../internal-lib
)

// CI: ../internal-lib path does not exist after checkout
// go build resolves public registry version instead
// no build error — different behavior in production
// this is why CI use different go dependencies than local

The failure is invisible at build time. CI produces a green build against a different dependency tree than the one running in your development environment. The divergence surfaces at runtime, on the code path where the public module version behaves differently from the local one.

Go.sum Mismatch Between Local and CI: What It Actually Means

go.sum stores cryptographic hashes for the complete resolved module graph — not just direct dependencies. When the resolved graph differs between environments, the checksums differ. go build fails with a sum mismatch error that points at the module, not at the environment variable that caused the graph to diverge in the first place.

The correct diagnostic: run go env on both the local machine and the CI runner, then diff the output. The relevant fields are GOFLAGS, GOPROXY, GONOSUMDB, GOPRIVATE, and GOMODCACHE. The mismatch will be in one of those. After aligning them, run go mod tidy in CI explicitly before the build step — this ensures the graph is resolved and the sum file is current before compilation starts.

Go Debugger Skipping Lines and Showing Wrong Variable Values: The Optimization Problem

Delve breakpoint not working and go debug variable not showing correct value are mid-level developer pain — and they have nothing to do with Delve being broken. The Go compiler performs function inlining, escape analysis, and register allocation before writing DWARF debug information. Delve reads that DWARF to map machine instructions back to source lines. When the compiler has restructured the code, the mapping is accurate to the compiled binary — not to the source you’re reading.

Why the Go Debugger Jumps Lines: Inlining and DWARF Mapping

When a small function is inlined into its caller, it no longer has its own stack frame. Its local variables exist in the caller’s frame. Delve maps the breakpoint to where the inlined code actually runs in the binary — which may be several source lines away from where you set the breakpoint. The debugger reports correct position for the compiled artifact. The mismatch is between that artifact and the source layout you’re looking at.

// source: breakpoint set on line 2
func validate(input string) bool {
 trimmed := strings.TrimSpace(input) // ← breakpoint here
 return len(trimmed) > 0
}

// compiler inlines validate() into caller at call site
// Delve: breakpoint fires in caller's frame, different line
// "trimmed" not visible — no dedicated frame, register-allocated
// go debug missing variables optimized — this is the mechanism

Variables that disappear mid-watch follow the same logic: the compiler determined the variable is only needed for one CPU instruction, assigned it to a register, and never wrote it to an addressable memory location. DWARF has no reference for it. Delve can’t surface what has no address.

Gcflags -N -l: What Disabling Optimizations Actually Does

-gcflags="all=-N -l" tells the compiler to skip optimizations (-N) and disable inlining (-l) across all packages. Every function gets its own stack frame. Every variable gets a memory address. DWARF maps cleanly to source lines. Delve becomes accurate.

# compile with full debug info — no inlining, no optimization
go build -gcflags="all=-N -l" -o ./bin/app ./cmd/app

# VS Code launch.json equivalent
{
 "buildFlags": "-gcflags='all=-N -l'",
 "mode": "debug"
}

# without these flags: debugger skips lines, variables vanish
# with them: full DWARF coverage, accurate source-line mapping

Binaries compiled with -N -l run 10–30% slower and are larger. This flag set belongs exclusively in debug launch configurations. If a bug disappears when you add these flags, the issue is optimization-dependent — a race condition or timing sensitivity exposed by compiler reordering. That’s a separate diagnostic requiring go build -race, not a debugger session.

Technical Reference
Goroutine Leak Patterns

Goroutine Leak Patterns That Kill Your Service Without Warning A goroutine leak is a goroutine that was spawned and never terminated — it holds stack memory, blocks on a channel or syscall, and the Go...

Go Build Cache Inconsistent Results: Two Caches, Two Different Problems

gopls cache not updating, go mod cache corrupted, golang build cache weird behavior — these often get lumped together as “the cache is broken.” They’re not the same cache. Go maintains two separate caching systems with different invalidation logic, different storage locations, and different failure modes. Running the wrong cleanup command leaves the actual problem intact.

Go clean -cache vs go clean -modcache: What Each One Actually Fixes

The build cache at $GOCACHE stores compiled package artifacts keyed by source content hash. It goes stale when: a code generation step produces output that the hash doesn’t reflect, a C dependency changes without touching Go source, or a partial compilation wrote a corrupted entry. go clean -cache deletes this. The next build recompiles everything from source.

The module cache at $GOMODCACHE stores downloaded module archives and extracted source trees. It goes inconsistent when: a download was interrupted, a private module was updated at the same version tag, or go mod verify reports a hash mismatch against go.sum. go clean -modcache deletes this. The next module resolution re-downloads from the proxy.

# step 1: determine which cache is actually broken
go mod verify # checks GOMODCACHE integrity against go.sum
go build -v ./... # -v shows cache-hit vs recompile per package

# step 2: fix the correct cache
go clean -cache # stale build artifacts, bad compilation entry
go clean -modcache # corrupted module download, version mismatch

# step 3: never do this without diagnosis first
go clean -cache -modcache # nuclear — slow rebuild, no information gained

Running both without diagnosis forces a full recompile and re-download on the next build — slow, and you still don’t know which cache caused the problem. Isolate before clearing.

Go clean modcache vs buildcache: The Language Server Connection

gopls reads type information from compiled module artifacts inside GOMODCACHE. If those artifacts are corrupted or stale, restarting the language server re-indexes the broken state. The correct fix: verify and clean the module cache first, run go build ./... to confirm the tool chain is clean, then restart the language server. A restart without a preceding build verification is guesswork.

The Root Cause: Four Tools, Four State Machines, No Synchronization

Every failure described above is a symptom of the same architectural reality: gopls, go build, go mod, and Delve each resolve their own view of your project on demand. There is no shared state manager. No notification when one tool updates its view. No mechanism that keeps them aligned automatically.

Gopls, go build, Delve, and go mod: Independent State Snapshots

gopls maintains an incremental workspace index updated via LSP events. go build resolves the module graph fresh from go.mod at every invocation. Delve reads DWARF from a binary compiled at a specific point in time with specific flags. go mod operates on the module graph and writes to GOMODCACHE, but sends no notification to the language server when it does.

When you update a dependency, change a replace directive, or rebuild with different compiler flags, each tool’s state drifts independently. Golang language server inconsistent behavior, CI dependency failures, and debugger inaccuracies are all the same problem expressed through different tool interfaces.

This is a deliberate design tradeoff, not a defect pending a fix. Each tool optimizes for its own access pattern and performance requirements. The cost falls on the developer: you need to know which tool’s state is authoritative for a given question. go build is authoritative on compilation. go mod verify is authoritative on module cache integrity. The IDE is never authoritative — it is a best-effort, lagging view. The debugger is only accurate when the binary was compiled with debug flags active.

Understanding which state machine to interrogate for a specific failure cuts diagnosis time from “restart everything and hope” to a targeted two-command sequence.

Worth Reading
Golang channel deadlock

When Golang Channels Kill Your App: Deadlocks, Blocking, and Fixes When Goland code hangs, it doesnt always crash or throw something you can grep. Sometimes a Go service just stops responding — no logs, no...

FAQ

Why does gopls show errors but go build works?
gopls maintains its own asynchronous workspace index that lags behind disk state. When a change crosses a package boundary, partial re-analysis doesn’t propagate through the full graph. The language server’s snapshot is stale; the compiler reads current disk state. If go build and go vet both exit clean, the error is in the language server — not in your code.
Why does go build change depending on environment?
The module graph is computed per environment based on go env values — specifically GOPROXY, GOFLAGS, GONOSUMDB, and GOPRIVATE. Different values produce different resolved dependency trees from the same go.mod. Diff go env output between machines to find the divergence.
Why does the Go debugger skip lines?
The compiler inlines small functions into their callers. Delve sets breakpoints against DWARF debug info, which maps to the inlined call site in the caller’s frame — not to the original function’s source line. Compiling with -gcflags="all=-N -l" disables inlining and produces accurate source-line mapping.
What is the difference between go clean -cache and go clean -modcache?
go clean -cache deletes compiled package artifacts at $GOCACHE — fixes stale build output. go clean -modcache deletes downloaded module source at $GOMODCACHE — fixes corrupted or outdated module downloads. Run go mod verify and go build -v first to determine which cache is actually broken.
Why do variables disappear in Delve mid-debug session?
The compiler determined the variable is only needed for one CPU instruction and assigned it to a register with no addressable memory location. DWARF has no reference for it. Build with -gcflags="all=-N -l" to force all variables into addressable memory and make them visible to the debugger.
Does restarting gopls always fix wrong diagnostics?
Only when the problem is a stale workspace index caused by file edits. If the module cache is corrupted, restarting re-indexes the broken state and produces the same errors. Fix the underlying cache first with go mod verify and go clean -cache, then restart the language server.
Why does go.sum mismatch between local and CI?
go.sum stores hashes for the complete resolved module graph. If the graph resolves differently between environments — because of different GOPROXY, GONOSUMDB, or replace directive behavior — the checksums differ. Align go env output across environments and run go mod tidy explicitly in CI before the build step.

Conclusion

The pattern across all four failure modes is identical: a tool’s internal state diverged from what another tool considers current. gopls showing errors while go build succeeds is an asynchronous index lagging behind disk. CI dependency failures are a resolved module graph differing by environment variable. Delve jumping lines is accurate DWARF mapping for optimized code. Build cache inconsistency is two separate caches with independent invalidation logic being treated as one.

The diagnostic approach that works consistently: identify which tool’s state you’re questioning, then use that tool’s own verification command before taking action. go build ./... for compiler state. go mod verify for module cache integrity. go build -gcflags="all=-N -l" for a debuggable binary. Restart the language server only after the tool chain underneath it is confirmed clean.

Restarting everything without this model is why the same failure reappears. The Go tool chain is not broken — it’s distributed. Distributed systems require explicit state reconciliation, and the developer’s job is knowing which state to reconcile and in what sequence.

Written by:

Source Category: Goland Internals