Back to Blog

10 Common Go (Golang) Mistakes Every Developer Should Avoid

Learn the 10 most common Go (Golang) programming mistakes and how to avoid them: string byte handling, slice append, map zero values, error wrapping, value receivers, defer in loops, unbuffered channels, and more.

10 Common Go (Golang) Mistakes Every Developer Should Avoid

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

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.gogo
package 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.gogo
package 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.gogo
package 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.gogo
package 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.gogo
package 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.gogo
func 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.gogo
package 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.gogo
func 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.gogo
func 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.gogo
data, _ := 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.gogo
package 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.gogo
package 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.

Write Go like a senior engineer

Interactive lessons in your browser. The first ones are free.

Try a free lessonOr create a free account