← All tech

Backend / language · default pick

Go

Simple, fast, concurrent network services with a tiny deploy footprint.

  • High-throughput HTTP & gRPC services
  • Effortless concurrency with goroutines
  • Single static binary, trivial deploys
  • Fast compiles and a calm, small language
Use it when

Reach for Go when you want a network service that is fast, easy to operate, and boring in the best way — predictable performance, one binary to ship, and concurrency that doesn't fight you.

Reach for something else when

Skip Go when you need a rich type system with generics-heavy abstractions, heavy numeric/ML work (use Python), or absolute control over memory layout and zero-cost abstractions (use Rust).

Official docs ↗


Go is the default backend of this curriculum because it gets you from idea to a deployed, fast service with the least ceremony. This track moves from “hello, server” to graceful shutdown, tests, Postgres, and a 15 MB container — tagged by level so you can read only as deep as you need.

Install Go and create your module

Beginner

Install the Go toolchain, then make a folder and run go mod init with your repo path.

Why a module, and why that name

A module is Go’s unit of dependencies and versioning. The path you pass to go mod init (normally the repo URL) becomes the import prefix for every package in the project, so packages elsewhere — and your own sub-packages — import it by that path. The repo doesn’t have to exist yet; pick the real name now to avoid a rename later.

Initialise the module
Run these in your terminal / editor
# Verify the toolchain (1.22 or newer for this track)
go version

mkdir aurora-api && cd aurora-api
go mod init github.com/you/aurora-api

Write the smallest real HTTP server

Beginner

Create cmd/api/main.go with a net/http server that answers GET /healthz with ok.

Why net/http and no framework

Go’s standard library is a production HTTP server — not a toy. Starting framework-free teaches the real primitives (http.Server, ServeMux, http.Handler) that every framework wraps. You can add a router later if you outgrow it, but most services never need to.

A minimal server
Run these in your terminal / editor
// cmd/api/main.go
package main

import (
	"log/slog"
	"net/http"
	"os"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		_, _ = w.Write([]byte("ok"))
	})

	logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
	logger.Info("listening", "addr", ":8080")
	if err := http.ListenAndServe(":8080", mux); err != nil {
		logger.Error("server failed", "err", err)
		os.Exit(1)
	}
}
go run ./cmd/api
# in another terminal:
curl -i localhost:8080/healthz
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer working in this repo.
Context: Fresh module github.com/you/aurora-api, Go 1.22+, empty except go.mod.
Task: Create a minimal, idiomatic net/http server in ./cmd/api/main.go.
Requirements:
- Use http.NewServeMux and the 1.22 method-prefixed pattern "GET /healthz".
- /healthz returns 200 with body "ok".
- Use log/slog with a JSON handler writing to stdout.
- No third-party dependencies.
Tests / acceptance:
- `go vet ./...` and `go build ./...` pass with no output.
- `curl -s -o /dev/null -w "%{http_code}" localhost:8080/healthz` prints 200.
Output: a unified diff plus a one-paragraph summary of the design.

Add real routes with Go 1.22 patterns

Beginner

Add a GET /products/{id} route and read the path value with r.PathValue("id").

What changed in Go 1.22

Before 1.22 you needed a third-party router for method matching and path variables. Go 1.22 added both to the standard ServeMux: patterns can include a method (GET , POST …) and wildcards like {id}, read back with r.PathValue("id"). For typical CRUD services this removes the most common reason to add a router dependency.

A route with a path variable
Run these in your terminal / editor
mux.HandleFunc("GET /products/{id}", func(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	w.Header().Set("Content-Type", "application/json")
	_, _ = w.Write([]byte(`{"id":"` + id + `"}`))
})

Return JSON properly with encoding/json

Beginner

Define a struct, set the Content-Type, and encode it with json.NewEncoder(w).Encode(v).

Struct tags control the wire shape

Go marshals exported struct fields to JSON. Field tags (json:"unit_price") decouple your Go names from the API’s names, and omitempty drops zero values. Encoding straight to the ResponseWriter streams the body without building an intermediate buffer.

Encode a struct to JSON
Run these in your terminal / editor
type Product struct {
	ID        string `json:"id"`
	Name      string `json:"name"`
	UnitPrice int64  `json:"unit_price"` // store money as integer cents
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}
Chat prompt — paste into a chat to get the code
For a plain chat. It returns complete code; you paste it in yourself.
Role: Go teacher. The reader has no repo access here — return complete code.
Task: Show an idiomatic handler that decodes a JSON request body into a struct, validates it, and
responds with 201 + the created object, or 422 + a JSON error on invalid input.
Requirements:
- Use encoding/json with a size-limited body (http.MaxBytesReader, 1 MB).
- Reject unknown fields (Decoder.DisallowUnknownFields).
- Money is int64 cents; name is required and non-empty.
Tests / acceptance (describe, since no repo):
- POST with {"name":"","unit_price":10} returns 422.
- POST with an extra field returns 400.
Output: the complete handler file, no commentary.

