Skip to content
gohacks.dev
Basics

The Blank Identifier in Go and Its Many Different Uses

The underscore discards values you must receive but do not need. See the seven places it appears, from ignored results and imports to interface checks.

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

Go's compiler refuses variables that are declared and not used, which is a strict rule that creates a problem: some functions return values you do not want, and some constructs hand you an index you have no use for. The blank identifier, a single underscore, is how the language lets you say "I know, discard it". It has more uses than people expect, and some of them are idioms that make a code base safer, not merely tidier. This article walks through each use with a program, flags the one that deserves suspicion, and builds on declared and not used and the init function. It is part of the Go basics path.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

What it is

_ is not a variable. You can write it on the left of an assignment, in a range clause, in a declaration and in a parameter list, and whatever is assigned to it is evaluated and thrown away. You cannot read from it, so fmt.Println(_) is a compile error, and you cannot declare it with := alone, since there would be no new variable. That write-only nature is the whole definition.

The seven uses

main.goGo
package main

import (
	"fmt"
	"io"
	"strconv"
	"strings"
)

type counter struct{ n int }

func (c *counter) Write(p []byte) (int, error) {
	c.n += len(p)
	return len(p), nil
}

var _ io.Writer = (*counter)(nil)

type padded struct {
	flag bool
	_    [7]byte
	size int64
}

func lookup(m map[string]int, k string) bool {
	_, ok := m[k]
	return ok
}

func handler(_ string, count int) int {
	return count * 2
}

func main() {
	n, _ := strconv.Atoi("42")
	fmt.Println(n)

	for _, word := range strings.Fields("go is fun") {
		fmt.Print(len(word), " ")
	}
	fmt.Println()

	for i := range 3 {
		_ = i
	}

	fmt.Println(lookup(map[string]int{"a": 1}, "a"), handler("ignored", 21))

	var c counter
	fmt.Fprintf(&c, "hello %s", "blank")
	fmt.Println(c.n)

	fmt.Println(padded{flag: true, size: 8}.size)
}
Text
42
2 2 3 
true 42
11
8

Read the program in order, because each section is a different use.

Ignoring a result. n, _ := strconv.Atoi("42") receives the number and discards the error. The compiler is satisfied, and that is exactly the risk: if the input were "abc", n would be 0 and nothing would say so. I treat _ on an error as something that needs a comment explaining why the failure is impossible or harmless. A parse of a constant is a fair example. A parse of user input is not.

Skipping the index or value in a loop. for _, word := range ... drops the index. The other direction, for i := range s, needs no blank at all, because you can leave out the second variable. The compiler would complain about an unused i, which is where _ = i comes in as a placeholder, but a loop that does not use its variable can drop it entirely with for range 3.

Testing existence. _, ok := m[k] asks whether a key exists and ignores the value, which is the standard map idiom from checking whether a key exists.

Ignoring a parameter. func handler(_ string, count int) says this function takes a string and deliberately does not use it. That is common when a function must match a signature, such as a callback or an interface method, and it is clearer than a named parameter nobody reads.

Checking an interface at compile time. The line var _ io.Writer = (*counter)(nil) assigns a typed nil pointer to a blank variable of the interface type. It does no work at run time, and it makes the compiler verify that *counter has a Write method with the right signature. The failure shows up at the declaration, not at some distant call:

main.goGo
package main

import "io"

type counter struct{ n int }

func (c *counter) Write(p []byte) int { return len(p) }

var _ io.Writer = (*counter)(nil)

func main() {}
Text
# example.test/lab
./main.go:9:19: cannot use (*counter)(nil) (value of type *counter) as io.Writer value in variable declaration: *counter does not implement io.Writer (wrong type for method Write)
		have Write([]byte) int
		want Write([]byte) (int, error)

The error says *counter does not implement io.Writer and shows the wrong method signature, have Write([]byte) int against want Write([]byte) (int, error). Without the blank assertion you would learn this the first time something tried to use the type as a writer. I add the line for any exported type that is meant to satisfy an interface, and it is how the standard library guards its own types.

Struct padding and markers. A _ field in a struct, as in _ [7]byte, is a field that exists in memory and cannot be named. It pads a layout to a particular offset, as when matching a C struct or a network header. A related use is the zero-size marker _ [0]func(), which makes a struct non-comparable because function arrays cannot be compared, a trick for types that should not be used as map keys.

Importing for side effects. import _ "pkg" loads a package so that its init function runs, with no names imported. That is the registration pattern that makes database/sql drivers and image decoders available, and the one use where removing an apparently unused import breaks the program.

Where it goes wrong

Three habits are worth avoiding. Do not use _ to discard errors on calls whose failure matters, such as _ = f.Close() after writing to a file, which the file how-to explains. If you do ignore one, say why in a comment, as // best effort: the temp file is removed anyway. Do not leave _ = x lines in finished code to silence the unused variable rule, since they are placeholders for code you meant to write. And do not assign function results to _ when you do not need to call the function at all. _ = expensive() calls it, while deleting the line does not.

Linters add a layer here. errcheck reports unchecked errors, and by default it treats an explicit _ = as a deliberate choice and stays quiet, which is a reasonable trade: the underscore is your signature on the decision. For a team convention, requiring a comment next to every ignored error is the cheap rule that catches most trouble.

Where it does not help

The blank identifier cannot hide an unused import that you do not want, such as one used only in a comment, and it cannot discard a value from a statement that is not an assignment. A function call whose result you want to ignore needs no blank: you can write fmt.Println("hi") and ignore both results silently, and it is only in assignments that you must name the thing you throw away. Builtins such as append are the exception, where an unused result is a compile error: append(s, 1) as a statement fails with append(s, 1) (value of type []int) is not used. Comparison is one more place a _ field has an effect that is easy to miss. Blank fields are skipped when two structs are compared with ==, since they can never hold anything meaningful, but their type still counts for comparability. The marker _ [0]func() makes the struct reject == with struct containing [0]func() cannot be compared, which I confirmed by compiling it. It is cheap insurance for a type, such as a handle, that must never be a map key or compared by value. The language rule is in the specification section on the blank identifier, and Effective Go on the blank identifier covers the interface check and import uses with the same examples the standard library uses.

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.