DEV Community

Clara Bennett
Clara Bennett

Posted on

5 Small Go Patterns That Make Your Code Instantly Cleaner

After writing Go for a while, I've noticed a few small patterns that consistently make code easier to read and maintain. None of these are groundbreaking — but that's the point. Clean code is built from simple habits.

1. Return early, stay flat

Instead of nesting, flip the condition and return:

// ❌ Nested
func process(data []byte) error {
    if data != nil {
        if len(data) > 0 {
            // do work...
            return nil
        }
        return fmt.Errorf("empty data")
    }
    return fmt.Errorf("nil data")
}

// ✅ Early returns
func process(data []byte) error {
    if data == nil {
        return fmt.Errorf("nil data")
    }
    if len(data) == 0 {
        return fmt.Errorf("empty data")
    }
    // do work...
    return nil
}
Enter fullscreen mode Exit fullscreen mode

2. Accept interfaces, return structs

This is idiomatic Go. Your functions should accept the narrowest interface they need, but return concrete types:

// ✅ Accept interface
func ReadAll(r io.Reader) ([]byte, error) {
    // ...
}

// ✅ Return concrete type
func NewServer(addr string) *Server {
    return &Server{addr: addr}
}
Enter fullscreen mode Exit fullscreen mode

This keeps your API flexible for callers while giving them full access to the returned type.

3. Use fmt.Errorf with %w for error wrapping

Since Go 1.13, always wrap errors with context:

// ❌ Context lost
if err != nil {
    return err
}

// ✅ Wrapped with context
if err != nil {
    return fmt.Errorf("reading config: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

Now callers can still use errors.Is() and errors.As() to inspect the chain.

4. Table-driven tests

If you're writing multiple test cases for the same function, use a table:

func TestAdd(t *testing.T) {
    tests := []struct {
        name string
        a, b int
        want int
    }{
        {"positive", 2, 3, 5},
        {"zero", 0, 0, 0},
        {"negative", -1, -2, -3},
    }
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            if got := Add(tt.a, tt.b); got != tt.want {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.want)
            }
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding a new test case is just one more line in the slice. Clean, scalable, easy to read.

5. Use context.Context as the first parameter

Any function that does I/O or could be cancelled should take a context:

func FetchUser(ctx context.Context, id string) (*User, error) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

It's the standard convention, and it makes your code composable with timeouts, cancellation, and tracing from day one.


None of these will win you style points at a conference. But they'll make your codebase something your teammates (and future you) actually enjoy working with. That's the real win.

What small Go patterns do you find yourself reaching for? Drop them in the comments 👇

Top comments (0)