Write table-driven tests with httptest

Intermediate

Test the handler in memory with httptest.NewRecorder() and a slice of cases.

Why table-driven, and why httptest

Table-driven tests are the Go idiom: one loop over a slice of {name, input, want} structs keeps cases dense and easy to extend. net/http/httptest exercises a handler without binding a socket — fast and deterministic. Name sub-tests with t.Run so failures point at the exact case.

A handler test
Run these in your terminal / editor
// cmd/api/main_test.go
func TestHealthz(t *testing.T) {
	cases := []struct {
		name   string
		method string
		path   string
		want   int
	}{
		{"ok", "GET", "/healthz", 200},
		{"wrong method", "POST", "/healthz", 405},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequest(tc.method, tc.path, nil)
			rec := httptest.NewRecorder()
			newRouter().ServeHTTP(rec, req)
			if rec.Code != tc.want {
				t.Fatalf("got %d, want %d", rec.Code, tc.want)
			}
		})
	}
}
go test ./... -run TestHealthz -v
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: Handlers live in ./cmd/api. Extract routing into a newRouter() *http.ServeMux so it is testable.
Task: Refactor to newRouter() and add table-driven tests for /healthz and /products/{id}.
Requirements:
- newRouter returns the configured mux; main calls it.
- Tests use net/http/httptest, t.Run sub-tests, no network sockets.
- Cover: healthz 200, wrong method 405, products happy path 200, missing id behaviour.
Tests / acceptance:
- `go test ./... -v` passes.
- `go vet ./...` clean.
Output: a unified diff plus a summary of the cases covered.

Handle errors the Go way

Intermediate

Return errors as values, wrap them with %w, and inspect with errors.Is / errors.As.

Wrapping preserves the chain

Go has no exceptions; functions return error as the last value. Wrapping with fmt.Errorf("loading product: %w", err) adds context while keeping the original error reachable. Callers test causes with errors.Is(err, ErrNotFound) or extract typed errors with errors.As. Define sentinel errors (var ErrNotFound = errors.New("not found")) at the boundary so handlers can map them to status codes.

Sentinel + wrap + map to status
Run these in your terminal / editor
var ErrNotFound = errors.New("not found")

func (s *Store) Product(ctx context.Context, id string) (Product, error) {
	p, ok := s.byID[id]
	if !ok {
		return Product{}, fmt.Errorf("product %q: %w", id, ErrNotFound)
	}
	return p, nil
}

// in the handler:
if errors.Is(err, ErrNotFound) {
	writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
	return
}

Fan out work with goroutines and channels

Intermediate

Launch concurrent work with go, collect results through a channel, and bound it with a context.

Concurrency is a language feature, not a library

A goroutine is a cheap, scheduler-managed thread (a few KB to start). Channels pass values between goroutines so you “share memory by communicating”. context.Context carries deadlines and cancellation down the call tree — when a request is cancelled, the work it spawned should stop. Use errgroup to fan out and collect the first error cleanly.

Bounded fan-out with errgroup
Run these in your terminal / editor
import "golang.org/x/sync/errgroup"

func enrich(ctx context.Context, ids []string) ([]Product, error) {
	g, ctx := errgroup.WithContext(ctx)
	g.SetLimit(8) // cap concurrency
	out := make([]Product, len(ids))
	for i, id := range ids {
		i, id := i, id
		g.Go(func() error {
			p, err := fetch(ctx, id)
			if err != nil {
				return err
			}
			out[i] = p
			return nil
		})
	}
	return out, g.Wait()
}
go get golang.org/x/sync/errgroup
go test ./... -race   # the race detector catches data races
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: We need to fetch N products concurrently with a concurrency cap and proper cancellation.
Task: Implement enrich(ctx, ids) using golang.org/x/sync/errgroup with SetLimit(8).
Requirements:
- Results preserve input order; no shared-slice data race (each goroutine writes its own index).
- Respect ctx cancellation; return the first error.
- Add a test using a fake fetch that sleeps, asserting it cancels on ctx timeout.
Tests / acceptance:
- `go test ./... -race` passes.
Output: a unified diff plus a one-paragraph rationale for the concurrency bound.

Talk to PostgreSQL with pgx

Intermediate

Add jackc/pgx/v5, open a pool, and run a query with context and parameters.

Why pgx over database/sql here

pgx is the most widely used PostgreSQL driver for Go. Its native pool (pgxpool) is fast and exposes Postgres features (e.g. COPY, listen/notify) that the generic database/sql interface hides. Always pass a context, and always use parameters ($1, $2) — never string-concatenate SQL, which invites injection.

Connect and query
Run these in your terminal / editor
go get github.com/jackc/pgx/v5
import "github.com/jackc/pgx/v5/pgxpool"

pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil { return err }
defer pool.Close()

