Go
Simple, fast and built for servers.
Go was designed at Google to make large networked software simple to write and fast to run. Docker, Kubernetes and Ollama, the most popular way to run LLMs locally, are written in Go.
Why learn Go?
Go deliberately has few features, so you can learn the whole language in days and read anyone's code. Built-in concurrency, fast compilation and single-file binaries make it a favourite for the servers and tools that run AI in production.
- Easy to learn: about 25 keywords and one obvious way to do most things.
- Concurrency built in: goroutines and channels make it easy to handle thousands of requests at once.
- Deploys anywhere: compiles to one static binary with no runtime to install.
- AI infrastructure: Ollama, many vector databases and LLM gateways are written in Go.
Set up and run your first program
Download Go from go.dev/dl. Every project is a module with a go.mod file.
go version
mkdir hello-ai && cd hello-ai
go mod init example.com/hello-ai
go run .package main
import "fmt"
func main() {
name := "Ada"
fmt.Printf("Hello, AI! Welcome to Go, %s.\n", name)
}go run . compiles and runs; go build produces a binary you can copy to any machine with the same operating system.Variables, types and constants
Use := to declare and infer a variable inside functions, or var with an explicit type. Unset variables get a zero value (0, "", false or nil), so there is no "undefined". Constants use const.
package main
import "fmt"
const HeadDim = 128
func main() {
layers := 32 // int
var lr float64 = 3e-4
model := "llama3.1" // string
var streaming bool // zero value: false
params := int64(8_030_000_000)
fmt.Println(model, layers, lr, streaming, HeadDim)
fmt.Printf("FP16 size: %.1f GB\n", float64(params)*2/1e9)
}Control flow
Go has one loop keyword, for, which covers classic loops, while-style loops and range over collections. if can start with a short statement, and switch does not fall through by default.
package main
import "fmt"
func main() {
for i := 0; i < 3; i++ {
fmt.Println("epoch", i)
}
n := 3
for n > 0 { // while-style
n--
}
for i, gpu := range []string{"H100", "B200"} {
fmt.Println(i, gpu)
}
if vram := 24; vram >= 80 {
fmt.Println("data center")
} else {
fmt.Println("desktop")
}
switch p := 8; p {
case 16:
fmt.Println("FP16")
case 8:
fmt.Println("FP8")
default:
fmt.Println("other")
}
}Functions and multiple returns
Functions can return several values, which Go uses for returning a result and an error together. Functions are values and can be closures. defer schedules a call to run when the function exits, perfect for clean-up.
package main
import "fmt"
func memoryGB(paramsB, bytes float64) float64 {
return paramsB * bytes
}
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
func main() {
defer fmt.Println("runs last")
fmt.Println(memoryGB(70, 2))
if q, err := divide(1, 0); err != nil {
fmt.Println("error:", err)
} else {
fmt.Println(q)
}
count := 0
next := func() int { count++; return count } // closure
next()
fmt.Println(next())
}Arrays, slices and maps
A slice is a growable view over an array and is what you will use almost always. append adds items. A map is a hash table. Use make to create them with a starting size.
package main
import (
"fmt"
"sort"
)
func main() {
losses := []float64{0.9, 0.4, 0.7}
losses = append(losses, 0.2, 0.1)
sort.Float64s(losses)
fmt.Println(losses, len(losses), losses[1:3])
vram := map[string]int{"H100": 80, "B200": 180}
vram["MI300X"] = 192
if gb, ok := vram["RTX 5090"]; ok {
fmt.Println(gb)
} else {
fmt.Println("not in the map")
}
for name, gb := range vram {
fmt.Println(name, gb)
}
}Structs, methods and interfaces
A struct groups fields; methods attach to a type with a receiver. Interfaces list methods, and any type that has those methods satisfies the interface automatically, with no implements keyword.
package main
import "fmt"
type GPU struct {
Name string
VRAMGB int
}
func (g GPU) Fits(modelGB int) bool { return modelGB <= g.VRAMGB }
type Describer interface {
Describe() string
}
func (g GPU) Describe() string { return fmt.Sprintf("%s (%d GB)", g.Name, g.VRAMGB) }
func show(d Describer) { fmt.Println(d.Describe()) }
func main() {
g := GPU{Name: "RTX 5090", VRAMGB: 32}
fmt.Println(g.Fits(16))
show(g) // GPU satisfies Describer automatically
}Pointers
Go has pointers but no pointer arithmetic, so they are safe. Use a pointer receiver (*T) when a method must change the struct or when the struct is large. Memory is managed by a garbage collector.
package main
import "fmt"
type Counter struct{ N int }
func (c *Counter) Inc() { c.N++ } // pointer receiver: changes the original
func main() {
c := Counter{}
c.Inc()
c.Inc()
fmt.Println(c.N) // 2
x := 10
p := &x
*p = 20
fmt.Println(x) // 20
}Errors as values
Go has no exceptions. Functions return an error as their last value, and you check it right away with if err != nil. Wrap errors with %w to add context, and inspect them with errors.Is and errors.As.
package main
import (
"errors"
"fmt"
"os"
)
var ErrTooBig = errors.New("model does not fit")
func load(modelGB, vramGB int) error {
if modelGB > vramGB {
return fmt.Errorf("loading %d GB model: %w", modelGB, ErrTooBig)
}
return nil
}
func main() {
if err := load(140, 24); errors.Is(err, ErrTooBig) {
fmt.Println(err)
}
if _, err := os.ReadFile("missing.txt"); err != nil {
fmt.Println("could not read:", err)
}
}Packages and modules
Each folder is a package. Names that start with a capital letter are exported (public). Add third-party modules with go get, which records them in go.mod.
// file: mathx/mathx.go
package mathx
// MemoryGB is exported because it starts with a capital letter.
func MemoryGB(paramsB, bytes float64) float64 { return paramsB * bytes }
// file: main.go
// package main
// import "example.com/hello-ai/mathx"
// fmt.Println(mathx.MemoryGB(8, 2))
// terminal: go get github.com/openai/openai-goFiles and JSON
The os package reads and writes files, and encoding/json converts structs to JSON using field tags.
package main
import (
"encoding/json"
"fmt"
"os"
)
type Results struct {
Model string `json:"model"`
Accuracy float64 `json:"accuracy"`
}
func main() {
data, _ := json.MarshalIndent(Results{"llama3.1", 0.91}, "", " ")
os.WriteFile("results.json", data, 0644)
var r Results
raw, _ := os.ReadFile("results.json")
json.Unmarshal(raw, &r)
fmt.Println(r.Model, r.Accuracy)
}Goroutines and channels
Put go before a function call and it runs concurrently in a lightweight goroutine; Go can run hundreds of thousands of them. Goroutines communicate through channels, and sync.WaitGroup waits for a group to finish.
package main
import (
"fmt"
"sync"
"time"
)
func ask(model string, out chan<- string, wg *sync.WaitGroup) {
defer wg.Done()
time.Sleep(time.Second) // stands in for an API call
out <- model + " answered"
}
func main() {
out := make(chan string, 3)
var wg sync.WaitGroup
for _, m := range []string{"a", "b", "c"} {
wg.Add(1)
go ask(m, out, &wg)
}
wg.Wait()
close(out)
for answer := range out {
fmt.Println(answer) // all three in about 1 second
}
}Testing and tooling
Tests go in files ending _test.go and run with go test ./.... gofmt formats all Go code the same way, go vet finds suspicious code, and benchmarks are built into the testing package.
package mathx
import "testing"
func TestMemoryGB(t *testing.T) {
if got := MemoryGB(8, 2); got != 16 {
t.Errorf("MemoryGB(8, 2) = %v, want 16", got)
}
}AI with Go
Go is rarely used to train models, but it is everywhere in AI infrastructure: model servers, API gateways, vector databases, agents and the tools that deploy them. Its speed and concurrency make it ideal for serving many users at once.
To understand the core idea of training, it helps to write gradient descent by hand, with no libraries.
The essential libraries
Gradient descent from scratch
This program learns y = 3x + 0.5 using nothing but loops and arithmetic. It is exactly what deep-learning frameworks do, millions of times faster and on GPUs.
package main
import (
"fmt"
"math/rand"
)
func main() {
var xs, ys []float64
for i := 0; i < 100; i++ {
x := -1 + 2*float64(i)/99
xs = append(xs, x)
ys = append(ys, 3*x+0.5+0.1*rand.NormFloat64())
}
w, b, lr := 0.0, 0.0, 0.1
for step := 0; step < 500; step++ {
var gw, gb float64
for i := range xs {
err := (w*xs[i] + b) - ys[i] // prediction error
gw += 2 * err * xs[i] / float64(len(xs))
gb += 2 * err / float64(len(xs))
}
w -= lr * gw // step downhill
b -= lr * gb
}
fmt.Printf("w = %.2f, b = %.2f\n", w, b) // close to 3.00 and 0.50
}Build an AI agent in Go
Go's standard library has everything an agent needs: net/http for requests and encoding/json for messages. Because each conversation can run in its own goroutine, one Go server can host thousands of agents.
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
func getWeather(city string) string { return "It is 24°C and sunny in " + city + "." }
func chat(body map[string]any) (map[string]any, error) {
b, _ := json.Marshal(body)
resp, err := http.Post("http://localhost:11434/v1/chat/completions", "application/json", bytes.NewReader(b))
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out map[string]any
err = json.NewDecoder(resp.Body).Decode(&out)
return out, err
}
func main() {
tools := []any{map[string]any{
"type": "function",
"function": map[string]any{
"name": "get_weather",
"description": "Get the current weather for a city",
"parameters": map[string]any{
"type": "object",
"properties": map[string]any{"city": map[string]any{"type": "string"}},
"required": []string{"city"},
},
},
}}
messages := []any{map[string]any{"role": "user", "content": "Should I take an umbrella in Lahore today?"}}
for {
r, err := chat(map[string]any{"model": "llama3.1", "messages": messages, "tools": tools})
if err != nil {
panic(err)
}
msg := r["choices"].([]any)[0].(map[string]any)["message"].(map[string]any)
messages = append(messages, msg)
calls, _ := msg["tool_calls"].([]any)
if len(calls) == 0 {
fmt.Println(msg["content"])
return
}
for _, c := range calls {
call := c.(map[string]any)
fn := call["function"].(map[string]any)
var args map[string]string
json.Unmarshal([]byte(fn["arguments"].(string)), &args)
messages = append(messages, map[string]any{
"role": "tool", "tool_call_id": call["id"], "content": getWeather(args["city"]),
})
}
}
}In real code, define structs for the request and response instead of map[string]any, and add a limit on how many tool rounds the agent may take.
ollama pull llama3.1, and keep it running. The code talks to its OpenAI-compatible API at http://localhost:11434/v1, so the same program also works with vLLM, llama.cpp server or a cloud API by changing the URL, model name and adding an API key.Go learning roadmap
Tick items off as you go. Your progress is saved in this browser.
Beginner
Intermediate
Advanced
Projects to build
Go cheat sheet
| Task | Syntax |
|---|---|
| Declare | x := 5; var y int |
fmt.Printf("%s %d\n", s, n) | |
| Slice | xs := []int{1, 2}; xs = append(xs, 3) |
| Map | m := map[string]int{"a": 1} |
| Loop | for i, v := range xs {} |
| Function | func add(a, b int) int { return a + b } |
| Error check | if err != nil { return err } |
| Struct | type P struct{ X float64 } |
| Method | func (p P) Len() float64 {} |
| Goroutine | go work() |
| Channel | ch := make(chan int); ch <- 1; v := <-ch |
| Test | go test ./... |
Common mistakes
- Ignoring errors with
_: you will miss failures. Check every error. - Writing to a nil map: declare maps with
makeor a literal before adding keys. - Goroutine leaks: make sure every goroutine can finish; use
contextto cancel work. - Capturing loop variables: fixed in Go 1.22, but older code may share one variable between goroutines.
- Overusing
map[string]any: define structs for clarity and type safety.
Where to go next
- A Tour of Go — interactive, in the browser
- Effective Go
- Go by Example
- Ollama source code
On TensorCore.si: Best GPU for local LLMs, LLM inference simulator.
Keyboard: [ previous language · ] next language · / search the library