What is Go? The Language Google Built for Production Engineering
Go is a statically typed, compiled language built at Google in 2009. It compiles to a single binary with no runtime dependency, starts in milliseconds, and handles concurrency through goroutines and channels. Docker, Kubernetes, and Terraform are all written in Go. Here is why.
Google had a problem in 2007. Their codebases were enormous, their compile times were painfully long, and the languages engineers were choosing — C++, Java, Python — each made different tradeoffs that created friction at scale. C++ compiled slowly and had complex dependency management. Java had the JVM overhead and verbose boilerplate. Python was too slow for systems code. They wanted a language that was fast to compile, fast at runtime, easy to write correct concurrent code in, and simple enough that a large distributed team could maintain a shared codebase without fighting the language.
Robert Griesemer, Rob Pike, and Ken Thompson designed Go (also called Golang) at Google in 2007. It went public in 2009 and released version 1.0 in 2012. The Go 1.0 compatibility guarantee — that code written for Go 1.0 will still compile and run with every subsequent Go 1.x release — became one of its most distinguishing promises. Code written in 2012 compiles cleanly today. That guarantee has held for over a decade.
The most important software infrastructure of the last decade is written in Go. Docker is Go. Kubernetes is Go. Terraform is Go. Prometheus is Go. etcd is Go. CockroachDB is Go. Caddy is Go. InfluxDB is Go. The list goes on. These are not toy projects. They are the infrastructure that runs significant portions of the internet.
Understanding what Go is and why engineers keep choosing it for infrastructure and backend services requires understanding what problems Go was designed to solve and the tradeoffs it makes deliberately.
What Go Actually Is
Go is a statically typed, compiled, garbage-collected programming language with native concurrency primitives. The executable it produces is a single self-contained binary with no runtime dependency. You copy the binary to a server and run it. No JVM. No Python interpreter. No dependency installation. The binary is everything.
Go's design philosophy emphasizes simplicity and orthogonality. The language has roughly 25 keywords. It does not have classes, inheritance, operator overloading, generics until 1.18, or many features developers expect from modern languages. The initial reaction from developers used to more feature-rich languages is often "why is this so limited?" The Go team's answer: because simple is easier to maintain at scale across a large engineering organization, and the missing features are either unnecessary or solvable through composition.
The compiler is fast. A large Go project compiles in seconds. Rebuilding Docker takes under a minute on modern hardware. The fast compilation cycle is not accidental — it was one of the primary design goals because slow compile times kill developer productivity at Google's scale.
The Syntax
package main
import (
"fmt"
"strings"
"strconv"
"errors"
)
type User struct {
ID int
Username string
Email string
Level int
IsActive bool
}
func (u User) DisplayName() string {
return fmt.Sprintf("%s (Level %d)", u.Username, u.Level)
}
func (u *User) AwardXP(amount int) error {
if amount <= 0 {
return errors.New("XP amount must be positive")
}
if !u.IsActive {
return fmt.Errorf("user %s is not active", u.Username)
}
u.Level += amount / 1000
return nil
}
func NewUser(id int, username, email string) (*User, error) {
if strings.TrimSpace(username) == "" {
return nil, errors.New("username cannot be empty")
}
if !strings.Contains(email, "@") {
return nil, fmt.Errorf("invalid email address: %s", email)
}
return &User{
ID: id,
Username: strings.TrimSpace(username),
Email: strings.ToLower(email),
Level: 1,
IsActive: true,
}, nil
}
func main() {
user, err := NewUser(1, "traven", "[email protected]")
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println(user.DisplayName())
if err := user.AwardXP(5000); err != nil {
fmt.Println("XP error:", err)
}
fmt.Println(user.DisplayName())
fmt.Println("ID:", strconv.Itoa(user.ID))
}
Go uses struct instead of class. Methods are defined on types using receiver syntax (func (u User) DisplayName() for value receivers, func (u *User) AwardXP() for pointer receivers). Value receivers work on copies. Pointer receivers work on the original and can modify it.
Error handling in Go is explicit. Functions that can fail return an error as their last return value. Callers check if err != nil. There are no exceptions. This design forces you to handle errors at each call site rather than letting them propagate silently. Critics find the pattern verbose. Proponents argue it makes control flow explicit and makes the boundary between "this worked" and "this failed" impossible to accidentally ignore.
Multiple return values are a language feature. func NewUser(...) (*User, error) returns either a valid User pointer or an error, never both (by convention, though the language permits it). The _ blank identifier discards return values you do not need.
Goroutines and Channels: The Concurrency Model
Goroutines are lightweight threads managed by the Go runtime. Creating a goroutine requires writing go before a function call. The Go runtime schedules goroutines across OS threads, multiplexing many goroutines onto fewer OS threads efficiently.
package main
import (
"fmt"
"sync"
"time"
)
func fetchUser(id int, wg *sync.WaitGroup, results chan<- string) {
defer wg.Done()
time.Sleep(50 * time.Millisecond)
results <- fmt.Sprintf("user:%d", id)
}
func main() {
userIDs := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
results := make(chan string, len(userIDs))
var wg sync.WaitGroup
for _, id := range userIDs {
wg.Add(1)
go fetchUser(id, &wg, results)
}
go func() {
wg.Wait()
close(results)
}()
for result := range results {
fmt.Println("Got:", result)
}
fmt.Println("All done")
}
make(chan string, len(userIDs)) creates a buffered channel. Sends to a buffered channel do not block until the buffer is full. chan<- string is a send-only channel. <-chan string would be receive-only. This directional typing catches accidental direction errors at compile time.
defer wg.Done() runs when the function returns, decrementing the WaitGroup counter. wg.Wait() blocks until the counter reaches zero. The goroutine that calls wg.Wait() then closes the channel. for result := range results receives from the channel until it is closed, then exits. This is the standard Go pattern for fan-out (many goroutines doing work) and fan-in (collecting results on one channel).
A goroutine has a starting stack size of 2KB (compared to 1-8MB for OS threads). The Go runtime grows goroutine stacks as needed. You can comfortably run tens of thousands of goroutines on a single server where you could only run a few thousand OS threads.
Channels for Communication
Channels are typed conduits. Goroutines communicate by sending values through channels rather than sharing memory. The Go philosophy: "Do not communicate by sharing memory; share memory by communicating."
package main
import (
"context"
"fmt"
"time"
)
func producer(ctx context.Context, out chan<- int) {
defer close(out)
for i := 0; ; i++ {
select {
case <-ctx.Done():
fmt.Println("Producer stopped")
return
case out <- i:
time.Sleep(100 * time.Millisecond)
}
}
}
func processor(in <-chan int, out chan<- string) {
defer close(out)
for val := range in {
out <- fmt.Sprintf("processed: %d", val*2)
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
numbers := make(chan int, 10)
processed := make(chan string, 10)
go producer(ctx, numbers)
go processor(numbers, processed)
for result := range processed {
fmt.Println(result)
}
}
The select statement waits on multiple channel operations, executing whichever one is ready. case <-ctx.Done() receives from the context's Done channel when the context is cancelled or timed out. This is Go's cancellation pattern: pass a context.Context through your call stack, check ctx.Done() at blocking points.
context.WithTimeout creates a context that cancels after 500 milliseconds. When it cancels, ctx.Done() becomes readable. The producer's select picks that case and returns. Closing numbers causes processor's for range to exit. Closing processed causes main's for range to exit. Clean shutdown propagates through the pipeline.
Building a Production HTTP API
Go's standard library net/http is capable enough for production without a framework. The popular router chi adds path parameters and middleware without significant overhead.
package main
import (
"context"
"encoding/json"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Price float64 `json:"price"`
Stock int `json:"stock"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type CreateProductRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
Stock int `json:"stock"`
CategoryID int `json:"category_id"`
}
func (r CreateProductRequest) Validate() error {
if r.Name == "" {
return errors.New("name is required")
}
if r.Price <= 0 {
return errors.New("price must be positive")
}
if r.Stock < 0 {
return errors.New("stock cannot be negative")
}
return nil
}
type APIResponse[T any] struct {
Data T `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, APIResponse[any]{Error: msg})
}
type ProductHandler struct {
logger *slog.Logger
}
func (h *ProductHandler) List(w http.ResponseWriter, r *http.Request) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
perPage, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
if perPage < 1 || perPage > 100 {
perPage = 20
}
products := []Product{
{ID: 1, Name: "Wireless Headphones", Slug: "wireless-headphones",
Price: 79.99, Stock: 42, Status: "active", CreatedAt: time.Now()},
}
writeJSON(w, http.StatusOK, map[string]any{
"data": products,
"pagination": map[string]int{
"page": page, "per_page": perPage, "total": len(products),
},
})
}
func (h *ProductHandler) Get(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug")
if slug == "" {
writeError(w, http.StatusBadRequest, "slug is required")
return
}
product := Product{
ID: 1, Name: "Wireless Headphones", Slug: slug,
Price: 79.99, Stock: 42, Status: "active", CreatedAt: time.Now(),
}
writeJSON(w, http.StatusOK, APIResponse[Product]{Data: product})
}
func (h *ProductHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateProductRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
return
}
if err := req.Validate(); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
h.logger.Info("creating product", "name", req.Name, "price", req.Price)
product := Product{
ID: 100, Name: req.Name, Slug: "generated-slug",
Price: req.Price, Stock: req.Stock, Status: "draft", CreatedAt: time.Now(),
}
writeJSON(w, http.StatusCreated, APIResponse[Product]{Data: product})
}
func requestLogger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
next.ServeHTTP(ww, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", ww.Status(),
"duration", time.Since(start),
"bytes", ww.BytesWritten(),
)
})
}
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
productHandler := &ProductHandler{logger: logger}
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(requestLogger(logger))
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(30 * time.Second))
r.Get("/health/live", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "alive"})
})
r.Route("/api/v1", func(r chi.Router) {
r.Route("/products", func(r chi.Router) {
r.Get("/", productHandler.List)
r.Post("/", productHandler.Create)
r.Get("/{slug}", productHandler.Get)
})
})
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
logger.Info("server starting", "addr", srv.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Error("server error", "err", err)
os.Exit(1)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
<-quit
logger.Info("shutting down server")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
logger.Error("shutdown error", "err", err)
}
logger.Info("server stopped")
}
slog is Go 1.21's structured logging package. Before 1.21, the ecosystem used zap (Uber) or zerolog. slog is now the standard. NewJSONHandler produces structured JSON logs. Every logger.Info("request", "method", r.Method, ...) call produces a JSON log entry with those key-value pairs.
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT) registers the channel to receive OS signals. <-quit blocks until a signal arrives. srv.Shutdown(ctx) drains in-flight requests and closes the listener cleanly. This is proper graceful shutdown for a Go HTTP server.
Working with Databases
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
_ "github.com/lib/pq"
)
type ProductRepository struct {
db *sql.DB
}
func NewProductRepository(dsn string) (*ProductRepository, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, fmt.Errorf("opening database: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(25)
db.SetConnMaxLifetime(5 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
return nil, fmt.Errorf("pinging database: %w", err)
}
return &ProductRepository{db: db}, nil
}
type Product struct {
ID int
Name string
Slug string
Price float64
Stock int
Status string
CategoryName string
CreatedAt time.Time
}
var ErrNotFound = errors.New("not found")
func (r *ProductRepository) GetBySlug(ctx context.Context, slug string) (*Product, error) {
query := `
SELECT p.id, p.name, p.slug, p.price, p.stock, p.status,
c.name AS category_name, p.created_at
FROM products p
JOIN categories c ON c.id = p.category_id
WHERE p.slug = $1 AND p.status = 'active'
`
var p Product
err := r.db.QueryRowContext(ctx, query, slug).Scan(
&p.ID, &p.Name, &p.Slug, &p.Price, &p.Stock,
&p.Status, &p.CategoryName, &p.CreatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("querying product by slug %s: %w", slug, err)
}
return &p, nil
}
func (r *ProductRepository) CreateOrder(ctx context.Context, userID int, items []OrderItem) (int, error) {
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return 0, fmt.Errorf("beginning transaction: %w", err)
}
defer tx.Rollback()
for _, item := range items {
var stock int
err := tx.QueryRowContext(ctx,
"SELECT stock FROM products WHERE id = $1 FOR UPDATE",
item.ProductID,
).Scan(&stock)
if err != nil {
return 0, fmt.Errorf("checking stock for product %d: %w", item.ProductID, err)
}
if stock < item.Quantity {
return 0, fmt.Errorf("insufficient stock for product %d: have %d, need %d",
item.ProductID, stock, item.Quantity)
}
}
var orderID int
err = tx.QueryRowContext(ctx,
`INSERT INTO orders (user_id, status, total_amount, created_at)
VALUES ($1, 'pending', 0, NOW())
RETURNING id`,
userID,
).Scan(&orderID)
if err != nil {
return 0, fmt.Errorf("creating order: %w", err)
}
var total float64
for _, item := range items {
var price float64
tx.QueryRowContext(ctx, "SELECT price FROM products WHERE id = $1", item.ProductID).Scan(&price)
total += price * float64(item.Quantity)
_, err = tx.ExecContext(ctx,
`INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES ($1, $2, $3, $4)`,
orderID, item.ProductID, item.Quantity, price,
)
if err != nil {
return 0, fmt.Errorf("inserting order item: %w", err)
}
res, err := tx.ExecContext(ctx,
"UPDATE products SET stock = stock - $1 WHERE id = $2 AND stock >= $1",
item.Quantity, item.ProductID,
)
if err != nil {
return 0, fmt.Errorf("decrementing stock: %w", err)
}
affected, _ := res.RowsAffected()
if affected == 0 {
return 0, fmt.Errorf("stock depleted for product %d during transaction", item.ProductID)
}
}
_, err = tx.ExecContext(ctx, "UPDATE orders SET total_amount = $1 WHERE id = $2", total, orderID)
if err != nil {
return 0, fmt.Errorf("updating order total: %w", err)
}
if err := tx.Commit(); err != nil {
return 0, fmt.Errorf("committing transaction: %w", err)
}
return orderID, nil
}
type OrderItem struct {
ProductID int
Quantity int
}
defer tx.Rollback() at the top of the transaction function is the correct Go pattern. If tx.Commit() succeeds, the subsequent Rollback() is a no-op. If any error occurs before Commit(), the deferred Rollback() cleans up automatically. No cleanup code in error paths.
fmt.Errorf("querying product by slug %s: %w", slug, err) wraps the error with context using %w. errors.Is(err, sql.ErrNoRows) still works through the wrapper. The error message carries the context: which query failed and on what input. Production error logs tell you exactly what happened.
Interfaces and Composition
Go does not have inheritance. It has interfaces and composition. Any type that implements an interface's methods satisfies the interface automatically — no explicit declaration.
package main
import (
"context"
"fmt"
"time"
)
type Cache interface {
Get(ctx context.Context, key string) ([]byte, error)
Set(ctx context.Context, key string, value []byte, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}
type ProductStore interface {
GetBySlug(ctx context.Context, slug string) (*Product, error)
List(ctx context.Context, page, perPage int) ([]Product, error)
Create(ctx context.Context, req CreateProductRequest) (*Product, error)
}
type CachedProductStore struct {
store ProductStore
cache Cache
ttl time.Duration
}
func NewCachedProductStore(store ProductStore, cache Cache, ttl time.Duration) *CachedProductStore {
return &CachedProductStore{store: store, cache: cache, ttl: ttl}
}
func (s *CachedProductStore) GetBySlug(ctx context.Context, slug string) (*Product, error) {
key := "product:" + slug
cached, err := s.cache.Get(ctx, key)
if err == nil && cached != nil {
var p Product
if err := json.Unmarshal(cached, &p); err == nil {
return &p, nil
}
}
product, err := s.store.GetBySlug(ctx, slug)
if err != nil {
return nil, err
}
if data, err := json.Marshal(product); err == nil {
s.cache.Set(ctx, key, data, s.ttl)
}
return product, nil
}
CachedProductStore wraps ProductStore with caching. It satisfies ProductStore because it implements the same methods. Code that depends on ProductStore works with either the raw repository or the cached version without any changes. This is dependency injection through interfaces — the idiomatic Go approach to testable, composable code.
Testing the cached store: pass in a mock ProductStore and a mock Cache. No database required. No Redis required. Pure unit tests.
Go Modules and Tooling
go mod init github.com/company/myapp
go get github.com/go-chi/chi/v5@latest
go get github.com/lib/pq@latest
go get -u ./...
go mod tidy
go build ./...
go build -o bin/server ./cmd/server
go test ./...
go test -v -run TestProductHandler ./internal/handlers/...
go test -race ./...
go test -bench=. -benchmem ./...
go vet ./...
staticcheck ./...
golangci-lint run
go tool pprof cpu.prof
go tool trace trace.out
go mod tidy removes unused dependencies and adds missing ones. go test -race enables the race detector, which instruments the binary to detect data races at runtime. The race detector adds overhead but catches concurrent bugs that are nearly impossible to find any other way. Run it in CI even if not in production.
golangci-lint runs dozens of linters simultaneously. It is the standard Go linting tool and catches everything from style issues to subtle bugs.
go tool pprof analyzes CPU and memory profiles. Go's built-in profiling support (net/http/pprof) lets you take profiles of a running production server without restarting it.
Why Engineers Choose Go
The single binary deployment model is Go's most practical advantage. No runtime installation on production servers. No "which Python version?" No library conflicts. Copy the binary, run it. Container images for Go services are 10-50MB versus 300MB+ for Node.js or JVM services.
The concurrency model handles the kind of I/O concurrency that modern backend services need. Goroutines are cheaper than OS threads. Channels prevent shared-memory bugs. The standard library's net/http handles concurrent HTTP connections efficiently without a separate event loop model.
The performance lands between interpreted languages (Python, JavaScript) and systems languages (Rust, C). For most backend services — API servers, data pipelines, proxies, CLI tools — Go's performance is more than sufficient, and the development speed is faster than Rust.
The standard library covers most use cases without third-party dependencies. HTTP server. JSON encoding. SQL database access. TLS. Cryptography. File I/O. Testing. Benchmarking. Profiling. The standard library is comprehensive, well-documented, and stable across Go versions.
Go does not suit every problem. For numerical computation and machine learning, Python's ecosystem is dominant. For frontend web development, JavaScript/TypeScript is the obvious choice. For memory-safety-critical systems programming or WebAssembly, Rust has capabilities Go lacks. For building on the JVM ecosystem, Java or Kotlin.
For backend services, DevOps tooling, CLI applications, and network services where you need performance, a single binary, and fast development: Go is one of the most pragmatic choices available in 2025.
Error Handling Patterns
Go's explicit error handling is the feature that generates the most discussion from developers coming from exception-based languages. After a few months writing Go, most developers stop viewing it as verbose and start viewing it as clear.
package main
import (
"errors"
"fmt"
"net/http"
)
var (
ErrNotFound = errors.New("not found")
ErrForbidden = errors.New("forbidden")
ErrValidation = errors.New("validation failed")
)
type AppError struct {
Code string
Message string
Cause error
Status int
}
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return e.Message
}
func (e *AppError) Unwrap() error {
return e.Cause
}
func NewNotFoundError(resource string, id any) *AppError {
return &AppError{
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s with ID %v not found", resource, id),
Status: http.StatusNotFound,
}
}
func NewValidationError(msg string) *AppError {
return &AppError{
Code: "VALIDATION_ERROR",
Message: msg,
Status: http.StatusBadRequest,
}
}
func processOrder(orderID int) error {
order, err := fetchOrder(orderID)
if err != nil {
return fmt.Errorf("fetching order %d: %w", orderID, err)
}
if err := validateOrder(order); err != nil {
return fmt.Errorf("validating order %d: %w", orderID, err)
}
return nil
}
func fetchOrder(id int) (map[string]any, error) {
if id == 0 {
return nil, NewNotFoundError("Order", id)
}
return map[string]any{"id": id, "status": "pending"}, nil
}
func validateOrder(order map[string]any) error {
if order["status"] == "cancelled" {
return NewValidationError("cannot process cancelled order")
}
return nil
}
func main() {
err := processOrder(0)
if err != nil {
var appErr *AppError
if errors.As(err, &appErr) {
fmt.Printf("App error [%s]: %s (HTTP %d)\n", appErr.Code, appErr.Message, appErr.Status)
} else {
fmt.Println("Unexpected error:", err)
}
}
}
errors.As unwraps error chains looking for a specific type. fmt.Errorf("context: %w", err) wraps an error with context while preserving the original for unwrapping. This lets you add context at each layer without losing the ability to check the root error type higher in the call stack.
Custom error types with HTTP status codes and error codes give your API handlers everything they need to produce the right response without if-else chains checking error messages.
Testing in Go
Go's standard library includes a testing framework. No third-party test library is required.
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/company/myapp/internal/handlers"
)
func TestListProducts(t *testing.T) {
t.Run("returns paginated results", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/products?page=1&per_page=10", nil)
w := httptest.NewRecorder()
handler := handlers.NewProductHandler(mockStore{})
handler.List(w, req)
res := w.Result()
if res.StatusCode != http.StatusOK {
t.Errorf("expected status 200, got %d", res.StatusCode)
}
var body map[string]any
json.NewDecoder(res.Body).Decode(&body)
if _, ok := body["data"]; !ok {
t.Error("response missing 'data' key")
}
if _, ok := body["pagination"]; !ok {
t.Error("response missing 'pagination' key")
}
})
t.Run("returns 400 for invalid page number", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/products?page=-1", nil)
w := httptest.NewRecorder()
handler := handlers.NewProductHandler(mockStore{})
handler.List(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d", w.Code)
}
})
}
type mockStore struct{}
func (m mockStore) List(ctx context.Context, page, perPage int) ([]Product, error) {
return []Product{{ID: 1, Name: "Test", Slug: "test", Price: 9.99}}, nil
}
func TestProcessOrder(t *testing.T) {
tests := []struct{
name string
orderID int
wantErr bool
errCode string
}{
{"valid order", 42, false, ""},
{"zero ID returns not found", 0, true, "NOT_FOUND"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := processOrder(tc.orderID)
if (err != nil) != tc.wantErr {
t.Errorf("processOrder(%d) error = %v, wantErr %v", tc.orderID, err, tc.wantErr)
}
if tc.wantErr && tc.errCode != "" {
var appErr *AppError
if !errors.As(err, &appErr) {
t.Errorf("expected AppError, got %T", err)
} else if appErr.Code != tc.errCode {
t.Errorf("expected error code %s, got %s", tc.errCode, appErr.Code)
}
}
})
}
}
httptest.NewRecorder() and httptest.NewRequest() let you test HTTP handlers without starting an actual server. Table-driven tests (the tests slice with subtests) are the idiomatic Go way to test multiple inputs and expected outputs. t.Run creates subtests with distinct names, making failures easy to identify.
Go test files end in _test.go. The _test package suffix (e.g., handlers_test) tests the public API of the package. Removing the suffix tests package internals. Both approaches are valid; testing the public interface is generally preferred.
Generics in Go
Go added generics in version 1.18 (2022). The syntax is simpler than Rust or Haskell generics.
package main
import "fmt"
func Map[T, U any](slice []T, f func(T) U) []U {
result := make([]U, len(slice))
for i, v := range slice {
result[i] = f(v)
}
return result
}
func Filter[T any](slice []T, predicate func(T) bool) []T {
var result []T
for _, v := range slice {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func Reduce[T, U any](slice []T, initial U, f func(U, T) U) U {
acc := initial
for _, v := range slice {
acc = f(acc, v)
}
return acc
}
type Number interface {
~int | ~int32 | ~int64 | ~float32 | ~float64
}
func Sum[T Number](nums []T) T {
var total T
for _, n := range nums {
total += n
}
return total
}
func main() {
nums := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
doubled := Map(nums, func(n int) int { return n * 2 })
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
total := Reduce(nums, 0, func(acc, n int) int { return acc + n })
sum := Sum(nums)
fmt.Println("Doubled:", doubled)
fmt.Println("Evens:", evens)
fmt.Println("Total:", total)
fmt.Println("Sum:", sum)
strs := []string{"hello", "world", "go"}
upper := Map(strs, strings.ToUpper)
long := Filter(strs, func(s string) bool { return len(s) > 4 })
joined := Reduce(strs, "", func(acc, s string) string {
if acc == "" { return s }
return acc + ", " + s
})
fmt.Println("Upper:", upper)
fmt.Println("Long:", long)
fmt.Println("Joined:", joined)
}
The ~int syntax in the Number constraint means "any type whose underlying type is int." This covers type MyInt int custom types in addition to the plain int. The constraint system lets you express type-level requirements precisely.
Go generics deliberately avoid the complexity of full dependent types or higher-kinded types that languages like Haskell support. The result is a generics system that covers 95% of practical use cases with a fraction of the learning curve.
Concurrency Patterns in Production
Real Go services handle concurrency constantly. Understanding which patterns apply where prevents subtle bugs.
Worker Pools
When you have more work than goroutines should run simultaneously, a worker pool bounds the concurrency:
package main
import (
"context"
"fmt"
"sync"
"time"
)
type Job struct {
ID int
Payload string
}
type Result struct {
JobID int
Output string
Err error
Elapsed time.Duration
}
func worker(ctx context.Context, id int, jobs <-chan Job, results chan<- Result, wg *sync.WaitGroup) {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case job, ok := <-jobs:
if !ok {
return
}
start := time.Now()
time.Sleep(50 * time.Millisecond)
results <- Result{
JobID: job.ID,
Output: fmt.Sprintf("processed:%s", job.Payload),
Elapsed: time.Since(start),
}
}
}
}
func runWorkerPool(ctx context.Context, numWorkers int, jobList []Job) []Result {
jobs := make(chan Job, len(jobList))
results := make(chan Result, len(jobList))
var wg sync.WaitGroup
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go worker(ctx, i, jobs, results, &wg)
}
for _, job := range jobList {
jobs <- job
}
close(jobs)
go func() {
wg.Wait()
close(results)
}()
var collected []Result
for r := range results {
collected = append(collected, r)
}
return collected
}
func main() {
ctx := context.Background()
jobs := make([]Job, 100)
for i := range jobs {
jobs[i] = Job{ID: i, Payload: fmt.Sprintf("item-%d", i)}
}
start := time.Now()
results := runWorkerPool(ctx, 10, jobs)
fmt.Printf("Processed %d jobs in %v with 10 workers\n", len(results), time.Since(start))
}
100 jobs taking 50ms each with 10 workers completes in ~500ms (100 jobs / 10 workers * 50ms). Without a pool, spawning 100 goroutines all at once completes in ~50ms but you cannot bound the resource usage. Worker pools are the pattern when you need predictable, bounded concurrency.
Context Cancellation Propagation
package main
import (
"context"
"fmt"
"net/http"
"time"
)
func fetchWithTimeout(ctx context.Context, url string) (int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return 0, err
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()
return resp.StatusCode, nil
}
func handleAPIRequest(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
type fetchResult struct {
status int
err error
source string
}
results := make(chan fetchResult, 2)
go func() {
status, err := fetchWithTimeout(ctx, "https://api.service-a.internal/data")
results <- fetchResult{status, err, "service-a"}
}()
go func() {
status, err := fetchWithTimeout(ctx, "https://api.service-b.internal/data")
results <- fetchResult{status, err, "service-b"}
}()
for i := 0; i < 2; i++ {
r := <-results
if r.err != nil {
http.Error(w, fmt.Sprintf("%s failed: %v", r.source, r.err), http.StatusBadGateway)
return
}
fmt.Fprintf(w, "%s: %d\n", r.source, r.status)
}
}
http.NewRequestWithContext(ctx, ...) attaches the context to the outbound HTTP request. If the context times out or is cancelled (because the client disconnected, or the parent handler timed out), the in-flight HTTP request is cancelled automatically. This prevents goroutines from hanging indefinitely waiting for slow downstream services after the client has already given up.
r.Context() in an HTTP handler returns a context that is cancelled when the client closes the connection. Passing it to all downstream calls means work stops when the client stops caring. Without context propagation, your server keeps doing work for requests that will never be delivered.
Go Modules: Dependency Management
go mod init github.com/company/myapp
cat go.mod
cat go.sum
go get github.com/go-chi/chi/[email protected]
go get github.com/jackc/pgx/v5@latest
go mod tidy
go mod download
go mod verify
go mod graph | head -20
go list -m all
GONOSUMCHECK=* GOFLAGS=-mod=mod go get ./...
go.sum contains cryptographic hashes of every module version your project depends on, transitively. The Go toolchain verifies these hashes on every build, preventing supply chain attacks where a module is silently replaced with malicious code after your go.sum was generated.
go mod tidy removes any imports in your go.mod that no file in your project actually uses and adds any missing dependencies. Run it before committing. A go.mod with unused dependencies is either a bug (you forgot to remove an import) or technical debt.
replace directives in go.mod redirect a module to a local path, useful when developing two modules simultaneously:
replace github.com/company/shared-lib => ../shared-lib
go work (workspace mode, Go 1.18+) handles multi-module development without replace directives:
go work init
go work use ./myapp
go work use ../shared-lib
Go workspaces let you develop across multiple modules and have changes in one immediately visible in others, without publishing intermediate versions.
Performance: Benchmarking and Profiling
Go includes built-in benchmarking and profiling tools that are part of the standard toolchain.
package main
import (
"strings"
"testing"
)
func joinWithBuilder(parts []string) string {
var sb strings.Builder
for i, part := range parts {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(part)
}
return sb.String()
}
func joinWithConcat(parts []string) string {
result := ""
for i, part := range parts {
if i > 0 {
result += ", "
}
result += part
}
return result
}
func BenchmarkJoinBuilder(b *testing.B) {
parts := []string{"one", "two", "three", "four", "five"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
joinWithBuilder(parts)
}
}
func BenchmarkJoinConcat(b *testing.B) {
parts := []string{"one", "two", "three", "four", "five"}
b.ResetTimer()
for i := 0; i < b.N; i++ {
joinWithConcat(parts)
}
}
go test -bench=. -benchmem ./...
go test -bench=BenchmarkJoinBuilder -benchtime=5s ./...
go test -cpuprofile=cpu.prof -memprofile=mem.prof -bench=. ./...
go tool pprof cpu.prof
go tool pprof -http=:8080 cpu.prof
-benchmem shows memory allocations per operation alongside timing. Reducing allocations is often the path to better Go performance. The strings.Builder approach avoids the quadratic allocation behavior of string concatenation.
pprof generates flame graphs showing which functions consume CPU time. The web interface (-http=:8080) is far more usable than the terminal interface. For production profiling, add net/http/pprof to your server and trigger profiles on demand without restarting.
Go is explicit about its performance characteristics. The garbage collector adds latency. Memory allocation has cost. Goroutine scheduling adds overhead compared to raw OS threads. But for API servers, CLI tools, and infrastructure software, Go's performance is more than adequate and its development speed and operational simplicity frequently outweigh the performance gap with lower-level languages.