Go is one of the easiest languages to write beginner code in. The syntax is minimal, the compiler is fast, and the standard library covers most use cases. But there is a gap between “code that compiles and works” and “idiomatic Go that is maintainable at scale,” and that gap shows up clearly in code reviews.

Here are the patterns that come up most.

Error Handling: Wrapping vs Discarding

Junior Go code:

func getUser(id string) (*User, error) {
    user, err := db.Query(id)
    if err != nil {
        return nil, err
    }
    return user, nil
}

Senior Go code:

func getUser(id string) (*User, error) {
    user, err := db.Query(id)
    if err != nil {
        return nil, fmt.Errorf("getUser %s: %w", id, err)
    }
    return user, nil
}

The %w verb wraps the error, preserving it for errors.Is() and errors.As() checks up the call stack. Without wrapping, you lose context about where the error originated. The calling code sees a bare database error with no indication of which function or which user ID caused it.

The pattern is: every function that propagates an error should add context. The extra three characters save hours of debugging.

Interfaces: Small and Behavior-Focused

Juniors tend to write interfaces that match the concrete type they are using:

// Too large - this is a class, not a behavior
type UserRepository interface {
    GetByID(id string) (*User, error)
    GetByEmail(email string) (*User, error)
    Create(user *User) error
    Update(user *User) error
    Delete(id string) error
    List(filters UserFilters) ([]*User, error)
    Count(filters UserFilters) (int, error)
}

Senior Go engineers define interfaces at the point of use, sized to what the consumer actually needs:

// In the email service package
type UserGetter interface {
    GetByID(id string) (*User, error)
}

func SendWelcomeEmail(users UserGetter, userID string) error {
    user, err := users.GetByID(userID)
    // ...
}

Small interfaces are easier to implement, easier to mock, and explicitly communicate what a function cares about. The io.Reader and io.Writer interfaces in the standard library are the canonical examples - one method each, used everywhere.

Goroutine Lifecycle Management

Junior code spawns goroutines without managing their lifecycle:

func processItems(items []Item) {
    for _, item := range items {
        go processItem(item) // No way to know when this finishes
    }
    // Function returns, goroutines may still be running
}

Senior Go code uses WaitGroups or channels to manage goroutine lifetimes:

func processItems(ctx context.Context, items []Item) error {
    var wg sync.WaitGroup
    errCh := make(chan error, len(items))

    for _, item := range items {
        wg.Add(1)
        go func(item Item) {
            defer wg.Done()
            if err := processItem(ctx, item); err != nil {
                errCh <- err
            }
        }(item)
    }

    wg.Wait()
    close(errCh)

    for err := range errCh {
        return err // Return first error
    }
    return nil
}

Unmanaged goroutines are goroutine leaks waiting to happen. Every goroutine should have a clear termination condition.

Context Propagation

Juniors pass context through some functions and not others:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    user, err := getUser(r.Context(), r.URL.Query().Get("id"))
    if err != nil { ... }

    // Context lost here - downstream operations cannot be cancelled
    data, err := fetchExternalData(user.ID)
}

Senior engineers thread context through every I/O operation:

func handleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    user, err := getUser(ctx, r.URL.Query().Get("id"))
    if err != nil { ... }

    data, err := fetchExternalData(ctx, user.ID)
}

Context propagation means request cancellation (when a user closes the browser), timeouts, and trace IDs propagate correctly through the entire call chain.

Table-Driven Tests

Junior tests:

func TestAdd(t *testing.T) {
    if Add(1, 2) != 3 {
        t.Error("expected 3")
    }
    if Add(-1, 1) != 0 {
        t.Error("expected 0")
    }
}

Senior Go tests use table-driven format:

func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive numbers", 1, 2, 3},
        {"negative and positive", -1, 1, 0},
        {"both negative", -2, -3, -5},
        {"zero identity", 0, 5, 5},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.a, tt.b); got != tt.expected {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
            }
        })
    }
}

Table-driven tests are easier to extend, self-documenting, and produce clearer failure messages.

Struct Embedding vs Inheritance

Juniors sometimes reach for complex struct hierarchies. Senior Go engineers use embedding for composition:

type BaseHandler struct {
    logger *slog.Logger
    tracer trace.Tracer
}

func (b *BaseHandler) log(msg string, args ...any) {
    b.logger.Info(msg, args...)
}

type UserHandler struct {
    BaseHandler
    users UserRepository
}

// UserHandler gets log() for free via embedding
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    h.log("getting user", "id", r.PathValue("id"))
    // ...
}

The defer Trap

A subtle one that trips up developers from other languages:

// Bug: defer evaluates arguments immediately
func processFiles(files []string) error {
    for _, f := range files {
        file, err := os.Open(f)
        if err != nil {
            return err
        }
        defer file.Close() // This defers until the FUNCTION returns, not the loop iteration
    }
    // All files are open here, only closed when processFiles returns
}

The fix:

func processFiles(files []string) error {
    for _, f := range files {
        if err := processFile(f); err != nil {
            return err
        }
    }
    return nil
}

func processFile(path string) error {
    file, err := os.Open(path)
    if err != nil {
        return err
    }
    defer file.Close() // Now correctly scoped to this function
    // ...
    return nil
}

Bottom Line

Go’s simplicity is deceptive. The language is easy to learn but the patterns for writing reliable, maintainable Go are learned through production experience and code review feedback. Error wrapping, small interfaces, goroutine lifecycle management, and context propagation are the fundamentals that separate Go code that works from Go code that you can confidently operate.

Review your codebase for these patterns. The places where they are missing are where your most annoying bugs live.