Go Best Practices and Common Mistakes
A closing checklist of habits that separate idiomatic, maintainable Go from code that merely compiles.
3 min read
Go's small syntax makes it easy to start writing, but there's a real gap between Go code that compiles and runs, and Go code an experienced Go developer would recognize as idiomatic. Here's a checklist worth returning to.
Run gofmt and go vet, always
go fmt ./...
go vet ./...gofmt isn't optional style in the Go community — it's the format everyone's editor produces, and fighting it is a losing battle. go vet catches real mistakes static analysis can find: format strings that don't match their arguments, unreachable code, suspicious struct tags. Most teams also add golangci-lint, which bundles dozens of additional linters into one command.
Common mistakes to avoid
Ignoring errors.
// Avoid
result, _ := someFunc()
// Prefer
result, err := someFunc()
if err != nil {
return fmt.Errorf("someFunc failed: %w", err)
}Overusing panic instead of returning an error.
// Avoid: panicking for an expected, recoverable situation
func parseAge(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
panic(err)
}
return n
}
// Prefer: let the caller decide what an invalid age means
func parseAge(s string) (int, error) {
return strconv.Atoi(s)
}Overusing goroutines without a way to wait for or limit them.
// Avoid: fires 10,000 unbounded goroutines with no coordination
for _, url := range urls {
go fetch(url)
}
// Prefer: bound concurrency and wait for completion
sem := make(chan struct{}, 10) // at most 10 concurrent
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
fetch(u)
}(url)
}
wg.Wait()Large, do-everything interfaces.
// Avoid
type Repository interface {
GetUser(id int) (*User, error)
SaveUser(u *User) error
DeleteUser(id int) error
GetOrder(id int) (*Order, error)
// ... a dozen more methods
}
// Prefer: small, focused interfaces, composed where needed
type UserGetter interface {
GetUser(id int) (*User, error)
}Smaller interfaces are easier to implement, easier to mock in tests, and easier for a type to satisfy without needing methods it doesn't actually have a use for.
Mutating shared state without synchronization. Anything touched by more than one goroutine needs a mutex, a channel, or to simply not be shared — run go run -race regularly (or wire it into CI) rather than hoping a race never shows up in production.
A final checklist
- [ ]
gofmtandgo vetrun clean, ideally enforced in CI. - [ ] No error return value is silently discarded with
_without a documented reason. - [ ]
panicis reserved for truly unrecoverable situations, not routine failure handling. - [ ] Every goroutine's lifecycle is accounted for — via a
WaitGroup, a channel, or acontext.Contextfor cancellation. - [ ] Interfaces are small and defined where they're consumed, not bundled next to the concrete type that implements them.
- [ ]
internal/marks code that's genuinely implementation detail, not part of the module's public API. - [ ]
go mod tidyhas been run sogo.mod/go.sumreflect actual imports. - [ ] Concurrent code has been run at least once with
-race.
None of these are exotic techniques — they're the same tools covered throughout this course, just applied with a bit more discipline. That discipline is most of what separates Go code that merely runs from Go code a team can actually build on for years.
Test what you just learned
4 quick questions. Get all of them right to unlock the next lesson.
You can take the quiz without an account — logging in just lets your result count toward your progress.