var price int64
err = pool.QueryRow(ctx,
	`SELECT unit_price FROM products WHERE id = $1`, id,
).Scan(&price)
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: Postgres is reachable via env DATABASE_URL. We use github.com/jackc/pgx/v5 with pgxpool.
Task: Add a ProductStore backed by pgxpool with Get(ctx, id) and List(ctx, limit, offset).
Requirements:
- Parameterised queries only ($1, $2); never string-concatenate input.
- Map pgx.ErrNoRows to the repo's ErrNotFound.
- Pool created once at startup, closed on shutdown; every method takes a context.
Tests / acceptance:
- Unit-test the error mapping with a fake row source (no live DB needed in CI).
- `go vet ./...` clean.
Output: a unified diff plus notes on connection-pool sizing.

Shut down gracefully

Advanced

Trap SIGINT/SIGTERM with signal.NotifyContext, then call server.Shutdown(ctx) with a timeout.

Why graceful shutdown matters in production

When an orchestrator (Cloud Run, Kubernetes) replaces your instance it sends SIGTERM. If you exit immediately you drop in-flight requests. signal.NotifyContext gives you a context cancelled on those signals; http.Server.Shutdown then stops accepting new connections and waits (up to a deadline) for active ones to finish. This is the single most impactful operability upgrade for a Go service.

Signal-aware server lifecycle
Run these in your terminal / editor
func run() error {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	srv := &http.Server{Addr: ":8080", Handler: newRouter()}
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			slog.Error("listen", "err", err)
		}
	}()

	<-ctx.Done() // blocks until a signal
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx)
}
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go engineer in this repo.
Context: main currently calls http.ListenAndServe directly.
Task: Refactor to a run() error using signal.NotifyContext and http.Server.Shutdown with a 10s deadline.
Requirements:
- main calls run() and os.Exit(1) on error.
- ListenAndServe's http.ErrServerClosed is treated as a clean stop, not an error.
- A 10-second shutdown deadline; log the shutdown.
Tests / acceptance:
- `go build ./...` passes; manual: Ctrl-C drains rather than dropping connections.
Output: a unified diff plus a one-paragraph explanation of the lifecycle.

Ship a 15 MB static container

Advanced

Compile a static binary with CGO_ENABLED=0 and copy it into a scratch/distroless image in a multi-stage build.

Why the binary gets so small

Go links everything into one binary. With CGO_ENABLED=0 there’s no libc dependency, so the binary runs on an empty base image. A multi-stage Dockerfile builds in the full SDK image, then copies just the binary into gcr.io/distroless/static (or scratch) — no shell, no package manager, a tiny attack surface, and fast cold starts on Cloud Run.

Multi-stage Dockerfile
Run these in your terminal / editor
FROM golang:1.22 AS build
WORKDIR /src
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app ./cmd/api

FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
ENTRYPOINT ["/app"]
docker build -t aurora-api .
docker run -p 8080:8080 -e DATABASE_URL=... aurora-api

Find the slow path with pprof and benchmarks

Advanced

Add net/http/pprof, write a Benchmark function, and profile CPU and allocations.

Measure before you optimise

Go ships first-class profiling. Importing net/http/pprof exposes /debug/pprof/* for live CPU, heap, and goroutine profiles. go test -bench runs micro-benchmarks; -benchmem reports allocations per operation, the usual culprit behind GC pressure. Optimise only what a profile proves is hot — guessing wastes time and obscures the code.

Benchmark and profile
Run these in your terminal / editor
func BenchmarkEncode(b *testing.B) {
	p := Product{ID: "p1", Name: "Widget", UnitPrice: 1999}
	b.ReportAllocs()
	for i := 0; i < b.N; i++ {
		_ = json.NewEncoder(io.Discard).Encode(p)
	}
}
go test -bench=. -benchmem ./...
go tool pprof -http=:9000 cpu.out   # flame graph in the browser
Agent prompt — paste into an agent with repo access
For Claude Code / Cursor / an agent that can read & edit this repo.
Role: Senior Go performance engineer in this repo.
Context: The JSON encode path appears hot under load.
Task: Add a benchmark for the product encode path and reduce allocations per op.
Requirements:
- Benchmark uses b.ReportAllocs() and io.Discard.
- Propose one concrete allocation reduction (e.g. reuse a buffer / sync.Pool) only if the benchmark shows it helps.
- Do not change behaviour; existing tests must still pass.
Tests / acceptance:
- `go test -bench=. -benchmem ./...` runs; report before/after allocs/op in the summary.
- `go test ./...` still green.
Output: a unified diff plus the before/after benchmark numbers.

Where to take it next

  • Wire this service to a real database in Aurora Commerce, where Go fronts PostgreSQL transactions for checkout.
  • Compare Go against its alternatives on the Compare page.
  • Going lower-level for raw speed or strict memory guarantees? See Rust.