How to Do Code Review for Go Projects

Review Go code by running go vet and gofmt to ensure style compliance and backward compatibility before submission.

The first line of defense

You open a pull request on a Go repository. The diff is clean. The tests pass. But the reviewer leaves three comments about formatting, one about an unchecked error, and another about a context parameter buried in the middle of the argument list. You fix them, squash, and merge. That cycle is the heartbeat of Go development. Code review in Go is not about policing syntax. It is about enforcing a shared mental model so that any developer can read any file and understand it within minutes.

What code review actually checks in Go

Go treats code as a communication medium. The language deliberately strips away decorators, inheritance hierarchies, and optional semicolons to force a plain structure. A code review in Go checks whether the code follows that plain structure. Think of it like editing a technical manual. You are not rewriting the engineering specs. You are making sure the headings match, the warnings are visible, and the steps follow a logical order. The Go community built tooling to handle the mechanical parts so humans can focus on architecture and edge cases.

The language removes choices so the team stops arguing about them.

The toolchain does the heavy lifting

Every Go project runs two commands before a human ever looks at the diff. The standard toolchain ships with static analysis and formatting built in. You do not need to install third-party linters to get started.

// main.go demonstrates the baseline review workflow
package main

import (
	"fmt"
	"os"
)

func main() {
	// Run go vet to catch suspicious constructs
	// Run gofmt to enforce canonical formatting
	// Both are part of the standard toolchain
	fmt.Println("Review workflow starts here")
}

The go vet command runs static analysis directly on your source code. It looks for dead code, impossible type switches, and printf format mismatches. gofmt reformats the file to match the official style guide. You do not configure it. You do not debate it. The toolchain runs them automatically in most editors, and CI pipelines fail if they are missing. When you run gofmt -d ., it prints a diff of what would change. If the output is empty, the formatting matches the standard. If it prints changes, the reviewer will ask you to run gofmt -w . and commit the result.

Most editors run gofmt on save. Trust the tool. Argue logic, not formatting.

Real-world review workflow

Real reviews go beyond formatting. They check error handling, context propagation, and package boundaries. Here is a typical HTTP handler that needs review attention.

// handler.go shows a common pattern that needs review attention
package main

import (
	"context"
	"net/http"
	"time"
)

// FetchData retrieves information from an upstream service
func FetchData(w http.ResponseWriter, r *http.Request) {
	// Context must be the first parameter in downstream calls
	// Deadline prevents goroutine leaks if the client disconnects
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	// Always check the error return value
	// Silent failures hide bugs in production
	data, err := callUpstream(ctx)
	if err != nil {
		http.Error(w, "upstream failed", http.StatusBadGateway)
		return
	}

	// Write the response safely
	w.Header().Set("Content-Type", "application/json")
	w.Write(data)
}

// callUpstream simulates an external dependency
func callUpstream(ctx context.Context) ([]byte, error) {
	// Implementation omitted for brevity
	return []byte(`{"status":"ok"}`), nil
}

A reviewer will scan this for three things. First, the context flows from the request to the downstream call. Second, the error is checked immediately. Third, the function signature follows Go conventions. If callUpstream accepted a *context.Context instead of a context.Context, the reviewer would flag it. Contexts are cheap to pass by value. Taking a pointer to a context breaks convention and adds unnecessary indirection. The reviewer will also check that defer cancel() appears right after context.WithTimeout. Forgetting the defer causes memory leaks because the context tree stays alive in the scheduler.

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

Common traps and compiler feedback

Go catches many mistakes at compile time, but some slip through to review. The compiler rejects programs with loop variable i captured by func literal when you try to use a range variable inside a goroutine without capturing it first. Go 1.22 fixed this by changing the loop variable semantics, but older codebases still carry the pattern. Reviewers look for explicit captures like go func(id int) { ... }(id) to guarantee safety.

Another common trap is ignoring return values. The compiler complains with result of ... call is not used if you drop a value that is not an error. If you intentionally drop a value, you use the blank identifier _. Writing result, _ := someFunc() tells the reviewer you considered the second return value and chose to discard it. Using _ for errors is almost always wrong. The compiler will not stop you, but a reviewer will mark it as a critical oversight.

Interface design also triggers review comments. Go follows a simple rule: accept interfaces, return structs. If a function returns an interface, the reviewer will ask why. Returning an interface hides the concrete implementation and makes testing harder. Accepting an interface at the boundary keeps dependencies loose. The reviewer will also check receiver names. Methods should use short names like (c *Client) or (b *Buffer). Writing (this *Client) or (self *Client) breaks community convention and signals that the author is carrying habits from another language.

The compiler catches syntax. Review catches design.

Backward compatibility and the Go 1 promise

Go ships with a strict compatibility guarantee. Code that compiles on Go 1.0 must compile on Go 1.23 without changes. This promise shapes how reviewers evaluate new features. You cannot break public APIs lightly. If you rename a struct field, you must add a deprecation comment and provide a migration path. If you change a function signature, you must wrap it in a new function and mark the old one as deprecated.

Reviewers check exported names carefully. Public names start with a capital letter. Private names start lowercase. There are no public or private keywords. The capitalization is the only visibility modifier. If a reviewer sees a capitalized type that should be internal to the package, they will ask you to lowercase it. Exposing internal types increases the maintenance burden and breaks encapsulation.

The Go 1 compatibility guarantee means you cannot delete exported symbols. Mark them deprecated, document the replacement, and wait for the next major version if you ever ship one.

When to rely on tools versus human eyes

Code review splits into two buckets. Automated tools handle consistency and obvious mistakes. Humans handle architecture and edge cases. You need both to ship reliable software.

Use gofmt when you want zero debates about indentation, braces, or spacing. Use go vet when you need to catch dead code, format string mismatches, and impossible type assertions. Use staticcheck or revive when you want project-specific rules that go beyond the standard toolchain. Use manual review when you are changing public APIs, adding new goroutines, or modifying error handling strategies. Use automated linters when you need to enforce import ordering, naming conventions, and unused variable detection. Use human eyes when you are evaluating context propagation, goroutine lifecycle management, and backward compatibility checks.

Automate the mechanical. Reserve human attention for the architecture.

Where to go next