Go Development
Write Go code that is readable, maintainable, and production-ready using battle-tested patterns from major production codebases.
Quick Decision Trees
MCP
Always use Context7 MCP to fetch the latest documentation.
Libraries
- Prefer to use libraries that are well-maintained and have a large community.
- Prefer zero-dependency libraries.
- Prefer libraries that are present in the awesome-go list.
- For HTTP services, use Chi for routing.
- For logging, use slog.Logger for logging.
- For configuration use flags or environment variables.
Linters
- golangci-lint is the best linter for Go. It is a comprehensive linter that checks for many issues in the code.
- It is a good idea to run golangci-lint on every commit.
- It is a good idea to run golangci-lint on every pull request.
- It is a good idea to run golangci-lint on every code review.
- It is a good idea to run golangci-lint on every code review.
Formatting
- goimports is the best formatter for Go. It is a simple formatter that formats the code according to the Go language specification.
- It is a good idea to run goimports on every commit.
- It is a good idea to run goimports on every pull request.
- It is a good idea to run goimports on every code review.
- It is a good idea to run goimports on every code review.
Testing
- Table-driven tests should follow the pattern of:
func TestProcess(t *testing.T) {
type testCase struct {
// Fields for the test case.
}
tests := map[string]testCase{
"name": {
// Test case fields.
},
// More test cases.
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
// Test code.
})
}
}
- Integration tests should be skipped if the environment variables are not set.
func TestIntegration(t *testing.T) {
if os.Getenv("INTEGRATION_TESTS") == "" {
t.Skip("skipping integration tests")
}
// Test code.
}
- Test helpers should call
t.Helper()so failure line numbers point to the actual test.
func TestHelper(t *testing.T) {
t.Helper()
// Test code.
}
When to use interfaces?
Define interfaces at consumption site, not implementation:
// GOOD: Consumer defines what it needs
package storage
type Store interface {
Get(key string) ([]byte, error)
}
// BAD: Implementation forces interface on consumers
package postgres
type PostgresStore interface { ... }
Interface size:
- 1 method: Perfect (Reader, Writer, Stringer)
- 2-3 methods: Good if cohesive
- 4+ methods: Consider splitting or using concrete types
- It is ok to have big interfaces for saas products or enterprice software products. For libraries, it is better to have small interfaces.
Accept interfaces, return concrete types:
// GOOD
func Process(r io.Reader) (*Result, error)
// BAD: Forces caller to deal with interface
func Process(r io.Reader) (io.Reader, error)
How to handle errors?
Decision tree:
- Can I handle this error completely here? → Log and continue
- Does caller need programmatic access? → Use
%wwrapping - Should I hide implementation details? → Use
%vwrapping - Is this a library? → Never log, always return
// Handle completely
if err != nil {
log.Printf("retrying with defaults: %v", err)
return useDefaults(), nil
}
// Caller needs access (use %w)
if err != nil {
return fmt.Errorf("connect to database: %w", err)
}
// Hide details (use %v)
if err != nil {
return fmt.Errorf("service unavailable: %v", err)
}
Error string format:
- Lowercase, no punctuation
- Avoid "failed to" or "error" prefix
- Add context:
"operation: %w"
When to use concurrency?
Leave concurrency to the caller unless:
- You're building a server/daemon that must handle concurrent requests
- You're implementing a worker pool pattern
- You're managing background operations (cleanup, metrics)
// GOOD: Synchronous by default
func Fetch(url string) (*Response, error)
// Caller decides concurrency
go fetch(url)
// BAD: Forces async on everyone
func FetchAsync(url string) <-chan *Response
Before launching a goroutine, know when it will stop:
// GOOD: Clear lifecycle
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
go func() {
for {
select {
case <-ctx.Done():
return // goroutine stops here
case work := <-ch:
process(work)
}
}
}()
Context as first parameter?
Always use context when:
- Making external calls (HTTP, DB, RPC)
- Operations may be cancelled
- Deadlines matter
- Need to pass request-scoped values
// GOOD
func Query(ctx context.Context, sql string) (*Rows, error)
// BAD: Can't be cancelled
func Query(sql string) (*Rows, error)
Common Workflows
Creating a new HTTP service
1. Project structure:
myservice/
├── cmd/
│ └── server/
│ └── main.go # Binary entrypoint
├── internal/
│ ├── handler/ # HTTP handlers
│ │ ├── handler.go
│ │ └── handler_test.go
│ ├── service/ # Business logic
│ │ ├── service.go
│ │ └── service_test.go
│ └── storage/ # Data layer
│ ├── postgres.go
│ └── postgres_test.go
├── go.mod
├── go.sum
├── Makefile
└── .golangci.yml
2. Initialize project:
mkdir -p myservice/{cmd/server,internal/{handler,service,storage}}
cd myservice
go mod init github.com/yourorg/myservice
# Setup linting
/path/to/scripts/setup_golangci_lint.sh .
3. Main.go pattern:
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"os/signal"
"time"
)
func main() {
// Flags only in main
addr := flag.String("addr", ":8080", "listen address")
timeout := flag.Duration("timeout", 30*time.Second, "request timeout")
flag.Parse()
// Initialize dependencies
srv := &http.Server{
Addr: *addr,
Handler: setupRoutes(),
ReadTimeout: *timeout,
WriteTimeout: *timeout,
}
// Graceful shutdown
go func() {
sigint := make(chan os.Signal, 1)
signal.Notify(sigint, os.Interrupt)
<-sigint
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Printf("shutdown error: %v", err)
}
}()
log.Printf("listening on %s", *addr)
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
4. Handler pattern:
package handler
import (
"encoding/json"
"net/http"
)
type Handler struct {
service Service
}
func New(svc Service) *Handler {
return &Handler{service: svc}
}
func (h *Handler) HandleGet(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// Extract params
id := r.URL.Query().Get("id")
if id == "" {
http.Error(w, "missing id", http.StatusBadRequest)
return
}
// Call service
result, err := h.service.Get(ctx, id)
if err != nil {
// Log and return appropriate status
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
// Respond
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(result)
}
Creating a CLI tool
1. Structure:
mycli/
├── main.go # Flag parsing and dispatch
├── internal/
│ └── command/
│ ├── run.go # Command implementations
│ └── run_test.go
├── go.mod
└── .golangci.yml
2. Main.go with subcommands:
package main
import (
"flag"
"fmt"
"os"
)
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "usage: %s <command> [flags]\n", os.Args[0])
os.Exit(1)
}
switch os.Args[1] {
case "process":
processCmd := flag.NewFlagSet("process", flag.ExitOnError)
input := processCmd.String("input", "", "input file")
processCmd.Parse(os.Args[2:])
if err := runProcess(*input); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
Adding comprehensive tests
1. Table-driven test pattern:
func TestProcess(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr bool
}{
{
name: "valid input",
input: "hello",
want: "HELLO",
wantErr: false,
},
{
name: "empty input",
input: "",
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Process(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("Process() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("Process() = %v, want %v", got, tt.want)
}
})
}
}
2. Test helper pattern:
func TestHandler(t *testing.T) {
h := setupHandler(t) // Helper creates handler
req := newRequest(t, "GET", "/api/test") // Helper creates request
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
assertStatus(t, rr.Code, http.StatusOK) // Helper asserts
assertBody(t, rr.Body.String(), "expected")
}
func setupHandler(t *testing.T) http.Handler {
t.Helper() // Marks this as test helper
// Setup code
}
Detailed Reference
For comprehensive coverage of all Go idioms, patterns, and best practices:
Read references/go-styleguide.md for:
- Complete naming conventions (packages, variables, interfaces, constants)
- Code organization principles (when to create packages, file structure)
- Error handling patterns (wrapping, checking, panics)
- Concurrency patterns (goroutines, channels, context, waitgroups)
- Interface design (when/where to define, sizes, embedded interfaces)
- Testing patterns (table-driven, subtests, helpers, mocks)
- Performance considerations (allocations, profiling, benchmarks)
- Critical pitfalls to avoid (loop variables, nil interfaces, defer in loops)
Linting Setup
Run the setup script:
scripts/setup_golangci_lint.sh /path/to/your/project
This configures comprehensive linting including:
- Error checking (errcheck, errorlint)
- Security analysis (gosec)
- Style enforcement (revive, gocritic)
- Performance checks (prealloc, perfsprint)
- Code quality (gocyclo, gocognit, staticcheck)
Common commands:
# Run all linters
golangci-lint run
# Auto-fix issues
golangci-lint run --fix
# Lint specific paths
golangci-lint run ./internal/...
Quick Reference Cheatsheet
Naming:
- Packages: lowercase, singular, no underscores
- Getters:
obj.Owner()notobj.GetOwner() - Acronyms: consistent case (
URLorurl, neverUrl)
Error handling:
- Check immediately after call
- Wrap with context:
fmt.Errorf("operation: %w", err) - Handle exactly once: log OR return, not both
- Never panic in libraries
Concurrency:
- Context as first parameter
- Know when every goroutine stops
- Use
sync.WaitGroupfor coordination - Don't force concurrency on callers
Structure:
- Return early with guard clauses
- Keep success path left-aligned
- Import groups: stdlib, external, internal
Testing:
- Table-driven with named fields
- Use
t.Run()for subtests - Call
t.Helper()in helpers - Message format:
got X, want Y
Critical pitfalls:
- Loop variable capture: pass to closure explicitly
- Nil interface check: interface with nil value ≠ nil
- Defer in loops: wrap in closure
- Map writes to nil: always
make()first