05 · Anti-Patterns
Common Go mistakes — recognize and avoid them
Ignoring Errors
_ to discard an error unless you have an explicit reason.
// BAD
result, _ := db.Query("SELECT ...") // error silently dropped
// GOOD
result, err := db.Query("SELECT ...")
if err != nil {
return fmt.Errorf("query failed: %w", err)
}
Goroutine Leaks
Goroutines are cheap, but leaking them (starting without a way to stop) exhausts memory over time.
// BAD — goroutine runs forever with no way to cancel
go func() {
for {
process()
time.Sleep(time.Second)
}
}()
// GOOD — use a context for cancellation
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
return // exits cleanly
default:
process()
time.Sleep(time.Second)
}
}
}(ctx)
Global Mutable State
// BAD — package-level var shared across all requests
var currentUser string
func HandleRequest(c *fiber.Ctx) error {
currentUser = c.Get("X-User-ID") // race condition!
return process()
}
// GOOD — pass state through context or function arguments
func HandleRequest(c *fiber.Ctx) error {
userID := c.Get("X-User-ID")
return process(c.UserContext(), userID)
}
Panic in Library / Handler Code
// BAD
func GetCoupon(id string) *Coupon {
if id == "" {
panic("id cannot be empty") // kills the server process
}
// ...
}
// GOOD
func GetCoupon(id string) (*Coupon, error) {
if id == "" {
return nil, errors.New("id cannot be empty")
}
// ...
}
Overusing interface{} / any
// BAD — loses all type safety
func Process(data interface{}) {
// need type assertions everywhere, runtime panics if wrong
name := data.(map[string]string)["name"]
}
// GOOD — define a concrete type
type ProcessInput struct {
Name string
}
func Process(data ProcessInput) {
name := data.Name
}
init() Abuse
init() runs automatically when a package is imported — before main(). Surprising side effects (DB connections, file reads) in init() make code hard to test and reason about.
// BAD
func init() {
db = connectToDatabase() // side effect on import
}
// GOOD — explicit initialization in main
func main() {
db, err := connectToDatabase()
if err != nil { log.Fatal(err) }
// pass db to routes/handlers
}
Fiber ctx Value Reuse
Fiber reuses *fiber.Ctx across requests for performance. Values from c.Query(), c.Params(), c.Body() point to pooled memory — storing them outside the handler lifetime corrupts future requests.
fiber.Ctx values to goroutines or store them after the handler returns.
// BAD — goroutine reads stale/overwritten memory
func HandleOrder(c *fiber.Ctx) error {
userID := c.Query("user_id") // pointer into pooled buffer
go processAsync(userID) // buffer may be reused before goroutine runs
return c.SendStatus(202)
}
// GOOD — copy the value before spawning
func HandleOrder(c *fiber.Ctx) error {
userID := c.Query("user_id")
userIDCopy := strings.Clone(userID) // own copy on the heap
go processAsync(userIDCopy)
return c.SendStatus(202)
}
Loop Variable Capture in Goroutines
Goroutines launched inside a loop share the loop variable by reference. By the time the goroutine runs, the loop has already advanced — all goroutines see the last value.
// BAD — all goroutines print the same (last) item
items := []string{"a", "b", "c"}
for _, item := range items {
go func() {
fmt.Println(item) // captures &item, not the value
}()
}
// GOOD — shadow the variable inside the loop body
for _, item := range items {
item := item // new variable per iteration
go func() {
fmt.Println(item)
}()
}
// ALSO GOOD — pass as argument
for _, item := range items {
go func(s string) {
fmt.Println(s)
}(item)
}
for loop variables, but loop variables in range over slices still follow old semantics in pre-1.22 code. Always be explicit.
Handling an Error Twice
Logging and returning an error causes duplicate log lines — each caller up the stack may log again.
// BAD — logged here AND by the caller
func GetUser(id string) (*User, error) {
user, err := db.Find(id)
if err != nil {
log.Printf("db.Find failed: %v", err) // logged once
return nil, err // caller logs again
}
return user, nil
}
// GOOD — return a wrapped error; log once at the top boundary
func GetUser(id string) (*User, error) {
user, err := db.Find(id)
if err != nil {
return nil, fmt.Errorf("GetUser %s: %w", id, err)
}
return user, nil
}
// Log only at the handler/entry point
func HandleGetUser(c *fiber.Ctx) error {
user, err := svc.GetUser(c.Params("id"))
if err != nil {
log.Printf("HandleGetUser: %v", err) // single log
return fiber.ErrInternalServerError
}
return c.JSON(user)
}
String Concatenation in Loops
Using + to build strings in a loop allocates a new string on every iteration — O(n²) memory cost.
// BAD — n allocations for n iterations
result := ""
for _, part := range parts {
result += part // allocates new string every time
}
// GOOD — single allocation
var b strings.Builder
for _, part := range parts {
b.WriteString(part)
}
result := b.String()
defer Inside a Loop
defer runs when the surrounding function returns, not at the end of the loop iteration. In a long-running loop over files or DB rows, deferred closes stack up and never run until the function exits.
// BAD — files stay open until ProcessAll returns
func ProcessAll(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close() // stacks, not per-iteration
process(f)
}
return nil
}
// GOOD — close in a helper function
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() // runs when processOne returns — correct
return process(f)
}
Slice Pre-allocation
Appending to a nil slice when the final size is known causes repeated reallocation and copying as the backing array grows.
// BAD — reallocates ~log₂(n) times
var results []Order
for _, row := range rows {
results = append(results, toOrder(row))
}
// GOOD — allocate once
results := make([]Order, 0, len(rows))
for _, row := range rows {
results = append(results, toOrder(row))
}
Maps Never Shrink
Deleting keys from a map frees the values but not the internal bucket array. A map that held 1 million keys still occupies ~the same memory after all keys are deleted.
// Problematic pattern in a long-running cache
cache := make(map[string][]byte)
// ... fill with 1M entries ...
for k := range cache {
delete(cache, k) // values freed, buckets remain
}
// cache still holds ~same memory footprint
// Fix: replace the map entirely when bulk-clearing
cache = make(map[string][]byte) // old map GC-eligible
Variable Shadowing with :=
Short variable declaration inside a block creates a new variable, not an assignment to the outer one. The outer variable is unchanged — a silent logic bug.
// BAD — err in outer scope never updated
var err error
if someCondition {
result, err := doSomething() // new 'err' scoped to this block
_ = result
// outer err is still nil here
}
if err != nil { // always false — wrong!
return err
}
// GOOD — assign, don't redeclare
var err error
var result Result
if someCondition {
result, err = doSomething() // = not :=
}
if err != nil {
return err
}
Key Takeaways
- Never discard errors with
_unless intentional and commented - Every goroutine needs an exit path — use
ctx.Done() - Avoid package-level mutable state — pass through context or arguments
- Return errors from handlers/services — never panic for expected failure cases
- Avoid
interface{}/any— define concrete types instead - Keep
init()side-effect free — initialize explicitly inmain() - Copy Fiber
ctxvalues before passing to goroutines — pooled memory gets reused - Shadow loop variables (
item := item) before launching goroutines - Log OR return an error — never both; duplicate logs obscure root cause
- Use
strings.Builderfor string building in loops - Wrap
defertargets in a helper function when looping over resources - Pre-allocate slices with
make([]T, 0, n)when final size is known - Replace maps entirely to free bucket memory — deleting keys alone does not shrink
- Watch for
:=shadowing — use=when updating an existing variable