Go has a strong house style that experienced practitioners converge on. Some of it is enforced by gofmt and golint. The rest is transmitted through code reviews, the standard library, and writing enough Go to feel the natural grain of the language.

After several years of Go, here are the patterns that mark idiomatic code and why they work.

Errors Are Values, Not Exceptions

The single biggest source of unidiomatic Go is developers from exception-based languages writing exception-style Go. The patterns to avoid:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Unidiomatic: panic as control flow
func getUser(id string) User {
    user, err := db.GetUser(id)
    if err != nil {
        panic(err)  // don't
    }
    return user
}

// Unidiomatic: ignoring errors
user, _ := db.GetUser(id)

Idiomatic Go handles errors at each call site, with explicit decisions about what to do when things go wrong:

1
2
3
4
5
6
7
8
// Idiomatic: handle the error, wrap it with context
func getUser(ctx context.Context, id string) (User, error) {
    user, err := db.GetUser(ctx, id)
    if err != nil {
        return User{}, fmt.Errorf("getUser %s: %w", id, err)
    }
    return user, nil
}

The %w verb wraps the error so callers can use errors.Is and errors.As to inspect the error chain. The format string adds the context that distinguishes this error from other errors in the logs.

Rule: wrap errors with context when returning them. Don’t use the same format at every layer — one meaningful context per layer is enough.

Accept Interfaces, Return Concrete Types

This is one of the most cited Go guidelines and one of the most misunderstood.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// Wrong: return interface
func NewWriter(dst io.Writer) io.Writer {
    return &bufferedWriter{dst: dst}
}

// Right: return concrete type
func NewWriter(dst io.Writer) *BufferedWriter {
    return &BufferedWriter{dst: dst}
}

// Accept interface (minimum needed)
func CopyWithBuffer(dst io.Writer, src io.Reader) error {
    // ...
}

Returning interfaces hides the concrete type from callers, preventing them from accessing methods the concrete type has that the interface doesn’t. Returning concrete types maximises what callers can do.

Accepting interfaces (the minimum interface required) keeps functions general without over-specifying the concrete type required.

The corollary: define interfaces at the point of use, not at the point of definition.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Wrong: define interface in the package that implements it
// (in package "storage")
type Storage interface {
    Get(key string) ([]byte, error)
    Put(key string, value []byte) error
}

// Right: define interface in the package that uses it
// (in package "service")
type DataStore interface {
    Get(key string) ([]byte, error)
    Put(key string, value []byte) error
}

Small, consumer-defined interfaces are one of Go’s strongest patterns. They compose naturally and decouple implementations from consumers.

Table-Driven Tests

Go’s standard testing package, combined with subtests, makes table-driven tests the natural pattern:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func TestParseAmount(t *testing.T) {
    tests := []struct {
        name    string
        input   string
        want    Amount
        wantErr bool
    }{
        {"valid integer", "100", Amount{Value: 100, Currency: ""}, false},
        {"valid with currency", "100USD", Amount{Value: 100, Currency: "USD"}, false},
        {"negative", "-50EUR", Amount{Value: -50, Currency: "EUR"}, false},
        {"invalid", "abc", Amount{}, true},
        {"empty", "", Amount{}, true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := ParseAmount(tt.input)
            if (err != nil) != tt.wantErr {
                t.Fatalf("ParseAmount(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
            }
            if !tt.wantErr && got != tt.want {
                t.Errorf("ParseAmount(%q) = %v, want %v", tt.input, got, tt.want)
            }
        })
    }
}

The benefits: adding test cases is one line, test names are explicit, subtests (t.Run) run independently so one failure doesn’t prevent others from running, and go test -run TestParseAmount/valid lets you run a specific case.

Named Return Values: Sparingly

Named return values (func f() (result int, err error)) are useful in exactly one case: when a deferred function needs to modify the return value.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// Legitimate use: defer needs to modify the return
func processWithRollback(ctx context.Context, db *sql.DB) (err error) {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return err
    }
    defer func() {
        if err != nil {
            tx.Rollback()
        }
    }()

    err = doWork(tx)
    return tx.Commit()
}

Everywhere else, named returns add noise without benefit. They encourage return with no arguments (“naked return”), which is confusing in any function longer than a few lines. Don’t use them unless the defer pattern above requires it.

Struct Initialisation: Always Name Fields

1
2
3
4
5
6
7
8
9
// Wrong: positional initialisation
p := Position{"EUR/USD", 1000000, 1.0842}

// Right: named fields
p := Position{
    Instrument: "EUR/USD",
    Quantity:   1000000,
    Rate:       1.0842,
}

Positional struct initialisation breaks silently when fields are reordered or a field is added. Named field initialisation is resilient to these changes and self-documents the code.

Context Is First, Error Is Last

Function signatures in Go have a conventional parameter order:

1
2
3
4
5
6
7
8
// Context first, if present
func ProcessOrder(ctx context.Context, order Order) (Result, error)

// Error last
func Parse(data []byte) (Config, error)

// Not this
func ProcessOrder(order Order, ctx context.Context) (error, Result)

This is a convention strong enough that linters enforce it. Following it makes code readable without thinking about it.

Don’t Use init() Except for Registration

init() functions run at package load time, in an order that isn’t always predictable. They make programs harder to test and reason about.

The one legitimate use: registering drivers, codecs, or plugins.

1
2
3
4
// Legitimate: registering a PostgreSQL driver
func init() {
    sql.Register("postgres", &pq.Driver{})
}

Everything else that happens in init() is usually better as explicit initialisation in main() or as lazy initialisation at first use.

Short Variable Names for Small Scopes

Go convention uses short variable names in small scopes:

1
2
3
4
5
6
7
8
for i, v := range items {
    // i and v are fine — scope is the loop body
}

// Single-letter receiver names
func (c *Client) Do(req *Request) (*Response, error) {
    // c, req, res — conventional
}

Longer names for longer scopes:

1
2
3
4
func (s *Server) handleRequest(ctx context.Context, req *http.Request) {
    userID := req.Header.Get("X-User-ID")
    // userID is in scope for the function body — descriptive name is appropriate
}

The Go standard library uses this consistently. Fighting it produces code that looks foreign to Go readers.


Idiomatic Go is the accumulated judgment of a language designed to be read more than it’s written. The patterns above aren’t arbitrary — each reflects a decision about what makes code clear, testable, and maintainable. The fastest way to internalise them is to read the standard library source and accept code review feedback without defending the pattern you brought from another language.