Skip to content
Volume G · The TensorCore.si Library

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.

Prologue

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.
Prologue

Set up and run your first program

Download Go from go.dev/dl. Every project is a module with a go.mod file.

terminal
go version
mkdir hello-ai && cd hello-ai
go mod init example.com/hello-ai
go run .
main.go
package main

import "fmt"

func main() {
	name := "Ada"
	fmt.Printf("Hello, AI! Welcome to Go, %s.\n", name)
}
Tipgo run . compiles and runs; go build produces a binary you can copy to any machine with the same operating system.
Chapter 1

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.

variables.go
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)
}
Chapter 2

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.

control.go
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")
	}
}
Chapter 3

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.

functions.go
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())
}
Chapter 4

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.

collections.go
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)
	}
}
Chapter 5

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.

structs.go
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
}
Chapter 6

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.

pointers.go
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
}
Chapter 7

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.

errors.go
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)
	}
}
Chapter 8

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.

mathx/mathx.go
// 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-go
Chapter 9

Files and JSON

The os package reads and writes files, and encoding/json converts structs to JSON using field tags.

files.go
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)
}
Chapter 10

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.

concurrency.go
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
	}
}
Chapter 11

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.

mathx/mathx_test.go
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)
	}
}
Part II · AI with Go

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

OllamaThe popular local-LLM runner, written in Go.
openai-goThe official OpenAI client for Go.
LangChainGoChains, retrieval and agents for Go.
Genkit for GoGoogle's framework for AI apps and agents.
GoMLXMachine learning in Go with an XLA back end.
onnxruntime_goRun ONNX models from Go.
Weaviate / MilvusVector databases with Go cores or clients.
GorgoniaGraph-based maths and neural networks.

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.

gradient.go
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
}
Part III · Build an AI agent

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.

Your goal→Model plans→Tool call→Your code runs the tool→Result back to model→Final answer
agent.go
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.

Run it locallyInstall Ollama, run 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.
Part IV · Your path

Go learning roadmap

Tick items off as you go. Your progress is saved in this browser.

Beginner

Intermediate

Advanced

Part IV · Your path

Projects to build

URL shortenerAn HTTP service that stores short links in a map, then a database.
Concurrent downloaderDownload many files at once with goroutines and a progress bar.
Chat serverA WebSocket chat room for many users.
LLM gatewayA proxy that routes requests to several model APIs and caches answers.
Agent APIExpose the agent above as an HTTP endpoint.
Log analyserParse gigabytes of logs in parallel and summarise errors.
Reference

Go cheat sheet

TaskSyntax
Declarex := 5; var y int
Printfmt.Printf("%s %d\n", s, n)
Slicexs := []int{1, 2}; xs = append(xs, 3)
Mapm := map[string]int{"a": 1}
Loopfor i, v := range xs {}
Functionfunc add(a, b int) int { return a + b }
Error checkif err != nil { return err }
Structtype P struct{ X float64 }
Methodfunc (p P) Len() float64 {}
Goroutinego work()
Channelch := make(chan int); ch <- 1; v := <-ch
Testgo test ./...
Reference

Common mistakes

  • Ignoring errors with _: you will miss failures. Check every error.
  • Writing to a nil map: declare maps with make or a literal before adding keys.
  • Goroutine leaks: make sure every goroutine can finish; use context to 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.
Reference

Where to go next

On TensorCore.si: Best GPU for local LLMs, LLM inference simulator.

Keyboard: [ previous language · ] next language · / search the library