Go Best Practices: Writing Idiomatic and Maintainable Code
Go’s philosophy emphasizes simplicity, clarity, and pragmatism. This guide covers essential best practices, idioms, and conventions that make Go code idiomatic, maintainable, and production-ready.
Understanding Go’s Philosophy
Go was designed with specific principles in mind that shape how idiomatic Go code should be written.
Core Principles
Simplicity Over Cleverness Go values explicit, readable code over clever abstractions. The language intentionally lacks certain features (like inheritance, generics until 1.18) to maintain simplicity.
// Good: Explicit and clear
func processData(data []string) error {
for _, item := range data {
if err := validate(item); err != nil {
return err
}
}
return nil
}
// Avoid: Overly clever
func processData(data []string) error {
return errors.Join(
slices.Collect(
iter.Filter(
iter.Map(data, validate),
func(e error) bool { return e != nil },
),
)...,
)
}
Readability is Paramount Code is read far more often than it’s written. Prioritize clarity for future maintainers (including yourself).
// Good: Clear variable names and logic
func calculateUserScore(user *User, activities []Activity) int {
score := 0
for _, activity := range activities {
if activity.UserID == user.ID {
score += activity.Points
}
}
return score
}
// Avoid: Cryptic abbreviations
func calcUS(u *U, as []A) int {
s := 0
for _, a := range as {
if a.UID == u.ID {
s += a.Pts
}
}
return s
}
Composition Over Inheritance Go uses composition and interfaces instead of inheritance hierarchies.
// Good: Composition
type Logger interface {
Log(msg string)
}
type Service struct {
logger Logger
}
func (s *Service) Process() {
s.logger.Log("Processing...")
}
// Avoid: Inheritance-like patterns
type BaseService struct {
logger Logger
}
type UserService struct {
BaseService
}
Error Handling Best Practices
Error handling is central to Go’s design philosophy.
Explicit Error Checking
Always check errors explicitly rather than ignoring them.
// Good: Explicit error handling
data, err := ioutil.ReadFile("config.json")
if err != nil {
return fmt.Errorf("failed to read config: %w", err)
}
// Avoid: Ignoring errors
data, _ := ioutil.ReadFile("config.json")
Error Wrapping and Context
Use error wrapping to provide context while preserving the original error.
// Good: Wrapped errors with context
func fetchUser(id string) (*User, error) {
resp, err := http.Get(fmt.Sprintf("/users/%s", id))
if err != nil {
return nil, fmt.Errorf("fetch user %s: %w", id, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch user %s: status %d", id, resp.StatusCode)
}
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode user response: %w", err)
}
return &user, nil
}
// Usage
user, err := fetchUser("123")
if err != nil {
log.Printf("Error: %v", err)
if errors.Is(err, io.EOF) {
// Handle specific error
}
}
Custom Error Types
Create custom error types for specific error conditions.
// Good: Custom error type
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
func validateEmail(email string) error {
if !strings.Contains(email, "@") {
return &ValidationError{
Field: "email",
Message: "must contain @",
}
}
return nil
}
// Usage
if err := validateEmail("invalid"); err != nil {
var valErr *ValidationError
if errors.As(err, &valErr) {
fmt.Printf("Field %s failed: %s\n", valErr.Field, valErr.Message)
}
}
Naming Conventions
Go has strong naming conventions that make code more idiomatic.
Package Names
- Use short, lowercase names
- Avoid generic names like
util,common,helper - Make package names descriptive of their purpose
// Good package names
package http
package json
package storage
package auth
// Avoid
package util
package common
package helpers
Variable and Function Names
- Use short names for short-lived variables
- Use longer names for package-level functions and types
- Use camelCase for unexported, PascalCase for exported
// Good: Appropriate naming
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
for i := 0; i < len(s.handlers); i++ {
h := s.handlers[i]
if h.matches(r) {
h.serve(w, r)
return
}
}
}
// Avoid: Inconsistent naming
func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
for index := 0; index < len(s.RequestHandlers); index++ {
handler := s.RequestHandlers[index]
if handler.MatchesRequest(r) {
handler.ServeRequest(w, r)
return
}
}
}
Interface Names
- Single-method interfaces should be named with
-ersuffix - Multi-method interfaces should have descriptive names
// Good: Single-method interface
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
// Good: Multi-method interface
type ReadWriter interface {
Read(p []byte) (n int, err error)
Write(p []byte) (n int, err error)
}
// Avoid: Overly generic names
type DataProcessor interface {
Process(data []byte) error
}
Code Organization
Package Structure
Organize code logically within packages.
myapp/
โโโ main.go # Entry point
โโโ config/
โ โโโ config.go # Configuration
โโโ storage/
โ โโโ storage.go # Interface
โ โโโ postgres.go # PostgreSQL implementation
โ โโโ memory.go # In-memory implementation
โโโ api/
โ โโโ handler.go # HTTP handlers
โ โโโ middleware.go # Middleware
โโโ service/
โโโ user.go # Business logic
File Organization
Keep related code together in files.
// Good: Logical grouping
package user
// Types
type User struct {
ID string
Name string
Email string
}
// Constructors
func NewUser(name, email string) *User {
return &User{
ID: uuid.New().String(),
Name: name,
Email: email,
}
}
// Methods
func (u *User) Validate() error {
if u.Name == "" {
return errors.New("name required")
}
return nil
}
// Helpers
func (u *User) DisplayName() string {
return fmt.Sprintf("%s <%s>", u.Name, u.Email)
}
Concurrency Best Practices
Use Goroutines Appropriately
Goroutines are lightweight, but don’t create unbounded numbers of them.
// Good: Bounded concurrency with worker pool
func processItems(items []Item) error {
workers := 10
jobs := make(chan Item, len(items))
errors := make(chan error, len(items))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
if err := process(job); err != nil {
errors <- err
}
}
}()
}
for _, item := range items {
jobs <- item
}
close(jobs)
wg.Wait()
close(errors)
for err := range errors {
if err != nil {
return err
}
}
return nil
}
// Avoid: Unbounded goroutines
func processItems(items []Item) error {
for _, item := range items {
go process(item) // Creates goroutine for each item!
}
return nil
}
Use Context for Cancellation
Always use context for timeout and cancellation.
// Good: Context-aware function
func fetchData(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// Usage
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
data, err := fetchData(ctx, "https://api.example.com/data")
Testing Best Practices
Table-Driven Tests
Use table-driven tests for comprehensive coverage.
// Good: Table-driven test
func TestValidateEmail(t *testing.T) {
tests := []struct {
name string
email string
wantErr bool
}{
{"valid email", "[email protected]", false},
{"missing @", "userexample.com", true},
{"empty string", "", true},
{"spaces", "user @example.com", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateEmail(tt.email)
if (err != nil) != tt.wantErr {
t.Errorf("ValidateEmail(%q) error = %v, wantErr %v",
tt.email, err, tt.wantErr)
}
})
}
}
Test Helpers
Create helper functions for common test operations.
// Good: Test helper
func TestUserCreation(t *testing.T) {
user := createTestUser(t, "John", "[email protected]")
if user.Name != "John" {
t.Errorf("expected name John, got %s", user.Name)
}
}
func createTestUser(t *testing.T, name, email string) *User {
t.Helper()
user := NewUser(name, email)
if err := user.Validate(); err != nil {
t.Fatalf("failed to create test user: %v", err)
}
return user
}
Performance Best Practices
Avoid Unnecessary Allocations
Be mindful of memory allocations in hot paths.
// Good: Reuse buffers
func processLargeFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
buf := make([]byte, 32*1024) // Reuse buffer
for {
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return err
}
if n == 0 {
break
}
if err := process(buf[:n]); err != nil {
return err
}
}
return nil
}
// Avoid: Allocating new buffer each iteration
func processLargeFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
for {
buf := make([]byte, 32*1024) // New allocation each iteration!
n, err := f.Read(buf)
if err != nil && err != io.EOF {
return err
}
if n == 0 {
break
}
if err := process(buf[:n]); err != nil {
return err
}
}
return nil
}
Use Pointers Wisely
Use pointers when necessary, but don’t over-use them.
// Good: Appropriate pointer usage
type Config struct {
Host string
Port int
}
func (c *Config) Validate() error {
if c.Host == "" {
return errors.New("host required")
}
return nil
}
// Avoid: Unnecessary pointers
type Config struct {
Host *string
Port *int
}
Documentation Best Practices
Write Clear Comments
Comments should explain why, not what.
// Good: Explains the why
// We use a buffer size of 32KB because it provides a good balance
// between memory usage and I/O performance for typical file sizes.
const bufferSize = 32 * 1024
// Avoid: Explains the what (obvious from code)
// Set buffer size to 32KB
const bufferSize = 32 * 1024
Document Exported Symbols
All exported functions, types, and constants should have documentation.
// Good: Complete documentation
// User represents a user in the system.
type User struct {
// ID is the unique identifier for the user.
ID string
// Name is the user's full name.
Name string
}
// NewUser creates a new User with the given name and email.
// It returns an error if the email is invalid.
func NewUser(name, email string) (*User, error) {
if !isValidEmail(email) {
return nil, fmt.Errorf("invalid email: %s", email)
}
return &User{
ID: uuid.New().String(),
Name: name,
}, nil
}
Dependency Management
Use Go Modules
Always use Go modules for dependency management.
# Initialize a module
go mod init github.com/username/project
# Add dependencies
go get github.com/some/package
# Update dependencies
go get -u ./...
# Tidy dependencies
go mod tidy
Minimize Dependencies
Keep dependencies minimal and well-maintained.
// Good: Minimal, focused dependencies
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
)
// Avoid: Excessive dependencies
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/some/heavy/framework"
"github.com/another/utility"
"github.com/yet/another/helper"
)
Common Anti-Patterns to Avoid
The Blank Identifier Abuse
Don’t ignore errors or values unnecessarily.
// Good: Handle errors appropriately
data, err := ioutil.ReadFile("file.txt")
if err != nil {
return err
}
// Avoid: Ignoring errors
data, _ := ioutil.ReadFile("file.txt")
Goroutine Leaks
Always ensure goroutines terminate.
// Good: Goroutine with proper termination
func (s *Server) Start(ctx context.Context) {
go func() {
<-ctx.Done()
s.shutdown()
}()
}
// Avoid: Goroutine leak
func (s *Server) Start() {
go func() {
for {
s.handleRequest()
}
}()
}
Defer in Loops
Be careful with defer in loops.
// Good: Defer outside loop
func processFiles(files []string) error {
for _, file := range files {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
if err := process(f); err != nil {
return err
}
}
return nil
}
// Better: Explicit cleanup in loop
func processFiles(files []string) error {
for _, file := range files {
if err := processFile(file); err != nil {
return err
}
}
return nil
}
func processFile(filename string) error {
f, err := os.Open(filename)
if err != nil {
return err
}
defer f.Close()
return process(f)
}
Summary
Go best practices emphasize:
- Simplicity: Write clear, explicit code
- Readability: Prioritize clarity for maintainers
- Error Handling: Always handle errors explicitly
- Naming: Use clear, idiomatic names
- Composition: Use composition over inheritance
- Concurrency: Use goroutines and channels wisely
- Testing: Write comprehensive, table-driven tests
- Documentation: Document exported symbols clearly
- Performance: Be mindful of allocations and efficiency
- Dependencies: Keep dependencies minimal and well-maintained
Following these practices will help you write idiomatic, maintainable Go code that’s easy for others to understand and maintain.
Comments