🐹 Golang for AI Developers πŸ€– β€” From 0 to Pro ⚑ A developer has published a comprehensive guide to using Go for AI development, covering topics from basic syntax to building concurrent, observable services that front machine learning models. The guide emphasizes Go's strengths in API gateways, streaming proxies, and orchestrators, while recommending a hybrid approach where Go handles HTTP, auth, and fan-out, and Python handles heavy computation. One file, one path: from package main to shipping a concurrent, observable Go service that fronts your models and never falls over.Every example is drawn from what AI engineers actually build in Go β€” streaming proxies, tool dispatchers, rate limiters, worker pools, context-cancelled model calls. No foo / bar filler. Companion reads: 🐍 Python for AI Developers //python for ai developers.md the sibling to this guide , πŸ—οΈ Building High-Quality AI Agents //building high quality ai agents.md , 🏒 Enterprise-Ready AI Agents //enterprise ready ai agents guide.md . | You are… | Start at | Skip | |---|---|---| | New to Go | Part 1 β†’ read straight through | Parts 12–13 on first pass | | Coming from Python | Part 1 the phrasebook , then Part 5 and Part 6 | β€” | | Coming from Java/C | Part 4, Part 5 β€” inheritance and exceptions are gone | Part 2 skim | | Building AI services | Part 6, Part 7, Part 9 | β€” | | Reviewing code | Part 14, Part 15 | everything else | Convention: // βœ… = do this, // ❌ = don't. Snippets target Go 1.22+ , with newer-version wins called out inline. Go was designed for large teams maintaining network services over years . Every trade-off follows from that: | Go chose | Instead of | Consequence for you | |---|---|---| | A tiny spec 25 keywords | Rich features | You can read any Go file after a week | | Compile to one static binary | Runtime + deps | FROM scratch images, 10 ms cold start | | Explicit errors as values | Exceptions | Failure paths are visible in the code | | Composition + interfaces | Inheritance | No class hierarchies to reverse-engineer | | Goroutines + channels | Callbacks / async colouring | Blocking code that scales to 100k connections | | One formatter, one toolchain | Ecosystem choice | Zero config debates; go test , go fmt , pprof are built in | Go is boring on purpose . The payoff is that a service written by someone who left two years ago still compiles, still reads clearly, and still runs. your .go files β†’ compiler: types, escape analysis, inlining β†’ one native binary ↑ includes the runtime scheduler + GC COPY binary / .The cost: more ceremony up front, no REPL, and a smaller ML ecosystem. | Dimension | Go | Python | |---|---|---| | Execution | Native binary + embedded runtime | Bytecode on the CPython VM | | Typing | Static, enforced by the compiler | Dynamic; static only via mypy in CI | | Parallelism | Real: goroutines across all cores | GIL-limited; processes or C extensions | | Concurrency cost | ~2 KB per goroutine | ~KB per coroutine, ~MB per thread | | p99 latency | Stable GC pauses < 1 ms | Noisier | | Deploy artifact | 15–40 MB static binary | Interpreter + wheels + lockfile | | Startup | ~5 ms | 100–500 ms imports | | ML/AI libraries | Thin inference clients, ONNX, tokenizers | Everything | | Best at | API gateways, streaming proxies, orchestrators, high-fan-out workers | Model training, data science, ML inference glue | The production shape that wins β€” and the one in this repo's CLAUDE.md //CLAUDE.md β€” is both: Go as the BFF that owns HTTP, auth, tenancy, streaming and fan-out; Python as the ML service it calls for heavy computation. Use Go where request volume and connection count live; use Python where the models live. | Python | Go | Note | |---|---|---| x = 5 | x := 5 | := declares + infers, inside functions only | list int | int | Slice β€” dynamic array | dict str, int | map string int | Iteration order is randomized | tuple | struct, or multiple return values | No tuple type | None | nil pointers, slices, maps, interfaces, funcs, chans | Value types have zero values instead | Optional T | T , or T, bool , or T, error | Pointers are the "maybe" of Go | raise ValueError ... | return fmt.Errorf "...: %w", err | Errors are returned, not thrown | try/except | if err = nil { … } | Explicit at every call | with open ... as f: | f, err := os.Open ... ; defer f.Close | defer is the context manager | @decorator | Higher-order function / middleware | Wrap the function or the handler | class A: def m self | type A struct{} + func a A M | Methods live outside the type | Protocol structural | interface | Go interfaces are structural too β€” no implements | async def / await | just call it, in a go routine | No function colouring | asyncio.gather | errgroup.Group | Bounded with SetLimit | asyncio.Semaphore 8 | buffered channel or SetLimit 8 | | f"{x:.2f}" | fmt.Sprintf "%.2f", x | | pytest | go test ./... | Testing is in the stdlib | venv + pyproject.toml | go.mod | Modules, no activation | package main import "fmt" "log/slog" "net/http" "os" func main { logger := slog.New slog.NewJSONHandler os.Stdout, nil mux := http.NewServeMux mux.HandleFunc "GET /healthz", func w http.ResponseWriter, r http.Request { fmt.Fprintln w, "ok" } logger.Info "listening", "addr", ":8080" if err := http.ListenAndServe ":8080", mux ; err = nil { logger.Error "server failed", "err", err os.Exit 1 } } Three things a Python developer should notice: no framework, no decorators, and errors returned rather than raised. "GET /healthz" method-and-pattern routing is Go 1.22+. 🎯 Actionable rules - Choose Go for the request path and the fan-out; keep Python where the models are. - Let the compiler carry the weight you spend mypy effort on in Python. - Learn error , interface , defer , and context β€” everything else is syntax. js var name string // "" β€” declared variables are ALWAYS initialized var count int // 0 var ratio float64 // 0 var ok bool // false var tools string // nil usable: len 0, append works var index map string int // nil readable, but WRITING panics var client http.Client // nil model := "claude-opus-5" // := infers the type; functions only timeout, retries := 30, 3 // multiple assignment , err := doThing // discards a value you must accept Zero values are Go's answer to None. There is no uninitialized memory, so a struct is useful the moment it exists. Design your types so the zero value works sync.Mutex , bytes.Buffer , and http.Client all do .⚠️ var m map string int is nil: reads return the zero value, writes panic. Always m := make map string int or m := map string int{} . int, int8/16/32/64, uint… // int is 64-bit on modern platforms; use it by default float32, float64 // float64 unless you're storing millions of embeddings string // immutable, UTF-8 bytes byte = uint8 // a raw byte rune = int32 // one Unicode code point bool T, map K V, chan T, T, func ... ..., interface{ … }, struct{ … } Go has no implicit conversion , not even int β†’ int64 : js var i int = 42 var f float64 = float64 i // explicit, always var u uint8 = uint8 300 // ⚠️ silently wraps to 44 β€” check ranges yourself n, err := strconv.Atoi "42" // string β†’ int returns an error s := strconv.Itoa 42 // int β†’ string f, err := strconv.ParseFloat "0.7", 64 b, err := strconv.ParseBool "true" ⚠️ string 65 gives "A" , not "65" β€” it converts a code point. Use strconv . go vet flags this. iota js const MaxHistoryTurns = 20 // untyped: adapts to context const ToolTimeout = 30 time.Second // typed by inference type Role string const RoleUser Role = "user" RoleAssistant Role = "assistant" RoleSystem Role = "system" type Status int const StatusOK Status = iota // 0 β€” iota counts from 0 within a const block StatusRetry // 1 StatusFailed // 2 func s Status String string { // makes it print nicely everywhere switch s { case StatusOK: return "ok" case StatusRetry: return "retry" case StatusFailed: return "failed" default: return fmt.Sprintf "Status %d ", int s } } A named string type type Role string is Go's enum: the compiler rejects a raw "usr" typo where a Role is expected, while JSON marshalling still just works. Strings are immutable byte slices holding UTF-8. Indexing gives bytes; ranging gives runes. s := "cafΓ©" len s // 5 β€” BYTES, not characters s 0 // 99 byte 'c' for i, r := range s { // i = byte offset, r = rune fmt.Printf "%d:%c ", i, r // 0:c 1:a 2:f 3:Γ© } utf8.RuneCountInString s // 4 β€” actual character count rune s 3 // 'Γ©' β€” index by character allocates byte s // copy to a mutable byte slice The strings package covers what Python puts on str : strings.TrimSpace " hi \n" // "hi" strings.ToLower "Calculate 2+2" strings.Split "a,b,c", "," // string{"a","b","c"} strings.SplitN "calculate 10 5", "calculate", 2 1 // " 10 5" maxsplit strings.Join string{"a", "b"}, ", " // "a, b" strings.HasPrefix name, "tool:" // also HasSuffix, Contains, EqualFold strings.ReplaceAll s, "ok", "done" strings.Fields " a b " // "a","b" β€” split on any whitespace strings.TrimPrefix path, "docs/" // prefix-safe not Trim, which is a char set strings.Cut "key=value", "=" // "key", "value", true β€” the modern splitter Building strings : += in a loop is O nΒ² and allocates every time. Use a builder: js var b strings.Builder b.Grow len history 64 // one allocation if you can estimate for , m := range history { fmt.Fprintf &b, "%s: %s\n", m.Role, m.Content } prompt := b.String fmt verbs you'll actually use fmt.Sprintf "%s scored %.2f", name, score // string, 2-decimal float fmt.Sprintf "%d/%d tokens", used, limit // int fmt.Sprintf "%q", name // "calculator" β€” quoted, like Python's r fmt.Sprintf "%v", cfg // default format fmt.Sprintf "%+v", cfg // {Name:agent Model:claude-opus-5} ← field names fmt.Sprintf "% v", cfg // Go syntax β€” best for debugging fmt.Sprintf "%T", v // the dynamic type: main.Agent fmt.Errorf "run tool %q: %w", name, err // %w WRAPS an error see Β§5 %q is your r : it makes "" and " " visible in logs. %+v on a struct is the fastest debugging tool in the language. A slice is a 3-word header: pointer to a backing array, length, capacity . That header is copied on assignment; the array is not. xs := string{"a", "b"} // literal ys := make string, 0, 100 // len 0, cap 100 β€” preallocate when you know the size ys = append ys, "x" // append RETURNS a new header; always reassign len xs ; cap xs xs = append xs, ys... // ... spreads a slice like Python's copy dst, src // copies min len dst , len src last10 := history max 0, len history -10 : // sliding window min/max builtins: Go 1.21+ ⚠️ The aliasing trap β€” slicing shares the backing array: all := int{1, 2, 3, 4, 5} head := all :3 head = append head, 99 // cap allows it β†’ OVERWRITES all 3 fmt.Println all // 1 2 3 99 5 Fixes: three-index slicing to cap it all :3:3 forces append to copy , or slices.Clone head . ⚠️ Never keep a small slice of a huge one β€” the whole backing array stays alive: snippet := slices.Clone bigDoc :100 // βœ… 100 bytes retained, not 50 MB The slices package Go 1.21+ replaces most hand-written loops: slices.Contains tools, "bash" slices.Sort scores slices.SortFunc docs, func a, b Doc int { return cmp.Compare b.Score, a.Score } // desc slices.Index names, "calculator" slices.Clone xs ; slices.Reverse xs ; slices.Max scores scores := map string float64{"calculator": 0.94} v := scores "missing" // 0 β€” no error, zero value v, ok := scores "missing" // βœ… the comma-ok idiom: v=0, ok=false delete scores, "calculator" len scores clear scores // Go 1.21+ for k, v := range scores { … } // ⚠️ ORDER IS RANDOMIZED, deliberately keys := slices.Sorted maps.Keys scores // Go 1.23+ β€” deterministic iteration dict.get vs . sync.RWMutex or use sync.Map only for its two specific patterns β€” see Β§6.6 . make map string int, 1000 . type AgentConfig struct { Name string json:"name" Model string json:"model" Temperature float64 json:"temperature,omitempty" Tools string json:"tools,omitempty" apiKey string json:"-" // lowercase = unexported; "-" = never marshalled } cfg := AgentConfig{Name: "researcher", Model: "claude-opus-5"} // βœ… field names, always p := &cfg // pointer p.Temperature = 0.2 // auto-dereference β€” no - in Go fmt.Printf "%+v\n", cfg Exported = capitalized. Name is visible outside the package; apiKey is not. That single rule replaces public / private . Struct tags are metadata read by reflection β€” the JSON, DB, and validation layers all use them. Value or pointer? | Use a value | Use a pointer | |---|---| Small, immutable-ish time.Time , Point | The method mutates the receiver | You want a copy concurrency safety | The struct is large copying costs | | Zero value is meaningful | Nil must be distinguishable from empty | Go is always pass-by-value β€” passing a struct copies it; passing a pointer copies the pointer. Slices, maps, and channels contain internal pointers, so copying the header still shares the data. if err := run ctx ; err = nil { // βœ… init statement scopes err to the if return fmt.Errorf "run: %w", err } switch { // no condition = cleaner if/else-if chain case score 0.9: label = "high" case score 0.5: label = "medium" default: label = "low" } switch status { // no fallthrough by default unlike C case StatusOK, StatusRetry: // multiple values per case continue } for i := 0; i < n; i++ { } // classic for i, msg := range history { } // range: index+value for , msg := range history { } // value only for k := range scores { } // map: keys only for range 5 { } // Go 1.22+: repeat N times for { break } // infinite loop β€” the only while for msg := range ch { } // range over a channel until it's closed for tok := range stream.Tokens { } // Go 1.23+: range over an iterator function There is no while , no ternary, and no do/while . That's not an oversight β€” it's the "one obvious way" principle. ⚠️ range copies each element: for , d := range docs { d.Score = 0 } mutates a copy. Use for i := range docs { docs i .Score = 0 } . βœ… Since Go 1.22 , loop variables are per-iteration , so the classic "all goroutines see the last value" bug is gone. On older versions you needed i := i inside the loop. goto exists; you will not use it. Labeled break / continue are occasionally right for breaking out of nested loops: outer: for , doc := range docs { for , chunk := range doc.Chunks { if chunk.Match q { break outer } } } 🎯 Actionable rules - Design types so the zero value is useful; never return a nil map you expect callers to write to. - Always reassign the result of append , and slices.Clone anything you retain from a big slice.- Use comma-ok on map reads whenever "absent" and "zero" differ. %+v and %q in every debug print; %w in every wrapped error. defer // Summarize returns a summary of text capped at maxWords words. // // It collapses whitespace and never splits a word. maxWords must be 0. func Summarize text string, maxWords int string, error { if maxWords <= 0 { return "", fmt.Errorf "maxWords must be positive, got %d", maxWords } words := strings.Fields text if len words maxWords { words = words :maxWords } return strings.Join words, " " , nil } summary, err := Summarize doc, 50 if err = nil { … } T, error is the signature of Go. The error is the last return value, always. There is no Optional , no exception, no hidden control flow. Doc comments start with the identifier's name and are the package's documentation go doc , pkg.go.dev . Exported identifiers without a comment are flagged by linters β€” and the comment is what an LLM reads when your function becomes a tool. func splitHostPort s string host string, port int, err error { // named returns // … named results are pre-declared and zero-valued; a bare return returns them return host, port, nil // βœ… still return explicitly for clarity } Use named returns for documentation and for defer -based error wrapping Β§3.4 β€” not as an excuse for naked return s in long functions. func RunTool name string, args ...any string, error { … } RunTool "calculator", "2+2" RunTool "search", queryArgs... // spread a slice type ToolFunc func ctx context.Context, args json.RawMessage string, error var registry = map string ToolFunc{} // string β†’ behaviour, the Go way func Register name string, fn ToolFunc { registry name = fn } Functions are values: assign them, store them in maps, pass them, return them. That covers most of what Python decorators do. func makeRetrier attempts int, base time.Duration func context.Context, func error error { return func ctx context.Context, op func error error { var err error for i := range attempts { if err = op ; err == nil { return nil } select { case <-time.After base << i : // exponential backoff case <-ctx.Done : return ctx.Err } } return fmt.Errorf "after %d attempts: %w", attempts, err } } retry := makeRetrier 3, 100 time.Millisecond Closures capture variables by reference , so a closure can outlive the function that made it β€” the compiler moves those variables to the heap see escape analysis, Β§7.4 . defer in practice defer schedules a call to run when the surrounding function returns β€” on any path, including panic. It is Go's with / finally . func fetchDoc ctx context.Context, url string byte, error { req, err := http.NewRequestWithContext ctx, http.MethodGet, url, nil if err = nil { return nil, fmt.Errorf "fetchDoc: build request: %w", err } resp, err := http.DefaultClient.Do req if err = nil { return nil, fmt.Errorf "fetchDoc: %w", err } defer resp.Body.Close // βœ… immediately after the error check, every time … } Four rules that cover every defer bug: defer time start := time.Now defer log.Printf "took %s", time.Since start // ❌ Since runs NOW β†’ always ~0 defer func { log.Printf "took %s", time.Since start } // βœ… closure defers the read for , p := range paths { f, := os.Open p defer f.Close // ❌ 10 000 open files, all closed at the very end } for , p := range paths { // βœ… give each iteration its own function func { f, := os.Open p ; defer f.Close ; process f } } func s Store Save ctx context.Context, d Doc err error { tx, err := s.db.BeginTx ctx, nil if err = nil { return err } defer func { if err = nil { = tx.Rollback ; return } err = tx.Commit } … } ⚠️ Deferred Close on a writer can silently drop errors. For files you write, close explicitly and check, or capture it: defer func { err = errors.Join err, f.Close } . init and package-level state func init { … } // runs once, after package vars, before main Use it almost never: it hides work, runs on import, and makes tests order-dependent. Prefer an explicit constructor called from main . The one defensible use is registering a driver or a codec. 🎯 Actionable rules - Return T, error ; handle or wrap the error at the very next line. defer the cleanup on the line after the error check that acquired the resource.- No defer inside loops β€” wrap the body in a function.- Doc-comment every exported identifier, starting with its name. type Agent struct { cfg AgentConfig llm LLMClient history Message mu sync.Mutex } // NewAgent constructs an Agent. Constructor functions are Go's init . func NewAgent cfg AgentConfig, llm LLMClient Agent, error { if cfg.Name == "" { return nil, errors.New "agent: name is required" } return &Agent{cfg: cfg, llm: llm}, nil } func a Agent AddMessage role Role, content string { // pointer receiver: mutates a.mu.Lock defer a.mu.Unlock a.history = append a.history, Message{Role: role, Content: content} } func a Agent Len int { return len a.history } // pointer for consistency func c AgentConfig Describe string { // value receiver: read-only, small return fmt.Sprintf "%s/%s@%.1f", c.Name, c.Model, c.Temperature } Receiver rules: sync.Mutex copying a mutex is a bug go vet catches . T satisfies an interface when methods have pointer receivers β€” a plain T value won't compile. This is the 1 "why doesn't my type implement this interface" error. type BaseTool struct { Name string Description string } func b BaseTool Schema string { … } type CalculatorTool struct { BaseTool // embedded: no field name Precision int } calc := CalculatorTool{BaseTool: BaseTool{Name: "calculator"}, Precision: 4} calc.Name // promoted field calc.Schema // promoted method Embedding promotes fields and methods β€” it looks like inheritance but it's delegation: there is no virtual dispatch and no super . Embedding an interface is the standard way to build decorators and partial fakes: type loggingStore struct { Store // embedded interface: unimplemented methods pass through log slog.Logger } func s loggingStore Get ctx context.Context, id string Doc, error { s.log.Info "get", "id", id return s.Store.Get ctx, id } There is no implements keyword. If the method set matches, the type satisfies the interface. // Defined in the package that USES it, not the one that implements it. type LLMClient interface { Complete ctx context.Context, prompt string string, error } type AnthropicClient struct{ … } func c AnthropicClient Complete ctx context.Context, p string string, error { … } // AnthropicClient now satisfies LLMClient. No import of your package required. agent, := NewAgent cfg, &AnthropicClient{} // prod agent, := NewAgent cfg, &fakeLLM{reply: "42"} // test β€” no mocking library needed The three rules that make Go interfaces work: io.Reader has one method. A 12-method interface is a class in disguise; nobody can fake it in a test. js var LLMClient = AnthropicClient nil // compile-time assertion that it satisfies any , type assertions, and type switches js var v any = payload // any == interface{} Go 1.18+ alias s, ok := v. string // βœ… comma-ok: never panics s := v. string // ❌ panics if v isn't a string switch x := v. type { // type switch case string: return x case map string any: return fmt.Sprintf "%d keys", len x case nil: return "null" default: return fmt.Sprintf "unsupported %T", x } any throws away the compiler's help β€” use it only at the JSON/reflection boundary and convert into a real type immediately the same discipline as Python's Any . ⚠️ The typed-nil trap β€” an interface holding a nil pointer is not nil: js func newClient AnthropicClient { return nil } var c LLMClient = newClient c == nil // false the interface has a type AnthropicClient and a nil value Fix: return the interface type as a literal nil , never a typed nil pointer. Most commonly this bites with error β€” never declare var err MyError and return it as error . Type parameters Go 1.18+ exist to remove copy-paste, not to build hierarchies. func Map T, U any xs T, f func T U U { out := make U, 0, len xs for , x := range xs { out = append out, f x } return out } names := Map tools, func t Tool string { return t.Name } func Keys K comparable, V any m map K V K { … } // comparable = usable as a map key type Number interface{ ~int | ~int64 | ~float64 } // ~ = "any type whose underlying type is" func Sum T Number xs T T { var s T; for , x := range xs { s += x }; return s } // A generic, type-safe cache β€” the common real-world use. type Cache K comparable, V any struct { mu sync.RWMutex m map K V } func NewCache K comparable, V any Cache K, V { return &Cache K, V {m: make map K V } } func c Cache K, V Get k K V, bool { c.mu.RLock ; defer c.mu.RUnlock v, ok := c.m k return v, ok } When not to use generics: if an interface expresses it, use the interface. Generics can't have methods with their own type parameters, they inflate compile times, and Map / Filter chains read worse in Go than a plain for loop. The slices , maps , and cmp packages already cover 90% of what you'd write. | Interface | Method | Why it matters | |---|---|---| error | Error string | Every failure Β§5 | fmt.Stringer | String string | Custom formatting in every %v | io.Reader / io.Writer | Read / Write | Files, sockets, buffers, HTTP bodies β€” all compose | io.Closer | Close error | Pairs with defer | json.Marshaler / Unmarshaler | Custom JSON | Enums, time formats, LLM payload quirks | context.Context | Done , Err , Value , Deadline | Cancellation everywhere Β§6.5 | http.Handler | ServeHTTP | Every middleware in Go | sort.Interface | Len / Less / Swap | Mostly superseded by slices.SortFunc | io.Reader / io.Writer are the reason Go plumbing composes so well: an HTTP body, a gzip stream, a file, and a bytes.Buffer are interchangeable. 🎯 Actionable rules - Constructors return T, error ; validate there, so an existing value is always valid.- Define small interfaces in the consuming package; accept interfaces, return structs. var Iface = T nil to assert satisfaction at compile time.- Reach for generics only after you've written the same function twice. type error interface { Error string } That's it. An error is any value with an Error string method. There is no stack unwinding, no exception hierarchy, no invisible control flow β€” which is why Go code has if err = nil everywhere and why you can always see the failure path. errors.New "agent: name is required" // static message fmt.Errorf "embed batch %d: %w", i, err // wrap with context fmt.Errorf "parse config: %v", err // %v = context WITHOUT wrapping errors.Join err1, err2 // multiple failures Go 1.20+ %w vs %v : %w keeps the original error reachable by errors.Is / errors.As ; %v flattens it to text. Wrap by default; use %v deliberately when you don't want callers coupling to an internal error type. Follow one convention across the codebase β€” this repo's CLAUDE.md //CLAUDE.md is fmt.Errorf "packagename.FuncName: %w", err : js func r Repo GetDoc ctx context.Context, id string Doc, error { var d Doc if err := r.db.GetContext ctx, &d, qGetDoc, id ; err = nil { return Doc{}, fmt.Errorf "repo.GetDoc: %w", err } return d, nil } Read top-to-bottom, the final message becomes a trace: handler.Query: service.Answer: repo.GetDoc: sql: no rows in result set Rules: add context, not restatement never "error: %w" ; don't capitalize or end with punctuation; never log and return the same error β€” pick one, and log at the boundary that handles it. Is , As // Sentinel: a comparable, exported value callers can test for. var ErrNotFound = errors.New "not found" ErrRateLimit = errors.New "rate limited" // Custom type: when the caller needs structured detail. type ToolError struct { Tool string Code int Err error } func e ToolError Error string { return fmt.Sprintf "tool %s: %v", e.Tool, e.Err } func e ToolError Unwrap error { return e.Err } // makes errors.Is see through it // Callers: if errors.Is err, ErrNotFound { // βœ… works through any wrapping return http.StatusNotFound, nil } var toolErr ToolError if errors.As err, &toolErr { // βœ… extract the typed error metrics.ToolFailures.WithLabelValues toolErr.Tool .Inc } if err == ErrNotFound { } // ❌ breaks the moment someone wraps errors.Is for identity , errors.As for structure . Never compare error strings. // βœ… Handle immediately; the happy path stays at the left margin. resp, err := c.Complete ctx, prompt if err = nil { return fmt.Errorf "agent.Run: %w", err } use resp // βœ… Retry only what's retryable. for attempt := range maxAttempts { out, err = call ctx if err == nil { break } if errors.Is err, ErrRateLimit && isTransient err { return fmt.Errorf "agent.call: %w", err // permanent β†’ stop immediately } select { case <-time.After backoff attempt : case <-ctx.Done : return ctx.Err } } // βœ… Deliberately ignoring an error is written, not implied. = resp.Body.Close defer func { = tx.Rollback } // rollback after a commit is a no-op js // βœ… Collect failures across a batch instead of stopping at the first. var errs error for , chunk := range chunks { if err := index ctx, chunk ; err = nil { errs = append errs, fmt.Errorf "chunk %s: %w", chunk.ID, err } } return errors.Join errs... // nil if the slice is empty panic unwinds the goroutine and crashes the process unless recovered. It is not an exception system. Panic only when the program cannot sensibly continue: an impossible invariant, a programming bug, or failed initialization at startup regexp.MustCompile , template.Must β€” the Must prefix is the convention . Recover only at a process boundary β€” one bad request must not kill the server: func Recoverer next http.Handler http.Handler { return http.HandlerFunc func w http.ResponseWriter, r http.Request { defer func { if rec := recover ; rec = nil { slog.Error "panic in handler", "err", rec, "path", r.URL.Path, "stack", string debug.Stack http.Error w, "internal error", http.StatusInternalServerError } } next.ServeHTTP w, r } } ⚠️ recover only works in the same goroutine. A panic inside go func {…} kills the whole process no matter what your HTTP middleware does β€” every goroutine you spawn needs its own recover, or must be provably panic-free.| Python | Go | |---|---| raise ValueError "bad temp" | return fmt.Errorf "bad temperature %v", t | except ValueError: | if errors.Is err, ErrBadTemp | except SomeError as e: e.field | var e SomeError; errors.As err, &e | raise X from err | fmt.Errorf "context: %w", err | finally: | defer | except Exception: pass | = f and a comment saying why | | Traceback | The wrap chain you built by hand | sys.exit 1 on fatal config | log.Fatal / panic in main only | 🎯 Actionable rules - Wrap with %w and a pkg.Func: prefix at every layer; log once, at the top. errors.Is for sentinels, errors.As for typed detail β€” never string comparison.- Panic only for programmer bugs and startup failures; recover only at boundaries. - Every goroutine you start needs its own panic protection. Go's headline feature. It is also where every serious Go bug lives. go doWork // that's the entire syntax go func id string { … } docID // pass arguments explicitly A goroutine is a user-space thread multiplexed onto OS threads by the Go runtime : ~2 KB of initial stack grown on demand , microsecond creation. A hundred thousand of them in one process is normal; a hundred thousand OS threads is not. The rule that prevents most production incidents: never start a goroutine without knowing how it stops. Every goroutine needs an exit condition β€” a closed channel, a cancelled context, or a finite loop. A goroutine blocked forever on a channel nobody writes to is a leak: its stack, its captured variables, and everything they reference stay alive until the process dies. // ❌ leaks one goroutine per request, forever, if nobody reads results go func { results <- expensive } // βœ… it can always exit go func { select { case results <- expensive : case <-ctx.Done : } } A channel is a typed, concurrency-safe queue. Unbuffered channels are a rendezvous : the sender blocks until a receiver takes the value. ch := make chan Token // unbuffered: synchronous handoff buf := make chan Job, 100 // buffered: sender proceeds until full ch <- tok // send tok := <-ch // receive tok, ok := <-ch // ok == false when the channel is closed AND drained close ch // only the SENDER closes, and only once for tok := range ch { … } // receives until closed Directional types document intent and are checked by the compiler: func produce out chan<- Token { … } // send-only func consume in <-chan Token { … } // receive-only | Operation | On a nil channel | On a closed channel | |---|---|---| | Send | blocks forever | panics | | Receive | blocks forever | returns zero value immediately, ok=false | | Close | panics | panics | Consequences: only ever close from the single owning sender; closing signals "no more values", not "stop". To stop a consumer, cancel its context. select select { case tok := <-tokens: emit tok case err := <-errs: return err case <-ctx.Done : // cancellation, always include it return ctx.Err case <-time.After 5 time.Second : // per-iteration timeout return errors.New "stream stalled" default: // non-blocking: runs if nothing else is ready metrics.Idle.Inc } select blocks until one case is ready, choosing randomly among ready cases. With default it never blocks. ⚠️ time.After allocates a timer per call β€” inside a hot loop use a reusable time.NewTimer / Ticker and stop it. 1. Bounded worker pool β€” N workers over a job channel. The default for embedding, indexing, or crawling: func EmbedAll ctx context.Context, chunks string, workers int float32, error { type result struct { i int vec float32 err error } jobs := make chan int out := make chan result, len chunks var wg sync.WaitGroup for range workers { // fixed number of goroutines wg.Add 1 go func { defer wg.Done for i := range jobs { // exits when jobs is closed v, err := embed ctx, chunks i out <- result{i, v, err} } } } go func { // feed, then close so workers exit defer close jobs for i := range chunks { select { case jobs <- i: case <-ctx.Done : return } } } wg.Wait close out vecs := make float32, len chunks for r := range out { if r.err = nil { return nil, fmt.Errorf "embed chunk %d: %w", r.i, r.err } vecs r.i = r.vec // index carries the order back } return vecs, nil } 2. errgroup β€” the concise version when you just need "run these, stop on first error": import "golang.org/x/sync/errgroup" g, ctx := errgroup.WithContext ctx // ctx is cancelled as soon as one task fails g.SetLimit 8 // ← bounded concurrency, one line results := make Doc, len ids for i, id := range ids { g.Go func error { // Go 1.22+: no i := i needed d, err := fetch ctx, id if err = nil { return fmt.Errorf "fetch %s: %w", id, err } results i = d // βœ… distinct indices β€” no mutex required return nil } } if err := g.Wait ; err = nil { return nil, err } This is Go's asyncio.gather + Semaphore , with cancellation included. 3. Pipeline / fan-in β€” merge several streams into one, the shape behind multi-model or multi-tool streaming: func merge T any ctx context.Context, chans ...<-chan T <-chan T { out := make chan T var wg sync.WaitGroup for , c := range chans { wg.Add 1 go func c <-chan T { defer wg.Done for v := range c { select { case out <- v: case <-ctx.Done : return } } } c } go func { wg.Wait ; close out } // close exactly once, after all senders finish return out } context : cancellation that actually propagates context.Context carries a deadline, a cancellation signal, and request-scoped values down the call tree. Every function that does I/O takes one as its first parameter. ctx, cancel := context.WithTimeout r.Context , 30 time.Second defer cancel // βœ… ALWAYS defer cancel β€” otherwise the timer leaks resp, err := agent.Run ctx, prompt switch { case errors.Is err, context.DeadlineExceeded : http.Error w, "upstream timeout", http.StatusGatewayTimeout case errors.Is err, context.Canceled : return // client hung up; nothing to write } Why it matters for AI services: when a user closes the browser mid-stream, r.Context is cancelled, and that cancellation flows into your model call, your DB query, and every worker goroutine β€” so you stop paying for tokens nobody will read. // Values: request-scoped metadata only, with an unexported key type. type ctxKey struct{} var tenantKey ctxKey ctx = context.WithValue ctx, tenantKey, tenant tenant, ok := ctx.Value tenantKey . string Rules: ctx is the first parameter, never stored in a struct; context.Background only in main /tests; never pass nil ; values are for tracing/tenancy, never for optional arguments. sync : when channels are overkill "Don't communicate by sharing memory; share memory by communicating." …but a mutex around a cache is simpler than a channel, and simpler wins. type Cache struct { mu sync.RWMutex // zero value is ready β€” no initialization m map string float32 } func c Cache Get k string float32, bool { c.mu.RLock // many concurrent readers defer c.mu.RUnlock v, ok := c.m k return v, ok } func c Cache Put k string, v float32 { c.mu.Lock // one writer, excludes readers defer c.mu.Unlock c.m k = v } var once sync.Once once.Do func { tokenizer = loadTokenizer } // exactly-once init var wg sync.WaitGroup // wg.Add before go , wg.Done in a defer var inflight atomic.Int64 // lock-free counters inflight.Add 1 ; defer inflight.Add -1 Use sync.Map only for its two documented patterns write-once/read-many, or disjoint key sets per goroutine ; otherwise a plain map with an RWMutex is faster and clearer. Put the mutex next to the data it protects, and document what it guards. go test -race ./... go run -race ./cmd/api It catches unsynchronized concurrent access at runtime ~10Γ— slower, more memory β€” fine for CI . A data race in Go is undefined behaviour, not just a wrong number: a torn map write crashes the process. | Symptom | Cause | Fix | |---|---|---| | Memory grows forever | Goroutine leak β€” blocked send/receive | Add <-ctx.Done to every select ; close channels | all goroutines are asleep - deadlock | Unbuffered send with no receiver; wg.Wait before Done | Check ownership; wg.Add before go | send on closed channel panic | Multiple senders, or closing to signal "stop" | Only the sole sender closes; cancel via context | | Results in the wrong order | Concurrency doesn't preserve order | Carry an index, or write into a preallocated slice | | Rare corrupt data | Data race | -race , then a mutex or channel | | 429s / OOM under load | Unbounded fan-out | g.SetLimit n or a worker pool | context deadline exceeded everywhere | One deadline shared by N sequential calls | Give each call its own budget | 🎯 Actionable rules - Every goroutine has a known exit path; every blocking select has <-ctx.Done .- Bound concurrency explicitly β€” errgroup.SetLimit or a fixed worker pool. Never go in an unbounded loop. ctx first parameter, defer cancel always.- Run -race in CI, permanently. You don't have to know this to write Go. You do have to know it to explain a p99 latency spike. G = goroutine M = OS thread P = processor a scheduling context, GOMAXPROCS of them P0 ──local run queue── G G G each P owns a queue of runnable Gs P1 ──local run queue── G an idle P steals work from a busy one ↑ bound to an M thread while running global run queue ── overflow ── GOMAXPROCS go.uber.org/automaxprocs or your 500m-CPU pod will spawn 64 Ps and thrash. async / await colouring. Versus Python: asyncio gives you one thread cooperatively multiplexing coroutines, and any blocking call freezes all of them. Go gives you preemptive scheduling across every core with no code-colour distinction. That's the core reason a Go gateway holds 50k streaming connections on hardware where a Python one needs process fan-out. Go's GC is a concurrent, tri-colour mark-and-sweep collector, non-generational and non-compacting. It's tuned for latency, not throughput : sub-millisecond stop-the-world pauses, at the cost of some CPU and headroom. GOGC=100 default: collect when the heap doubles since the last GC GOGC=200 collect half as often β€” more RAM, less CPU GOMEMLIMIT=6GiB soft memory ceiling Go 1.19+ β€” the setting for containers GODEBUG=gctrace=1 ./api one line per GC cycle: heap size, pause, CPU share In containers, set GOMEMLIMIT to ~80% of the pod's memory limit. Without it, Go sizes the heap from GOGC alone, happily grows past the cgroup limit, and gets OOM-killed with no Go-level error. With it, the GC works harder as you approach the ceiling instead of dying.Pointer-heavy structures make GC scan more. Fewer, larger allocations of pointer-free data float32 for embeddings, not float32 is the single biggest GC win in AI workloads. A write in one goroutine is only guaranteed visible to another if they synchronize β€” via a channel operation, a mutex, sync/atomic , sync.Once , or WaitGroup . Without that, the compiler and CPU may reorder freely, and the race detector will eventually tell you. There is no "volatile"; there is sync/atomic . The compiler puts values on the stack free, no GC unless they can outlive the function, in which case they escape to the heap . go build -gcflags='-m' ./... prints "escapes to heap" / "does not escape" Common causes of escape: returning a pointer to a local, storing in an interface, closing over a variable, sending on a channel, fmt.Sprintf . Allocation-reduction techniques, in order of payoff: out := make Doc, 0, len ids // 1. preallocate with capacity β€” avoids log n regrowths m := make map string int, 1000 var b strings.Builder // 2. builders instead of += concatenation b.Grow estimate var bufPool = sync.Pool{ // 3. pool big, short-lived buffers on hot paths New: func any { return new bytes.Buffer }, } buf := bufPool.Get . bytes.Buffer defer func { buf.Reset ; bufPool.Put buf } func s Scanner Fill dst byte int // 4. let the caller own the buffer Do these where a profile says they matter Β§12 , not everywhere. sync.Pool used carelessly is a memory leak with extra steps. | Workload | Winner | Why | |---|---|---| | 20k concurrent SSE streams | Go, decisively | 2 KB goroutines vs event-loop + process fan-out | | Fan-out to 50 tools/APIs per request | Go | errgroup + real parallelism | | JSON/protobuf transformation at volume | Go | Compiled, GC-friendly, no interpreter overhead | | Token/rate accounting, queues, schedulers | Go | Predictable latency, cheap primitives | | Embedding, training, fine-tuning | Python | torch/numpy/CUDA live there | | Data science, notebooks, evaluation | Python | The ecosystem is the product | | Model-specific pre/post-processing | Python | Tokenizers and libraries exist already | 🎯 Actionable rules - In containers: set GOMEMLIMIT ~80% of the limit and make GOMAXPROCS cgroup-aware.- Preallocate slices and maps whose size you know. - Prefer pointer-free bulk data float32 to reduce GC scan time.- Optimize allocations only where a pprof profile points. Go's stdlib is unusually complete: an HTTP/2 server, JSON, TLS, templating, profiling, and testing all ship with the compiler. The list below is what an AI service actually uses. net/http β€” the server mux := http.NewServeMux mux.HandleFunc "POST /v1/query", h.Query // Go 1.22+: method + wildcards mux.HandleFunc "GET /v1/jobs/{id}", h.GetJob // r.PathValue "id" srv := &http.Server{ Addr: ":8080", Handler: Recoverer RequestID Logging mux , // middleware = wrapped handlers ReadHeaderTimeout: 5 time.Second, // βœ… blocks Slowloris; the one people forget ReadTimeout: 30 time.Second, WriteTimeout: 0, // 0 for SSE/streaming endpoints; set it otherwise IdleTimeout: 120 time.Second, MaxHeaderBytes: 1 << 20, } // Graceful shutdown: stop accepting, let in-flight requests finish. go func { if err := srv.ListenAndServe ; err = nil && errors.Is err, http.ErrServerClosed { slog.Error "listen", "err", err ; os.Exit 1 } } ctx, stop := signal.NotifyContext context.Background , os.Interrupt, syscall.SIGTERM defer stop <-ctx.Done shutdownCtx, cancel := context.WithTimeout context.Background , 30 time.Second defer cancel = srv.Shutdown shutdownCtx chi adds routers, groups, and middleware chains on top of http.Handler without inventing a new handler type β€” which is why it composes with everything and why this repo uses it . net/http β€” the client js var client = &http.Client{ // βœ… ONE client for the process, reused Timeout: 60 time.Second, // total budget, including body read Transport: &http.Transport{ MaxIdleConns: 200, MaxIdleConnsPerHost: 100, // default is 2 β€” far too low for an LLM proxy IdleConnTimeout: 90 time.Second, }, } req, err := http.NewRequestWithContext ctx, http.MethodPost, url, bytes.NewReader body if err = nil { return fmt.Errorf "llm.Complete: %w", err } req.Header.Set "Content-Type", "application/json" resp, err := client.Do req if err = nil { return fmt.Errorf "llm.Complete: %w", err } defer resp.Body.Close // βœ… ALWAYS β€” otherwise the connection leaks if resp.StatusCode = http.StatusOK { b, := io.ReadAll io.LimitReader resp.Body, 4<<10 // cap what you read on errors return fmt.Errorf "llm.Complete: status %d: %s", resp.StatusCode, b } Three non-negotiables: reuse the client, always close the body, always pass a context. Creating an http.Client per request disables connection pooling and exhausts sockets under load. encoding/json type QueryIn struct { Query string json:"query" Temperature float64 json:"temperature,omitempty" // omit when zero Tools string json:"tools,omitempty" internal string json:"-" // never marshalled } b, err := json.Marshal v err = json.Unmarshal b, &v // note the pointer dec := json.NewDecoder r.Body // βœ… stream, don't ReadAll dec.DisallowUnknownFields // βœ… typo'd client fields become errors if err := dec.Decode &in ; err = nil { http.Error w, "invalid body", http.StatusBadRequest ; return } var raw json.RawMessage // defer parsing tool args enc := json.NewEncoder w ; enc.Encode out // stream the response out ⚠️ Only exported fields are marshalled. ⚠️ Unmarshalling into map string any turns every number into float64 β€” decode into a struct whenever you can. For hot paths, json.Decoder on the body avoids materializing the whole payload. Custom marshalling for domain types: func r Role MarshalJSON byte, error { return json.Marshal string r } log/slog β€” structured logging Go 1.21+ logger := slog.New slog.NewJSONHandler os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo} slog.SetDefault logger slog.Info "tool completed", "tool", name, "ms", elapsed.Milliseconds , "tokens", n slog.Error "model call failed", "err", err, "model", cfg.Model, "attempt", i reqLog := logger.With "request id", rid, "tenant", tenant // bind once, reuse per request reqLog.Info "received" Structured key-value output is what makes logs queryable in Loki/Datadog. Never log prompts, keys, or full request bodies β€” log ids, counts, durations, and truncated previews. time time.Now ; time.Since start // monotonic for durations 30 time.Second; 500 time.Millisecond // Durations are typed ints β€” no unit bugs t.Format time.RFC3339 ; time.Parse time.RFC3339, s time.Now .UTC // store UTC, convert at the edge tick := time.NewTicker 10 time.Second defer tick.Stop // βœ… tickers leak if not stopped select { case <-tick.C: flushMetrics case <-ctx.Done : return } io and bufio β€” the composable plumbing io.Copy dst, src // stream, constant memory io.ReadAll io.LimitReader r, 10<<20 // βœ… always cap untrusted input io.MultiWriter w, &buf // tee the response into a buffer sc := bufio.NewScanner resp.Body // line-by-line: perfect for SSE sc.Buffer make byte, 0, 64 1024 , 1<<20 // βœ… raise the 64 KB line limit for sc.Scan { line := sc.Text … } if err := sc.Err ; err = nil { … } // βœ… Scan returning false isn't always EOF | Package | Use it for | |---|---| context | Cancellation and deadlines Β§6.5 | sync / sync/atomic | Mutexes, WaitGroup , Once , counters Β§6.6 | errors | Is , As , Join , Unwrap Β§5 | strconv / strings / bytes | Conversion and text handling Β§2.4 | regexp | RE2 β€” linear time, no catastrophic backtracking; MustCompile at package level | os / os/signal | Env, files, SIGTERM handling | flag | Small CLIs; use cobra for a command tree | embed | //go:embed prompts/ .md β€” bake prompts and migrations into the binary | text/template | Prompt templating with named fields | database/sql + sqlx , pgx | SQL; always QueryContext , always defer rows.Close , always check rows.Err | encoding/base64 , crypto/ | Tokens, signatures, crypto/rand for secrets | net/http/httptest | In-process HTTP tests Β§10 | runtime/pprof , net/http/pprof | Profiling Β§12 | testing | Tests, benchmarks, fuzzing β€” all built in | Third-party worth adopting: golang.org/x/sync/errgroup and singleflight , go-chi/chi , jmoiron/sqlx , stretchr/testify/require , pressly/goose , golang.org/x/time/rate , and OpenTelemetry for traces. Go culture keeps dependency trees small β€” prefer the stdlib until it genuinely hurts. 🎯 Actionable rules - One http.Client per process with a timeout and a tuned transport; defer resp.Body.Close always.- Explicit http.Server timeouts and graceful shutdown on SIGTERM. json.Decoder + DisallowUnknownFields on request bodies; io.LimitReader on anything untrusted. slog with key-value pairs from day one β€” retrofitting structure is miserable. What Go is actually for in an AI stack: the request path, the fan-out, and the streaming. func c LLM Stream ctx context.Context, prompt string, out chan<- string error { req, := http.NewRequestWithContext ctx, http.MethodPost, c.url, encode prompt req.Header.Set "Accept", "text/event-stream" resp, err := c.http.Do req if err = nil { return fmt.Errorf "llm.Stream: %w", err } defer resp.Body.Close sc := bufio.NewScanner resp.Body sc.Buffer make byte, 0, 64 1024 , 1<<20 // model chunks exceed the 64 KB default for sc.Scan { line, ok := strings.CutPrefix sc.Text , "data: " if ok || line == "" { continue } if line == " DONE " { return nil } var ev struct { Delta struct{ Text string } json:"delta" } if err := json.Unmarshal byte line , &ev ; err = nil { return fmt.Errorf "llm.Stream: decode %q: %w", truncate line, 80 , err } select { case out <- ev.Delta.Text: case <-ctx.Done : // client disconnected: stop paying for tokens return ctx.Err } } return sc.Err } func h Handler Stream w http.ResponseWriter, r http.Request { rc := http.NewResponseController w // Go 1.20+; replaces the http.Flusher cast w.Header .Set "Content-Type", "text/event-stream" w.Header .Set "Cache-Control", "no-cache" w.Header .Set "X-Accel-Buffering", "no" // stop nginx from buffering your stream ctx := r.Context // cancelled when the client goes away tokens := make chan string, 16 errc := make chan error, 1 go func { errc <- h.llm.Stream ctx, r.FormValue "q" , tokens ; close tokens } for { select { case tok, ok := <-tokens: if ok { fmt.Fprint w, "data: DONE \n\n" = rc.Flush return } fmt.Fprintf w, "data: %s\n\n", tok = rc.Flush // βœ… without Flush nothing reaches the client case <-ctx.Done : return case <-time.After 30 time.Second : slog.Warn "stream stalled", "path", r.URL.Path return } } } Remember to set WriteTimeout: 0 on the server for streaming routes Β§8.1 , or the connection dies mid-answer. type Tool struct { Name string json:"name" Description string json:"description" Schema json.RawMessage json:"input schema" // sent verbatim to the model Run func ctx context.Context, args json.RawMessage string, error json:"-" } type Registry struct { mu sync.RWMutex tools map string Tool } func r Registry Register t Tool error { r.mu.Lock ; defer r.mu.Unlock if , dup := r.tools t.Name ; dup { return fmt.Errorf "registry.Register: duplicate tool %q", t.Name } r.tools t.Name = t return nil } func r Registry Dispatch ctx context.Context, name string, args json.RawMessage string, error { r.mu.RLock ; t, ok := r.tools name ; r.mu.RUnlock if ok { return "", fmt.Errorf "registry.Dispatch: unknown tool %q", name // never trust the model } ctx, cancel := context.WithTimeout ctx, 30 time.Second // βœ… per-tool budget defer cancel return t.Run ctx, args } Two things the model must never control: which tools exist, and how long they may run. import "golang.org/x/time/rate" type Client struct { http http.Client limiter rate.Limiter // rate.NewLimiter rate.Limit 50 , 100 β†’ 50 rps, burst 100 sem chan struct{} // concurrency cap: make chan struct{}, 16 } func c Client Complete ctx context.Context, prompt string string, error { if err := c.limiter.Wait ctx ; err = nil { // blocks or returns on cancellation return "", fmt.Errorf "llm.Complete: rate wait: %w", err } select { // bound in-flight requests case c.sem <- struct{}{}: defer func { <-c.sem } case <-ctx.Done : return "", ctx.Err } var lastErr error for attempt := range 4 { out, err := c.do ctx, prompt if err == nil { return out, nil } lastErr = err var re RetryableError if errors.As err, &re { return "", fmt.Errorf "llm.Complete: %w", err // permanent β†’ stop } delay := re.RetryAfter // honour the server's hint if delay == 0 { delay = time.Duration 1<