Go Code Style Guide

Writing Idiomatic Go

Write idiomatic Go by using gofmt for automatic formatting and following official style conventions for readability.

The friction of formatting

You open a pull request. The code works. The tests pass. The CI pipeline fails on a single line: expected statement, found '}'. You spent twenty minutes debugging a missing semicolon that Go does not actually use. The real problem is a misplaced brace. You fix it, push again, and the review comes back with three comments about indentation, two about variable names, and one asking why you wrapped a string in a pointer. Writing Go feels different from Python or JavaScript. The language refuses to let you customize its appearance. That friction is intentional.

Why Go refuses to let you argue about braces

Go ships with a formatter that runs on every save. Most editors integrate it automatically. The tool does not just align your code. It enforces a single correct way to format everything. Think of it like a railway system. In other languages, you design the tracks, lay the ties, and paint the signals. In Go, the tracks are already laid. You just drive the train. The community accepts this because formatting debates waste time. You spend your energy on logic, not tabs versus spaces. Run gofmt -w . to rewrite every file in a directory to match the standard. The command is idempotent. Run it once or run it a hundred times. The output stays identical.

// CalculateTotal computes the sum of a slice of integers.
func CalculateTotal(values []int) int {
    // The opening brace stays on the same line as the function signature.
    // gofmt enforces this rule without exception.
    total := 0
    // Loop variables use short names when the scope is narrow.
    // The compiler optimizes the iteration, so readability matters more than micro-optimizations.
    for _, v := range values {
        total += v
    }
    // Return the accumulated value directly.
    // No extra wrapping or unnecessary type conversions.
    return total
}

Trust the formatter. Argue logic, not indentation.

Naming things without overthinking

Go favors short names. i, j, k for loops. f for files. r for readers. Long names like databaseConnectionManager become db. The compiler does not care about length. The convention exists because short names reduce visual noise. Public names start with a capital letter. Private names start lowercase. There are no public or private keywords. Capitalization controls visibility. Receiver names follow a pattern: one or two letters matching the type. (b *Buffer) Write() works. (this *Buffer) does not. The community reads the receiver name as a shorthand for the type itself.

// Buffer holds a sequence of bytes for sequential reading.
type Buffer struct {
    // data stores the underlying byte slice.
    // Lowercase names keep the field unexported.
    data []byte
    // pos tracks the current read offset.
    pos int
}

// Read pulls bytes from the buffer into the destination slice.
func (b *Buffer) Read(dst []byte) (int, error) {
    // The receiver name b matches the type Buffer.
    // This convention makes method chains easier to scan.
    if b.pos >= len(b.data) {
        // Return early when the buffer is exhausted.
        // io.EOF signals the end of input without panicking.
        return 0, io.EOF
    }
    // Copy remaining bytes or fill the destination slice.
    n := copy(dst, b.data[b.pos:])
    // Advance the position pointer after the copy completes.
    b.pos += n
    return n, nil
}

Short names reduce cognitive load. Capitalization controls visibility.

Errors are values, not exceptions

JavaScript throws exceptions. Python raises them. Go returns errors as regular values. You check them immediately. if err != nil { return err } looks verbose. It is. The verbosity forces you to handle the failure path where it happens. You cannot accidentally swallow an error behind a try-catch block three functions up. Wrap errors with %w to preserve the chain. fmt.Errorf("failed to open file: %w", err) keeps the original context intact. Discarding values uses the underscore. result, _ := strconv.Atoi(input) tells the reader you considered the error and chose to ignore it. Use it sparingly. Ignoring errors in production code usually causes silent data corruption.

// ParseConfig reads and decodes a JSON configuration file.
func ParseConfig(path string) (*Config, error) {
    // Open the file and check the error immediately.
    // Deferring error handling hides the exact failure point.
    f, err := os.Open(path)
    if err != nil {
        // Wrap the error to add context for the caller.
        // The %w verb preserves the original error for errors.Is checks.
        return nil, fmt.Errorf("open config: %w", err)
    }
    // Defer the close call to guarantee cleanup.
    // The defer runs when ParseConfig returns, regardless of success or failure.
    defer f.Close()

    // Decode the JSON payload into a concrete struct.
    // Returning a struct gives the caller direct field access.
    var cfg Config
    if err := json.NewDecoder(f).Decode(&cfg); err != nil {
        return nil, fmt.Errorf("decode config: %w", err)
    }
    return &cfg, nil
}

