Go, often searched as Golang, is a small language, which makes it easy to underestimate. Developers with years of Go behind them still hit the same handful of gotchas. Most of these bugs compile fine. The program just gives the wrong answer, leaks resources, or deadlocks in production.
Below are the 10 most common ones. Each one has a runnable Golang example and the idiomatic fix.
Table of Contents
- 1. Treating Strings as Characters Instead of Bytes
- 2. Forgetting to Reassign append
- 3. Zero Values Hiding Missing Map Keys
- 4. Using %v Instead of %w When Wrapping Errors
- 5. Value Receivers That Silently Discard Mutations
- 6. Shadowed Variables That Return nil Errors
- 7. Unexported Struct Fields Disappearing in JSON
- 8. Using defer Inside Loops
- 9. Ignoring Errors with the Blank Identifier
- 10. Deadlocks from Unbuffered Channels
1. Treating Strings as Characters Instead of Bytes
A Go string is a read-only slice of bytes, not characters. len(s) returns byte count, and indexing with s[i] returns a byte. That's fine for ASCII text. It breaks as soon as a character takes more than one byte.
example.gogopackage main import ( "fmt" "unicode/utf8" ) func main() { word := "kött" fmt.Println("len:", len(word)) fmt.Println("runes:", utf8.RuneCountInString(word)) }
The Swedish word for meat has four characters but five bytes. Slicing by byte index can cut a rune in half and produce invalid UTF-8.
Fix: Use utf8.RuneCountInString to count characters. When iterating, for i, r := range s gives you runes. When slicing, convert to []rune first.
2. Forgetting to Reassign append
append does not update the slice you pass in. When capacity runs out it allocates a new backing array and returns a slice pointing at that. If you ignore the return value, the appended elements are lost.
example.gogopackage main import "fmt" func main() { nums := []int{1, 2, 3} appendWrong(nums) fmt.Println("wrong:", nums) nums = appendRight(nums) fmt.Println("right:", nums) } func appendWrong(s []int) { _ = append(s, 4) } func appendRight(s []int) []int { return append(s, 4) }
A bare append(s, 4) call is actually a compile error in Go, because the return value must be used. The _ = in appendWrong is only there to get past the compiler. It behaves the same as calling append and never assigning the result back.
Fix: Always reassign with s = append(s, x). If a helper grows a slice, have it return the new slice and reassign at the call site.
3. Zero Values Hiding Missing Map Keys
Reading a missing key from a map doesn't return an error. It returns the zero value for the map's value type. For a map[string]int that's 0, which you can't tell apart from a key that was set to zero on purpose.
example.gogopackage main import "fmt" func main() { scores := map[string]int{"alice": 0} if v := scores["alice"]; v == 0 { fmt.Println("alice looks the same as missing users") } if v, ok := scores["bob"]; ok { fmt.Println("bob:", v) } else { fmt.Println("bob is missing") } }
Fix: Use the comma-ok idiom, v, ok := m[key]. The boolean tells you whether the key existed.
4. Using %v Instead of %w When Wrapping Errors
fmt.Errorf with %v formats the original error into a new string and throws the error value away. With %w, the new error wraps the original, so errors.Is and errors.As can still find it.
example.gogopackage main import ( "errors" "fmt" "os" ) func main() { _, err := os.Open("does-not-exist.txt") wrapped := fmt.Errorf("reading config: %w", err) fmt.Println(wrapped) fmt.Println("is not-exist:", errors.Is(wrapped, os.ErrNotExist)) }
Fix: Wrap with %w whenever callers might need to check the underlying error. Use %v only when the original error is an implementation detail you don't want callers depending on.
5. Value Receivers That Silently Discard Mutations
A method with a value receiver gets a copy of the struct. Whatever it changes on that copy is gone when the method returns.
example.gogopackage main import "fmt" type Counter struct { n int } func (c Counter) IncValue() { c.n++ } func (c *Counter) IncPointer() { c.n++ } func main() { c := Counter{} c.IncValue() fmt.Println("after value receiver:", c.n) c.IncPointer() fmt.Println("after pointer receiver:", c.n) }
Fix: Use a pointer receiver when the method changes the receiver or the struct is big enough that copying it is wasteful. If some methods on a type need a pointer receiver, it's usually cleaner to give them all one.
6. Shadowed Variables That Return nil Errors
A short variable declaration (:=) inside an if creates a new variable that only lives in that block. If it shadows an outer err by accident, the outer one stays nil and the function reports success after something failed.
example.gogofunc loadUser(id string) (*User, error) { var err error user, err := fetch(id) if err != nil { return nil, err } if err := validate(user); err != nil { log.Println(err) // the outer err is still nil here } return user, err }
Fix: Assign to the outer variable with = instead of :=, or return right away inside the if block. Turn on go vet -shadow or the govet linter to catch these before review.
7. Unexported Struct Fields Disappearing in JSON
encoding/json only sees exported fields. A lowercase field is invisible to the marshaller, so it's left out of the output without any error.
example.gogopackage main import ( "encoding/json" "fmt" ) type User struct { Name string `json:"name"` email string Age int `json:"age,omitempty"` } func main() { u := User{Name: "Ada", email: "[email protected]", Age: 36} data, _ := json.Marshal(u) fmt.Println(string(data)) }
The output has no email key, and nothing warns you about it.
Fix: Capitalize the fields you want serialized and use struct tags to set the JSON key name. Add omitempty to optional fields.
8. Using defer Inside Loops
defer runs when the surrounding function returns, not at the end of each loop iteration. If a loop opens a thousand files and defers file.Close() for each, all thousand file descriptors stay open until the function exits.
example.gogofunc processAll(paths []string) error { for _, p := range paths { f, err := os.Open(p) if err != nil { return err } defer f.Close() // all closes pile up until processAll returns // ... read file ... } return nil }
Fix: Move the per-iteration work into a helper function, so defer runs at the end of each call.
example.gogofunc processAll(paths []string) error { for _, p := range paths { if err := processOne(p); err != nil { return err } } return nil } func processOne(path string) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() // ... read file ... return nil }
9. Ignoring Errors with the Blank Identifier
result, _ := doThing() throws away any failure the function reports. The program carries on with a zero value or half-finished state, and when it finally crashes, it's somewhere far from the real cause.
example.gogodata, _ := os.ReadFile("config.json") _ = json.Unmarshal(data, &cfg)
If the file is missing, data is nil, Unmarshal returns an error nobody reads, and cfg stays zero. You find out later, when the app acts as if there's no config at all.
Fix: Handle the error or return it to the caller. Only use _ when the error really can't matter, and leave a short comment saying why.
10. Deadlocks from Unbuffered Channels
On an unbuffered channel, a send only completes when a receiver takes the value at the same moment. If nothing ever receives, the send blocks forever.
example.gogopackage main import "fmt" func main() { ch := make(chan int) // unbuffered ch <- 42 // deadlock: no goroutine is receiving fmt.Println(<-ch) }
The send waits for a receiver, but the receive is on the next line and never gets to run. The runtime sees that every goroutine is stuck and exits with fatal error: all goroutines are asleep - deadlock!.
Fix: Make sure a goroutine is ready to receive before you send, or use a buffered channel when the producer and consumer run at different speeds. For a single result, a channel with a buffer of 1 is a safe default.
example.gogopackage main import "fmt" func main() { ch := make(chan int, 1) // buffered ch <- 42 fmt.Println(<-ch) }
Keep Going
Knowing these ten Golang traps will save you a lot of debugging. The rest you pick up by building real projects and hitting edge cases with a debugger open. If you want structured practice, the LevelUpGo Go track covers these patterns with runnable exercises and automated feedback on your code.
