Go Best Practices
Introduction
Welcome to our guide on Go best practices! As you progress in your Go programming journey, it's important not just to write code that works, but code that follows the community's established patterns and practices. This guide will help you understand the idiomatic way to write Go code that is clean, efficient, and easily maintainable.
Go (or Golang) was designed with simplicity and readability in mind. Unlike many other languages, Go emphasizes convention over configuration and provides a standard formatting tool (gofmt
). This unique approach creates a more consistent ecosystem where Go code written by different developers follows similar patterns and styles.
In this guide, we'll explore the key best practices that will help you write better Go code, avoid common pitfalls, and leverage the language's strengths effectively.
Code Organization
Package Structure
One of the first decisions you'll make when starting a Go project is how to organize your code into packages. Here are some best practices:
- Package names should be simple and reflect their purpose
- One package per directory
- Use a domain-based project layout for larger applications
Here's a common Go project structure:
myproject/
├── cmd/ # Command applications
│ └── myapp/ # Main application
│ └── main.go # Entry point
├── internal/ # Private code
│ ├── auth/ # Authentication package
│ └── database/ # Database package
├── pkg/ # Public packages that can be imported
│ └── models/ # Data models
├── api/ # API definitions (protobuf, OpenAPI)
├── web/ # Web assets
├── configs/ # Configuration files
├── scripts/ # Build and utility scripts
├── go.mod # Module definition
└── go.sum # Dependencies checksum
Import Conventions
Organize your imports in the following groups with a blank line between each group:
- Standard library imports
- Third-party imports
- Local/internal packages
import (
"fmt"
"strings"
"github.com/pkg/errors"
"golang.org/x/text/language"
"myproject/internal/auth"
)
Code Style
Using gofmt
Always use gofmt
(or go fmt
) to format your code. This eliminates style debates and ensures consistency across the Go ecosystem.
# Format a single file
gofmt -w file.go
# Format all files in a directory and subdirectories
go fmt ./...
Naming Conventions
Go has specific naming conventions that differ from other languages:
- Use camelCase, not snake_case
- Acronyms should be all uppercase (e.g.,
HTTP
, notHttp
) - Short variable names for short scopes (e.g.,
i
for loop indices) - Exported names (capitalized) should have documentation comments
// Good
func ProcessHTTPRequest(w http.ResponseWriter, r *http.Request) {
userID := getUserID(r)
// ...
}
// Bad
func process_http_request(w http.ResponseWriter, r *http.Request) {
userId := get_user_id(r)
// ...
}
Error Handling
Go's error handling is explicit. Always check errors and provide context:
// Good error handling
file, err := os.Open("file.txt")
if err != nil {
return fmt.Errorf("opening file: %w", err)
}
defer file.Close()
// Bad - ignoring errors
file, _ := os.Open("file.txt") // Never ignore errors like this!
For error handling in larger applications, consider using packages like github.com/pkg/errors
for stack traces.
Performance Best Practices
Memory Management
Go is garbage collected, but you can still optimize memory usage:
- Reuse slices and maps instead of recreating them
- Pre-allocate slices when the size is known
- Use pointers judiciously (only when you need to share mutable state)
// Pre-allocating a slice of known size
data := make([]int, 0, 100) // Capacity of 100, length of 0
// Appending to the slice
for i := 0; i < 100; i++ {
data = append(data, i)
}
// This is more efficient than:
var data []int
for i := 0; i < 100; i++ {
data = append(data, i)
}
Concurrency Patterns
Go's goroutines and channels are powerful, but should be used with care:
- Always close channels when no more values will be sent
- Use
sync.WaitGroup
to wait for goroutines to finish - Consider using worker pools for bounded concurrency
Here's an example of a simple worker pool:
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
fmt.Printf("Worker %d processing job %d
", id, j)
// Simulate work
time.Sleep(time.Second)
results <- j * 2
}
}
func main() {
const numJobs = 10
const numWorkers = 3
jobs := make(chan int, numJobs)
results := make(chan int, numJobs)
// Start workers
var wg sync.WaitGroup
for w := 1; w <= numWorkers; w++ {
wg.Add(1)
go worker(w, jobs, results, &wg)
}
// Send jobs
for j := 1; j <= numJobs; j++ {
jobs <- j
}
close(jobs)
// Wait for all workers to finish
go func() {
wg.Wait()
close(results)
}()
// Collect results
for r := range results {
fmt.Println("Result:", r)
}
}
Output:
Worker 3 processing job 1
Worker 1 processing job 2
Worker 2 processing job 3
Result: 2
Worker 1 processing job 4
Result: 4
Worker 3 processing job 5
Result: 6
Worker 2 processing job 6
Result: 8
Worker 3 processing job 7
Result: 10
Worker 1 processing job 8
Result: 12
Worker 2 processing job 9
Result: 14
Worker 3 processing job 10
Result: 16
Result: 6
Result: 18
Result: 20
Avoid Premature Optimization
Remember Go's proverb: "Clear is better than clever." Write clear code first, then optimize if necessary using benchmarks.
// To run a benchmark
go test -bench=.
// An example benchmark function
func BenchmarkMyFunction(b *testing.B) {
for i := 0; i < b.N; i++ {
MyFunction()
}
}
Testing Best Practices
Go has a built-in testing framework that's simple but powerful. Here are some testing best practices:
Writing Table-Driven Tests
Use table-driven tests to test multiple cases efficiently:
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive", 2, 3, 5},
{"negative", -1, -2, -3},
{"mixed", -1, 5, 4},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := Add(tc.a, tc.b)
if got != tc.expected {
t.Errorf("Add(%d, %d) = %d; want %d", tc.a, tc.b, got, tc.expected)
}
})
}
}
Using Subtests
As shown above, use t.Run()
to create subtests. This allows for better organization and selective test running.
Testing HTTP Handlers
For web applications, use the httptest
package to test HTTP handlers:
func TestGreetingHandler(t *testing.T) {
req, err := http.NewRequest("GET", "/greeting?name=World", nil)
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler := http.HandlerFunc(GreetingHandler)
handler.ServeHTTP(rr, req)
// Check status code
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusOK)
}
// Check response body
expected := `{"message":"Hello, World!"}`
if rr.Body.String() != expected {
t.Errorf("handler returned unexpected body: got %v want %v", rr.Body.String(), expected)
}
}
Interface Design
Go's interfaces are implicitly implemented, encouraging a design focused on behavior rather than hierarchy.
Keep Interfaces Small
Go favors small, focused interfaces:
// Good - small, focused interface
type Reader interface {
Read(p []byte) (n int, err error)
}
// Less ideal - too many methods in one interface
type FileSystem interface {
Open(name string) (File, error)
Create(name string) (File, error)
Remove(name string) error
Rename(oldname, newname string) error
Mkdir(name string, perm os.FileMode) error
// ... more methods
}
Accept Interfaces, Return Structs
A common Go pattern is to accept interfaces and return concrete types:
// Good
func NewReader(r io.Reader) *MyReader {
return &MyReader{r: r}
}
// Not as flexible
func NewReader(r *os.File) *MyReader {
return &MyReader{r: r}
}
Dependency Management
Modern Go uses Go modules for dependency management:
# Initialize a new module
go mod init github.com/username/projectname
# Add a dependency
go get github.com/some/dependency
# Update dependencies
go get -u ./...
# Clean up unused dependencies
go mod tidy
Version Selection
Be explicit about dependency versions:
# Get a specific version
go get github.com/some/[email protected]
# Get the latest patch release of a minor version
go get github.com/some/[email protected]
Real-World Example: Building a REST API
Let's put these practices together in a simple REST API example:
package main
import (
"encoding/json"
"log"
"net/http"
"sync"
)
// Task represents a todo item
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Completed bool `json:"completed"`
}
// TaskStore manages a collection of tasks
type TaskStore struct {
sync.RWMutex
tasks map[int]Task
nextID int
}
// NewTaskStore creates a new task store
func NewTaskStore() *TaskStore {
return &TaskStore{
tasks: make(map[int]Task),
nextID: 1,
}
}
// GetTasks returns all tasks
func (ts *TaskStore) GetTasks() []Task {
ts.RLock()
defer ts.RUnlock()
tasks := make([]Task, 0, len(ts.tasks))
for _, task := range ts.tasks {
tasks = append(tasks, task)
}
return tasks
}
// AddTask adds a new task
func (ts *TaskStore) AddTask(title string) Task {
ts.Lock()
defer ts.Unlock()
task := Task{
ID: ts.nextID,
Title: title,
Completed: false,
}
ts.tasks[task.ID] = task
ts.nextID++
return task
}
// tasksHandler handles requests to /tasks
func tasksHandler(store *TaskStore) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
// Return all tasks
tasks := store.GetTasks()
data, err := json.Marshal(tasks)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(data)
case http.MethodPost:
// Add a new task
var req struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
task := store.AddTask(req.Title)
data, err := json.Marshal(task)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
w.Write(data)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
}
func main() {
store := NewTaskStore()
// Add some initial tasks
store.AddTask("Learn Go")
store.AddTask("Build a web service")
// Set up routes
http.HandleFunc("/tasks", tasksHandler(store))
// Start the server
log.Println("Server starting on :8080...")
if err := http.ListenAndServe(":8080", nil); err != nil {
log.Fatalf("Server failed to start: %v", err)
}
}
With this server running, you can:
- Get all tasks:
curl -X GET http://localhost:8080/tasks
- Add a task:
curl -X POST -H "Content-Type: application/json" -d '{"title":"New task"}' http://localhost:8080/tasks
Common Mistakes to Avoid
Let's look at some common mistakes in Go and how to avoid them:
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)