Skip to content
gohacks.dev
Basics

The Short Variable Declaration and the Shadowing Trap

The := operator can quietly create a new variable that hides an outer one. See how shadowed err values get lost, what go vet misses, and how to catch it.

6 min read
Summarize in AI
Flat illustration of an editor window full of code.

Shadowing is the bug that compiles, passes go vet, and returns the wrong answer. It happens in the most ordinary code you will write, usually around an if and an err. A function appears to check an error and then returns nil anyway, and the cause is a single colon. If you are early in learning the language, the rest of the basics path covers the pieces this builds on, and this article shows the failure, the tool that finds it, and the habit that avoids it.

Tested with Go 1.25.6, golang.org/x/tools v0.38.0 (shadow analyzer), macOS on Apple Silicon (arm64)

What := really does

The short declaration a, b := f() declares variables and assigns them in one step. The rule that matters is scope. A declaration creates the name in the current block. An if body, a for body and a switch case are all blocks. So if a name with the same spelling exists in an outer block, the inner declaration makes a separate variable and the outer one is hidden for the rest of that block.

Here is the shape that bites. The outer function declares err and data. Then, inside an if, it calls a function and assigns the results with :=:

main.goGo
package main

import (
	"errors"
	"fmt"
)

func load() (string, error) { return "", errors.New("disk full") }

func run() error {
	var err error
	data := "default"

	if true {
		data, err := load()
		fmt.Println("inner:", data, err)
	}

	fmt.Println("outer:", data, err)
	return err
}

func main() {
	fmt.Println("run returned:", run())
}
Text
inner:  disk full
outer: default <nil>
run returned: <nil>

The inner line prints the error, so the failure was detected. The outer line prints default and <nil>, so run reports success. The inner data and err were brand new variables that lived only inside the if, and the real err that the function returns was never touched. In a real program the if is usually a retry loop, a cache miss path or a switch case, and the symptom is a handler that logs a failure and then answers 200.

The compiler is no help here

It is worth being clear about what the toolchain does not do. This program compiles. It also passes go vet without a word:

Shell
go vet .
echo $?
Text
0

Shadowing is legal, and it is sometimes the right call, so the standard vet checks leave it alone. What would have caught this one is Go's own "declared and not used" rule, and here the inner variables are used by the Println, so even that stays quiet. The specification section on scope describes exactly this behavior, so nothing here is a compiler bug.

When the rule does trigger, it is for the opposite mistake. A := must introduce at least one new name on its left side in the current scope:

main.goGo
package main

import "fmt"

func main() {
	x := 1
	x := 2
	fmt.Println(x)
}
Text
# example.test/lab
./main.go:7:4: no new variables on left side of :=

That error, no new variables on left side of :=, is the compiler telling you that you meant =.

Finding shadowed variables with a tool

The check lives in a separate analyzer, shadow, maintained alongside the standard vet passes in golang.org/x/tools. It is not part of go vet by default because it is noisy. Legitimate shadowing is common, such as err inside nested blocks that finish with their own return. Install it and point vet at it:

Shell
go install golang.org/x/tools/go/analysis/passes/shadow/cmd/[email protected]
go vet -vettool=$(which shadow) ./...

On the program above it printed:

Text
# example.test/shadow
# [example.test/shadow]
./main.go:15:3: declaration of "data" shadows declaration at line 12
./main.go:15:9: declaration of "err" shadows declaration at line 11

The line numbers point at the inner declaration and the outer one it hides, which is exactly what you need. If you already use golangci-lint, its govet linter can switch the same analyzer on. Expect some hits that are deliberate, and triage them by looking for err first.

Three ways to write it so it cannot happen

Declare once, assign with =. The direct fix for the example is to keep data and err in the outer scope and use plain assignment inside the block:

main.goGo
package main

import (
	"errors"
	"fmt"
)

func load() (string, error) { return "", errors.New("disk full") }

func run() error {
	var err error
	data := "default"

	if true {
		data, err = load()
		fmt.Println("inner:", data, err)
	}

	fmt.Println("outer:", data, err)
	return err
}

func main() {
	fmt.Println("run returned:", run())
}
Text
inner:  disk full
outer:  disk full
run returned: disk full

Now the outer err is set, and the function reports the failure. Note that data is also overwritten with the empty string that load returned. That is correct behavior, but it shows the second reason to care: with =, the assignment changes real state, so think about what a failed call leaves behind.

Return early and avoid the block. Most shadowing of err disappears when the code uses guard clauses. If the failing call returns straight away, there is no outer err to shadow and nothing to forget:

Go
func run() (string, error) {
	data, err := load()
	if err != nil {
		return "", fmt.Errorf("load: %w", err)
	}
	return data, nil
}

Scope the variable to the if on purpose. The pattern if err := step(); err != nil { ... } is shadowing that you chose. The new err exists only for the if and its else branches, and nothing outside depends on it. This is fine and idiomatic, as long as the outer function does not also rely on a variable called err that it expects this call to set.

Other places the trap hides

The same mistake appears outside err. A for loop that computes a total in an inner scope and reports it from an outer one, a package-level var cache that a function replaces with cache := make(...), and a deferred closure that reads a variable the surrounding function has since shadowed. The last is the worst, because it is cheap to write by accident when you refactor.

Package-level shadowing deserves a mention of its own. If your package has a global logger and a function does logger := newLogger(), every call below it uses the new one and every other function still uses the old one. When two behaviors differ only after a refactor, search for := against a package variable first.

Imports can be shadowed as well. A local variable named url hides the net/url package for the rest of its block, and the compile error you get points at a method that does not exist on a string, which looks unrelated. Package names shadowed by variables covers that one, with the quick fix.

Where shadowing is fine

Not every shadowed name is a bug, and a rule that bans all of it would be worse than the problem. Naming an inner err in a short if err := ...; err != nil is idiomatic. Shadowing a parameter to copy it before a closure captures it was common before the change in the loop variable semantics of Go 1.22, and you will still see x := x in older code. And in a short function where one block repeats the same pattern twice, the clarity of identical lines beats an artificial unique name.

What I avoid is shadowing across a long stretch of code, or shadowing something the function returns. A named result such as (result string, err error) that gets shadowed by an inner err is the most expensive version of the bug, because the return statement hides it. If you use named results at all, the interaction with defer is worth reading before you do. If the analyzer's output looks like too much to act on, start with the lines that mention err, because those are the ones that change behavior.

Also inTooling
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.