Why Go for Backend?
✓ HTTP servers & microservices
✓ CLI tools & DevOps
✓ Data pipelines
✓ gRPC services
✓ High-concurrency systems
✓ Single binary deploy
✓ Native concurrency (goroutines)
✓ ~10x faster than Python
✓ ~3x less memory than JVM
✓ No runtime dependencies
.go file, and run with go run . after go mod init (see Modules & toolchain under Basics). Run tests with go test ./... and use go test -race when you add goroutines.Basics & Types
Variables & Declaration
var, :=, constants, zero values
Go has two ways to declare variables. var for package-level and explicit typing. := (short declaration) inside functions for type inference. Uninitialized variables get their zero value automatically — no undefined behavior.
| Type | Zero value | Example |
|---|---|---|
| int, int64 | 0 | var n int → 0 |
| float64 | 0.0 | var f float64 → 0.0 |
| string | "" | var s string → "" |
| bool | false | var b bool → false |
| pointer | nil | var p *int → nil |
| slice, map | nil | var m map[string]int → nil |
package main // Package-level: must use var var serverPort int = 8080 var serviceName string // zero value = "" // Constant — cannot be changed const MaxRetries = 3 func main() { // Short declaration — type inferred host := "localhost" port := 5432 // Multiple assignment x, y := 10, 20 // Explicit type (useful for clarity) var timeout int64 = 30 // Blank identifier — discard value result, _ := someFunction() }
:= inside functions (most common). Use var at package level or when you need an explicit type. Never use var for everything — that's not idiomatic Go.Modules, packages & toolchain
go mod init, imports, go run / go build / go test
Since Go 1.16+, modules are the standard way to declare dependencies. A module is a tree of packages with a go.mod file at the root; the first line is your module path (often your repo URL). A package is a directory of .go files with the same package clause; package main with a func main() builds an executable.
mkdir hello && cd hello go mod init example.com/hello # go.mod created — edit module path to match your VCS URL for libraries go get github.com/google/uuid@latest # add a dependency go mod tidy # drop unused, add missing go run . # compile and run package in current dir go build -o app . # binary named app go test ./... # all packages under module
// cmd/server/main.go — thin entrypoint package main import "example.com/hello/internal/api" func main() { api.Listen(":8080") } // internal/api — cannot be imported by other modules (enforced by compiler) // pkg/ — public library surface if you publish the module
internal/ for code that must not become an external API. For application code, many teams use cmd/<name>/main.go plus internal/... and skip pkg/ until they actually publish a library.Slices & Maps
Go's primary collection types
Slices are Go's dynamic arrays — they reference an underlying array with a pointer, length, and capacity. Maps are unordered key-value stores backed by a hash table. Both are reference types.
// Create slice: nil, empty, or with values var ids []int // nil slice names := []string{} // empty, non-nil scores := []int{95, 87, 92} // with values // make(type, len, cap) — pre-allocate for performance results := make([]string, 0, 100) // cap 100, avoids re-alloc // Append (may allocate new backing array) ids = append(ids, 1, 2, 3) // Slice of slice: [low:high] — shares backing array! first := scores[0:2] // [95, 87] — NOT a copy // Range loop — i=index, v=value for i, v := range scores { fmt.Printf("[%d] = %d\n", i, v) }
// Must initialize before use (nil map panics on write) cache := make(map[string]int) config := map[string]string{ "host": "localhost", "port": "5432", } // Read: always use two-value form to check existence val, ok := config["host"] // ok = false if missing if !ok { // key not found — handle it } // Write and delete cache["user:42"] = 1 delete(cache, "user:42") // Iterate (order is random) for k, v := range config { fmt.Printf("%s = %s\n", k, v) }
sync.Map or a mutex-protected map when multiple goroutines read/write. This is a common source of race conditions.Functions
Functions, Defer & Closures
Multiple returns, defer, first-class functions
Go functions can return multiple values — the idiomatic pattern is (result, error). Defer schedules a function call to run when the surrounding function returns — essential for cleanup. Functions are first-class values.
// Multiple return values — idiomatic (result, error) func fetchUser(id int) (*User, error) { if id <= 0 { return nil, fmt.Errorf("invalid id: %d", id) } return &User{ID: id}, nil } // Defer: runs LIFO when function returns // — cleanup, unlock, close file func readFile(path string) (string, error) { f, err := os.Open(path) if err != nil { return "", err } defer f.Close() // always closes, even on panic // ... read file ... } // Named return values + defer for cleanup pattern func withTx(db *sql.DB, fn func(*sql.Tx) error) (err error) { tx, _ := db.Begin() defer func() { if err != nil { tx.Rollback() } else { tx.Commit() } }() err = fn(tx) return } // Closures capture variables from outer scope func counter() func() int { n := 0 return func() int { n++ return n } }
Structs & Interfaces
Structs, Methods & Interfaces
Go's approach to object-oriented design
Go has no classes. You compose behavior with structs + methods + interfaces. Interfaces are satisfied implicitly — any type with the right method set implements the interface. No implements keyword.
// Struct definition type User struct { ID int Name string Email string age int // unexported (lowercase) = package-private } // Pointer receiver: modifies the struct + avoids copy func (u *User) SetAge(age int) { u.age = age } // Value receiver: read-only, gets a copy func (u User) DisplayName() string { return fmt.Sprintf("%s <%s>", u.Name, u.Email) } // Interface — implicit satisfaction type UserStore interface { Get(id int) (*User, error) Save(u *User) error Delete(id int) error } // PostgresStore implicitly satisfies UserStore type PostgresStore struct { db *sql.DB } func (s *PostgresStore) Get(id int) (*User, error) { /* ... */ } func (s *PostgresStore) Save(u *User) error { /* ... */ } func (s *PostgresStore) Delete(id int) error { /* ... */ } // Accept interface, not concrete type → easy to mock in tests func NewUserService(store UserStore) *UserService { return &UserService{store: store} }
Goroutines
Goroutines — Lightweight Concurrency
go keyword, WaitGroup, fan-out patterns
Goroutines are Go's lightweight threads — they start with ~2KB of stack (vs ~1MB for OS threads) and are multiplexed onto OS threads by the Go scheduler. You can run millions of goroutines simultaneously.
All blue boxes run concurrently. Main waits for all to finish via WaitGroup.
import "sync" func processItems(items []Item) []Result { var wg sync.WaitGroup results := make([]Result, len(items)) for i, item := range items { wg.Add(1) go func(idx int, it Item) { // pass as params — avoid closure bug defer wg.Done() results[idx] = process(it) }(i, item) } wg.Wait() return results } // Goroutine with context cancellation func worker(ctx context.Context, jobs <-chan Job) { for { select { case <-ctx.Done(): return // context cancelled — shut down cleanly case job, ok := <-jobs: if !ok { return } // channel closed process(job) } } }
go func() { fmt.Println(i) }() inside a loop captures the variable i by reference — all goroutines may print the same final value. Always pass loop variables as function parameters.Channels
Channels & Select
Pipeline patterns, fan-in, timeouts
Channels are typed conduits for communication between goroutines. Go's philosophy: "Do not communicate by sharing memory; share memory by communicating."
// Unbuffered: sender blocks until receiver is ready ch := make(chan int) // Buffered: sender only blocks when buffer is full chBuf := make(chan int, 10) // Directional channels in function signatures func producer(out chan<- int) { out <- 42 } // send-only func consumer(in <-chan int) { v := <-in } // recv-only // Select: multiplex channels + timeout pattern func fetchWithTimeout(url string) (string, error) { resultCh := make(chan string, 1) go func() { resultCh <- httpGet(url) }() select { case result := <-resultCh: return result, nil case <-time.After(5 * time.Second): return "", fmt.Errorf("timeout") } } // Close channel to signal "no more data" // Range over channel reads until closed for item := range dataCh { process(item) // exits when dataCh is closed }
Sync Primitives
Mutex, RWMutex & sync.Once
Protecting shared state from data races
// Thread-safe in-memory cache type Cache struct { mu sync.RWMutex // multiple readers OR one writer data map[string]string } func (c *Cache) Get(key string) (string, bool) { c.mu.RLock() // shared read lock defer c.mu.RUnlock() v, ok := c.data[key] return v, ok } func (c *Cache) Set(key, val string) { c.mu.Lock() // exclusive write lock defer c.mu.Unlock() c.data[key] = val } // sync.Once: run initialization exactly once var ( instance *DB once sync.Once ) func GetDB() *DB { once.Do(func() { instance = connectDB() // runs exactly once, ever }) return instance } // Atomic operations (no mutex needed for single values) var requestCount atomic.Int64 requestCount.Add(1) n := requestCount.Load()
go test -race ./... during development. Go's built-in race detector catches data races at runtime — it's the most valuable tool for concurrent code correctness.Error Handling
Idiomatic Error Handling
fmt.Errorf %w, errors.Is/As, sentinel errors, panic/recover
Go errors are values — no exceptions. The pattern is always: check the error, handle it, or wrap and return it. Never ignore errors in production code.
// Sentinel errors — check identity with errors.Is var ErrNotFound = errors.New("not found") var ErrUnauthorized = errors.New("unauthorized") // Custom error type — check type with errors.As type ValidationError struct { Field string Message string } func (e *ValidationError) Error() string { return fmt.Sprintf("validation: %s: %s", e.Field, e.Message) } // Wrapping with %w — preserves error chain func getUser(id int) (*User, error) { u, err := db.QueryUser(id) if err != nil { return nil, fmt.Errorf("getUser(%d): %w", id, err) } return u, nil } // Unwrapping — check type in wrapped chain err := getUser(42) if errors.Is(err, ErrNotFound) { // works even if err is wrapped multiple levels deep } var ve *ValidationError if errors.As(err, &ve) { fmt.Println(ve.Field) // access the concrete type } // Recover from panic — use in HTTP handler middleware func recoverMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { http.Error(w, "internal server error", 500) } }() next.ServeHTTP(w, r) }) }
HTTP Server
Building an HTTP Server
net/http, routing, middleware, JSON API
Recovery, CORS
JSON response
package main import ( "encoding/json" "log/slog" "net/http" ) type Server struct { store UserStore router *http.ServeMux } func NewServer(store UserStore) *Server { s := &Server{store: store, router: http.NewServeMux()} s.router.HandleFunc("GET /users/{id}", s.getUser) // Go 1.22+ s.router.HandleFunc("POST /users", s.createUser) return s } func (s *Server) getUser(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") // Go 1.22 path params user, err := s.store.Get(id) if errors.Is(err, ErrNotFound) { writeJSON(w, 404, map[string]string{"error": "not found"}) return } writeJSON(w, 200, user) } // Middleware: wrap an http.Handler func logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slog.Info("request", "method", r.Method, "path", r.URL.Path) next.ServeHTTP(w, r) }) } // JSON helper func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) } func main() { store := NewPostgresStore() srv := NewServer(store) handler := logMiddleware(recoverMiddleware(srv.router)) server := &http.Server{ Addr: ":8080", Handler: handler, ReadTimeout: 10 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, } slog.Info("listening", "addr", ":8080") server.ListenAndServe() }
net/http package. For most APIs you no longer need gorilla/mux or chi — the stdlib is sufficient.Database
database/sql & Connection Pooling
Queries, transactions, prepared statements, pgx
import ( "context" "database/sql" _ "github.com/lib/pq" // postgres driver (side-effect import) ) // Setup connection pool (not a single connection) func NewDB(dsn string) (*sql.DB, error) { db, err := sql.Open("postgres", dsn) if err != nil { return nil, err } db.SetMaxOpenConns(25) // max simultaneous connections db.SetMaxIdleConns(5) // keep alive for reuse db.SetConnMaxLifetime(5 * time.Minute) return db, db.PingContext(context.Background()) } // Query multiple rows func (s *Store) ListUsers(ctx context.Context) ([]User, error) { rows, err := s.db.QueryContext(ctx, "SELECT id, name, email FROM users ORDER BY id") if err != nil { return nil, err } defer rows.Close() var users []User for rows.Next() { var u User if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil { return nil, fmt.Errorf("scan: %w", err) } users = append(users, u) } return users, rows.Err() // always check rows.Err() } // Transaction with rollback-on-error func (s *Store) TransferCredits(ctx context.Context, from, to, amount int) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() // no-op if Commit() already called if _, err = tx.ExecContext(ctx, "UPDATE accounts SET credits = credits - $1 WHERE id = $2", amount, from); err != nil { return err } if _, err = tx.ExecContext(ctx, "UPDATE accounts SET credits = credits + $1 WHERE id = $2", amount, to); err != nil { return err } return tx.Commit() }
$1, $2 in Postgres, ? in MySQL). Never format user input directly into SQL strings — that's an injection vulnerability.Backend Patterns
Context — Timeouts & Cancellation
context.WithTimeout, WithCancel, WithValue
context.Context propagates deadlines, cancellation signals, and request-scoped values down your call chain. Every blocking operation — HTTP calls, DB queries, file I/O — should accept a context.
// HTTP handler: derive context from request func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) { // Add a 5-second timeout to the request context ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() // always cancel to release resources result, err := s.store.Get(ctx, id) // If DB takes > 5s, ctx.Done() fires, query is cancelled } // Pass context through the entire call chain // handler → service → repository → SQL query func (svc *UserService) GetUser(ctx context.Context, id int) (*User, error) { return svc.repo.Find(ctx, id) // ctx flows all the way down } // Store request-scoped values (user ID, trace ID) type contextKey string const userIDKey contextKey = "userID" ctx = context.WithValue(ctx, userIDKey, userID) uid := ctx.Value(userIDKey).(int) // type assert
context.Background() or context.TODO() at the top level). Always cancel.Worker Pool — Bounded Concurrency
Process N items with at most K concurrent workers
func workerPool(ctx context.Context, jobs []Job, workers int) []Result { jobCh := make(chan Job, len(jobs)) resultCh := make(chan Result, len(jobs)) // Start fixed number of worker goroutines var wg sync.WaitGroup for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() for job := range jobCh { resultCh <- process(ctx, job) } }() } // Feed jobs, close when done for _, j := range jobs { jobCh <- j } close(jobCh) // Wait and close results go func() { wg.Wait(); close(resultCh) }() var results []Result for r := range resultCh { results = append(results, r) } return results } // Semaphore pattern: limit with buffered channel sem := make(chan struct{}, 10) // max 10 concurrent for _, item := range items { sem <- struct{}{} // acquire go func(it Item) { defer func() { <-sem }() // release process(it) }(item) }
Testing
Table-driven tests, httptest, benchmarks
// Table-driven test — idiomatic Go func TestAdd(t *testing.T) { tests := []struct { name string a, b int expected int }{ {"positive", 2, 3, 5}, {"negative", -1, -2, -3}, {"zeros", 0, 0, 0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got := Add(tt.a, tt.b) if got != tt.expected { t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected) } }) } } // HTTP handler test with httptest func TestGetUser(t *testing.T) { store := &MockStore{} // implements UserStore interface srv := NewServer(store) req := httptest.NewRequest("GET", "/users/42", nil) w := httptest.NewRecorder() srv.router.ServeHTTP(w, req) if w.Code != 200 { t.Errorf("got status %d, want 200", w.Code) } } // Benchmark func BenchmarkProcess(b *testing.B) { for i := 0; i < b.N; i++ { Process(testData) } } // Run commands: // go test ./... — all tests // go test -race ./... — with race detector // go test -bench=. ./... — benchmarks // go test -cover ./... — coverage
Cheat Sheet
| Construct | Syntax | Notes |
|---|---|---|
| Short var | x := 42 | Inside functions only; type inferred |
| Package var | var x int = 42 | Package level; explicit type |
| Goroutine | go func() {…}() | Non-blocking; always pass loop vars as params |
| Channel make | ch := make(chan int, 10) | Buffered; unbuffered = make(chan int) |
| Send / Recv | ch <- v / v := <-ch | Blocks if unbuffered and no partner |
| Select | select { case v := <-ch: } | Multiplex channels; default = non-blocking |
| Defer | defer f.Close() | LIFO order; runs on return/panic |
| Error wrap | fmt.Errorf("op: %w", err) | %w preserves chain for errors.Is/As |
| Error check | errors.Is(err, ErrNotFound) | Works through wrapped chains |
| Interface | type R interface { Read([]byte) } | Implicit — any type with right methods qualifies |
| Pointer recv | func (s *S) Set(v int) | Modifies; avoids copy; use for most methods |
| Value recv | func (s S) Get() int | Read-only copy; use for small immutable types |
| Context timeout | ctx, cancel := context.WithTimeout(…) | Always defer cancel() |
| Mutex | mu.Lock() / defer mu.Unlock() | RWMutex for read-heavy workloads |
| Map safe read | v, ok := m["key"] | ok = false if missing; never skip the check |
| Slice append | s = append(s, item) | May allocate new array; assign result back |
| Range loop | for i, v := range slice | Use _ to discard index or value |
| JSON encode | json.NewEncoder(w).Encode(v) | Struct tags: json:"field_name,omitempty" |
| Test race | go test -race ./... | Run on every PR; catches data races |
| Profile | go tool pprof cpu.prof | net/http/pprof for live profiling |
| go mod init | Creates go.mod | Module path usually matches repo import path |
| go get | Add / upgrade deps | Use @version or @latest; run go mod tidy |
| internal/ | Private packages | Importable only from parent tree of module |
"Accept interfaces, return concrete types."
"Errors are values."
"The bigger the interface, the weaker the abstraction."
"A little copying is better than a little dependency."