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
}
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}
}
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)
}
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)
}
})
}
}
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) {
// ...
}
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)