Errors are values. Handle them where they happen.

A realistic example: an HTTP handler

Real Go code ties formatting, naming, error handling, and interfaces together. HTTP handlers demonstrate the pattern clearly. The standard library expects a function signature that accepts a ResponseWriter and a *Request. You validate input, call downstream services, and return structured responses. Context flows through every long-lived call site. It carries deadlines and cancellation signals.

// GetUserHandler returns an HTTP handler that fetches user data.
func GetUserHandler(svc UserService) http.HandlerFunc {
    // The closure captures the service dependency.
    // Passing interfaces keeps the handler testable without a real database.
    return func(w http.ResponseWriter, r *http.Request) {
        // Extract the user ID from the URL path.
        // chi.URLParam is a common convention in routing packages.
        id := chi.URLParam(r, "id")
        if id == "" {
            // Return a 400 status immediately on malformed input.
            // Early returns prevent deeply nested conditional blocks.
            http.Error(w, "missing user id", http.StatusBadRequest)
            return
        }

        // Attach a timeout to the request context.
        // Downstream calls respect cancellation and prevent goroutine leaks.
        ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
        defer cancel()

        // Call the service layer with the context and ID.
        // The service returns a concrete struct and an error.
        user, err := svc.Get(ctx, id)
        if err != nil {
            // Log the error and return a 500 status.
            // Production code usually wraps this in a structured logger.
            log.Printf("fetch user failed: %v", err)
            http.Error(w, "internal server error", http.StatusInternalServerError)
            return
        }

        // Set the content type header before writing the body.
        // The server flushes headers on the first write.
        w.Header().Set("Content-Type", "application/json")
        // Encode the struct directly to the response writer.
        // json.Encoder handles streaming and escapes special characters.
        if err := json.NewEncoder(w).Encode(user); err != nil {
            log.Printf("encode user failed: %v", err)
        }
    }
}

Context is plumbing. Run it through every long-lived call site.

What happens when you ignore the conventions

The compiler enforces visibility rules strictly. Reference a lowercase function from another package and you get undefined: pkg.lowercaseFunc. Forget to use an imported package and the build fails with imported and not used: "encoding/json". Go does not allow unused imports. You must use the blank identifier to silence it: import _ "net/http/pprof". This tells the compiler you want the side effects, not the symbols. Runtime panics often come from nil pointer dereferences or channel operations on closed pipes. The worst goroutine bug is the one that never logs. Always attach a context or a done channel to background work. If the main program exits, orphaned goroutines leak memory until the process dies.

// StartWorker launches a background routine that processes jobs.
func StartWorker(ctx context.Context, jobs <-chan Job, results chan<- Result) {
    // The goroutine runs independently of the caller.
    // It must respect ctx cancellation to avoid memory leaks.
    go func() {
        // Range over the jobs channel until it closes or context cancels.
        // The select statement allows early exit on deadline expiration.
        for {
            select {
            case <-ctx.Done():
                // Exit immediately when the parent context cancels.
                // This prevents the goroutine from waiting on a dead channel.
                return
            case job, ok := <-jobs:
                if !ok {
                    // The channel closed. No more jobs will arrive.
                    // Return cleanly to release the goroutine.
                    return
                }
                // Process the job and send the result downstream.
                // Blocking sends backpressure the pipeline automatically.
                results <- process(job)
            }
        }
    }()
}

The compiler catches visibility mistakes. Runtime leaks hide in silent channels.

When to bend the rules

Go conventions exist to reduce cognitive overhead. You follow them by default. You deviate only when the problem demands it. The decision matrix below maps scenarios to the right tool.

Use gofmt when you want your code to match the standard library without debate. Use short names when the scope is small and the meaning is obvious from context. Use explicit error returns when you need to handle failures immediately at the call site. Use interfaces as function parameters when you want to accept multiple underlying types. Use concrete structs as return values when the caller needs to access specific fields. Use the blank identifier when you intentionally discard a return value. Use plain sequential code when concurrency adds complexity without performance gains. Use a goroutine when you have independent I/O calls that can run while others wait. Use a worker pool when you need bounded concurrency to protect a downstream service. Use a single goroutine plus a channel when one task feeds another in a pipeline.

Follow the convention. Break it only when you can explain why.

Where to go next