Go Blank Identifier: Ignoring Values
Go's blank identifier `_` is a write-only variable that explicitly discards values. Unlike other languages that allow unused variables, Go's compiler enforces that every declared variable must be...
Key Insights
- The blank identifier
_is Go’s explicit way to discard values you don’t need, preventing unused variable compilation errors while documenting intent - Most commonly used to ignore unwanted return values in multi-value functions, but blindly ignoring errors is a dangerous anti-pattern that leads to silent failures
- Beyond basic value discarding,
_enables powerful compile-time patterns like interface compliance checks and side-effect-only imports
Understanding the Blank Identifier
Go’s blank identifier _ is a write-only variable that explicitly discards values. Unlike other languages that allow unused variables, Go’s compiler enforces that every declared variable must be used. The blank identifier provides an escape hatch that’s both explicit and self-documenting.
Here’s the fundamental problem it solves:
package main
func main() {
x, y := getValue()
println(x)
// Compilation error: y declared and not used
}
func getValue() (int, int) {
return 42, 100
}
This code won’t compile. Go refuses to build programs with unused variables, treating them as potential bugs. The blank identifier fixes this:
package main
func main() {
x, _ := getValue()
println(x)
// Compiles successfully
}
func getValue() (int, int) {
return 42, 100
}
The underscore tells both the compiler and future readers: “I know there’s a second return value, and I’m intentionally ignoring it.”
Ignoring Function Return Values
The most frequent use of _ is discarding unwanted return values from functions. Go functions commonly return multiple values, particularly error values as the last return.
package main
import (
"fmt"
"strconv"
)
func main() {
// Keep the value, ignore the error
num, _ := strconv.Atoi("42")
fmt.Println(num)
// Keep the error, ignore the value
_, err := strconv.Atoi("not-a-number")
if err != nil {
fmt.Println("Conversion failed:", err)
}
// Ignore everything (rare and usually wrong)
_ = fmt.Sprintf("formatted %s", "string")
}
However, ignoring errors is dangerous and should be done sparingly. Here’s when it’s acceptable versus when it’s not:
// ACCEPTABLE: Error is genuinely impossible or irrelevant
func writeToBuffer() {
var buf bytes.Buffer
_, _ = buf.WriteString("hello") // WriteString on bytes.Buffer never errors
// Better: just ignore the count
_ = buf.WriteString("world")
}
// DANGEROUS: Ignoring errors that can actually occur
func readConfig() Config {
data, _ := os.ReadFile("config.json") // BAD: File might not exist!
var cfg Config
_ = json.Unmarshal(data, &cfg) // BAD: Data might be invalid!
return cfg
}
// CORRECT: Handle errors appropriately
func readConfigCorrectly() (Config, error) {
data, err := os.ReadFile("config.json")
if err != nil {
return Config{}, fmt.Errorf("reading config: %w", err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return Config{}, fmt.Errorf("parsing config: %w", err)
}
return cfg, nil
}
Some functions return values you genuinely don’t need. The fmt.Fprintf family returns the number of bytes written and an error. Often you only care about the error:
func logMessage(w io.Writer, msg string) error {
_, err := fmt.Fprintf(w, "[LOG] %s\n", msg)
return err
}
Blank Identifier in Loops
When iterating with range, you get both an index and a value. Often you need only one:
package main
func main() {
colors := []string{"red", "green", "blue"}
// Need only values, ignore index
for _, color := range colors {
println(color)
}
// Need only index, ignore value
for i, _ := range colors {
println(i)
}
// Shorthand: omit the blank identifier for index-only
for i := range colors {
println(i)
}
}
The third form is idiomatic Go. When you only need the index, omit the value entirely rather than using for i, _ := range. The blank identifier is only necessary when you want the second element but not the first.
For maps, the same pattern applies:
package main
func main() {
users := map[string]int{
"alice": 30,
"bob": 25,
}
// Only keys
for name := range users {
println(name)
}
// Only values
for _, age := range users {
println(age)
}
// Both
for name, age := range users {
println(name, age)
}
}
Import Side Effects
Some packages need to be imported solely for their init() functions, which execute automatically at program startup. Database drivers are the classic example:
package main
import (
"database/sql"
_ "github.com/lib/pq" // PostgreSQL driver
)
func main() {
db, err := sql.Open("postgres", "connection-string")
if err != nil {
panic(err)
}
defer db.Close()
// Use db...
}
The pq package registers itself with database/sql in its init() function. You never call pq functions directly, so a normal import would cause an “imported and not used” error. The blank identifier import runs the initialization without requiring you to reference the package.
Other common uses include:
import (
_ "net/http/pprof" // Registers pprof handlers
_ "time/tzdata" // Embeds timezone database
)
This pattern makes side effects explicit. Anyone reading the code knows these imports exist purely for initialization.
Interface Compliance Checks
Go’s blank identifier enables compile-time verification that a type implements an interface:
package main
import "io"
type MyReader struct {
data []byte
pos int
}
func (r *MyReader) Read(p []byte) (n int, err error) {
if r.pos >= len(r.data) {
return 0, io.EOF
}
n = copy(p, r.data[r.pos:])
r.pos += n
return n, nil
}
// Compile-time check that *MyReader implements io.Reader
var _ io.Reader = (*MyReader)(nil)
If MyReader doesn’t properly implement io.Reader, compilation fails immediately. This is especially valuable in large codebases where interface requirements might change:
package cache
import "context"
// Cache defines our caching interface
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte) error
Delete(ctx context.Context, key string) error
}
// RedisCache implementation
type RedisCache struct {
// fields...
}
func (c *RedisCache) Get(ctx context.Context, key string) ([]byte, error) {
// implementation
return nil, nil
}
func (c *RedisCache) Set(ctx context.Context, key string, value []byte) error {
// implementation
return nil
}
func (c *RedisCache) Delete(ctx context.Context, key string) error {
// implementation
return nil
}
// Ensure RedisCache implements Cache
var _ Cache = (*RedisCache)(nil)
If someone adds a new method to the Cache interface, any type with a compliance check will fail to compile until updated. This catches interface mismatches immediately rather than at runtime.
Common Pitfalls and Best Practices
The blank identifier is powerful but easily misused. Here are guidelines for using it correctly:
Never ignore errors that matter:
// WRONG: Silent failure
func processFile(filename string) {
data, _ := os.ReadFile(filename)
process(data) // data is nil if read failed!
}
// CORRECT: Handle the error
func processFile(filename string) error {
data, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("reading %s: %w", filename, err)
}
return process(data)
}
Use it for genuinely impossible errors:
// Acceptable: Sprintf never returns an error
func formatMessage(name string) string {
msg := fmt.Sprintf("Hello, %s!", name)
return msg
}
// Also acceptable: Writing to bytes.Buffer never fails
func buildString() string {
var buf bytes.Buffer
_ = buf.WriteByte('x')
return buf.String()
}
Document why you’re ignoring values:
// Good: Comment explains the decision
func cleanup() {
// Error only occurs if file doesn't exist, which is fine
_ = os.Remove(tempFile)
}
// Better: Use a named function that documents intent
func removeIfExists(filename string) {
_ = os.Remove(filename)
}
Prefer explicit over implicit:
// Less clear
for i := range items {
process(items[i])
}
// More explicit about intent
for _, item := range items {
process(item)
}
The blank identifier is a tool for explicit communication. Use it to tell readers “I considered this value and chose to ignore it,” not as a shortcut to avoid proper error handling. When you find yourself using _ to ignore an error, pause and ask whether that error genuinely doesn’t matter. Most of the time, it does.