Go, often searched as Golang, is famous for its simplicity, but simple does not mean easy. Even developers with years of experience trip over the same handful of gotchas. These bugs rarely cause compiler errors. They just quietly produce the wrong result, leak resources, or deadlock in production.
This guide walks through the 10 most common mistakes Go developers make, with runnable Golang examples and the idiomatic fix for each one.
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. This works fine for ASCII but breaks on anything multi-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 mutate the slice you pass in. It may return a new underlying array if capacity runs out. If you ignore the return value, your changes vanish.
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) }
Go is unusual in that it treats a bare append(s, 4) call as a compile error: the return value must be used. The _ = in appendWrong only exists to satisfy the compiler and mirror what happens when someone writes the call without assigning it back.
Fix: Always reassign: s = append(s, x). When passing slices to helpers that grow them, return the new slice and reassign at the call site.
3. Zero Values Hiding Missing Map Keys
Reading a missing key from a map returns the zero value for the map's value type, not an error. If your map holds int and a key is missing, you get 0, which looks identical to a key that was explicitly set to zero.
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 produces a new error string that loses the original error value. With %w, the returned error wraps the original, and errors.Is and errors.As can walk the chain.
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 you want callers to be able to check the underlying error. Reach for %v only when the original error is an implementation detail you do not want to leak.
5. Value Receivers That Silently Discard Mutations
Methods with a value receiver work on a copy of the struct. Any changes they make to fields disappear 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 mutates the receiver, when the struct is large enough that copying is wasteful, or when you want consistency across all methods on the type.
6. Shadowed Variables That Return nil Errors
A short variable declaration inside an if block creates a new variable scoped to that block. If you already have an outer err and shadow it by accident, the outer one stays nil and you return success when 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: Either reuse the outer variable with = instead of :=, or return immediately inside the if block. Enable go vet -shadow or the govet linter to catch these at review time.
7. Unexported Struct Fields Disappearing in JSON
encoding/json can only see exported fields. Lowercase field names are invisible to the marshaller and silently omitted from the output.
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)) }
email is gone from the output. It was never even considered.
Fix: Capitalize fields you want serialized, and use struct tags to control the JSON key name. Use omitempty for optional fields.
8. Using defer Inside Loops
defer runs when the surrounding function returns, not when the loop iteration ends. Deferring a file.Close() inside a loop that processes a thousand files keeps all thousand file descriptors 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: Extract the per-iteration work into a helper function so defer fires 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
Writing result, _ := doThing() throws away every failure the function can report. The program keeps running with a zero value or partial state, and the eventual crash happens far from the real cause.
example.gogodata, _ := os.ReadFile("config.json") _ = json.Unmarshal(data, &cfg)
If the file is missing, data is nil, Unmarshal fails silently, and cfg stays zero. You only notice when the app behaves as if no config exists.
Fix: Handle the error, or return it up the stack. Reserve _ for cases where the error genuinely cannot matter, and leave a short comment explaining why.
10. Deadlocks from Unbuffered Channels
An unbuffered channel requires a sender and a receiver to meet at the same moment. Sending with nothing to receive 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 blocks waiting for a receiver, but the receiver is on the next line and never runs. Go detects the stuck goroutine and panics with fatal error: all goroutines are asleep - deadlock!.
Fix: Use a buffered channel when producer and consumer run at different speeds, or make sure a goroutine is ready to receive before you send. For one-shot results, a buffered channel of size 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
Avoiding these Golang traps is most of the battle. The rest comes from building real projects and running into edge cases with a debugger open. If you want structured practice, the LevelUpGo Go track walks through these patterns with runnable exercises and automated feedback on your code.
