Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save fff7d1bc/1a674aa7d93a79e693ea17456199e06a to your computer and use it in GitHub Desktop.

Select an option

Save fff7d1bc/1a674aa7d93a79e693ea17456199e06a to your computer and use it in GitHub Desktop.
go_one_day_course_for_python_engineers.md

One-Day Go Course for Experienced Python Engineers

Audience: experienced programmers who know Python well and are comfortable with Linux, cloud platforms, CLIs, HTTP APIs, automation, and production systems.

Goal: become able to read, write, review, and debug practical Go code without trying to translate Python idioms directly into Go.

Last reviewed: 2026-05-06.

This course uses modern Go: modules, context, table-driven tests, structured logging with log/slog, generics awareness, and production-oriented tooling. It avoids spending time on installation basics.


Table of contents

  1. The Go mental model
  2. Suggested one-day pacing
  3. Python-to-Go map
  4. Project structure, packages, modules, and commands
  5. Basic syntax and semantics
  6. Types, zero values, conversions, and naming
  7. Strings, bytes, and Unicode
  8. Slices, arrays, and maps
  9. Structs, methods, pointers, and receivers
  10. Functions, multiple returns, errors, and defer
  11. Interfaces and composition
  12. Standard library essentials for cloud/Linux work
  13. Context: cancellation, timeouts, and request scope
  14. Concurrency: goroutines, channels, mutexes, and leaks
  15. Testing, benchmarking, fuzzing, and testability
  16. Generics: useful, but not the first tool
  17. Memory, performance, and runtime intuition
  18. Production workflow: CI, security, builds, and diagnostics
  19. Idiomatic style and code review checklist
  20. End-to-end reference pattern
  21. Common pitfalls
  22. Learning resources

1. The Go mental model

Go is a small, compiled, statically typed language designed for readable, maintainable systems code. It is not Python with braces. The syntax is easy; the important shift is adopting Go's design taste.

Python often rewards flexibility, expressiveness, introspection, and compactness. Go rewards explicitness, small abstractions, boring structure, local reasoning, and simple failure paths.

A useful shorthand:

Python instinct: make it expressive and flexible.
Go instinct:    make it explicit, typed, small, and obvious.

Go is especially good for:

  • CLIs and automation tools;
  • HTTP APIs and agents;
  • Kubernetes/controllers/operators;
  • cloud SDK wrappers and infrastructure tools;
  • network services;
  • concurrent I/O-heavy programs;
  • single-binary deployment.

Go is less pleasant when you want:

  • a highly dynamic scripting style;
  • heavy metaprogramming;
  • deep inheritance hierarchies;
  • a rich REPL-oriented workflow;
  • very terse data transformation pipelines.

The most important Go habits:

  1. Return errors explicitly.
  2. Prefer concrete data structures until abstraction is needed.
  3. Prefer small interfaces, usually defined by the consumer.
  4. Pass context.Context through I/O and long-running operations.
  5. Use the standard library first.
  6. Test with go test ./... constantly.
  7. Do not hide complexity behind clever helpers.
  8. Let gofmt decide formatting.

2. Suggested one-day pacing

This is not a task list. It is a practical order for learning the language in roughly eight focused hours.

Block Topic What should click
1 Mental model, project structure, modules How Go code is organized and built
2 Syntax, types, zero values, control flow How to read ordinary Go code
3 Slices, maps, strings, structs The data model you will use daily
4 Functions, methods, pointers, errors, defer How Go expresses behavior and failure
5 Interfaces and composition How Go replaces Python duck typing and inheritance
6 Standard library, JSON, HTTP, files, CLI basics How to write useful cloud/Linux tools
7 Context and concurrency How to avoid leaks and race conditions
8 Testing, production workflow, generics, pitfalls How to review and ship Go code

For an experienced programmer, the sections that deserve the most attention are not the basic syntax. Slow down on these:

  • slice aliasing and allocation;
  • map zero values and key existence;
  • pointer receivers versus value receivers;
  • error wrapping and matching;
  • interface values, especially typed nils;
  • cancellation with context;
  • goroutine lifetime management;
  • package boundaries;
  • table-driven tests;
  • dependency/module versioning.

3. Python-to-Go map

Python concept Go equivalent or habit
list Slice, for example []string
dict Map, for example map[string]int
set Usually map[T]struct{}
tuple Usually struct, multiple return values, or array in rare cases
None nil, but only for pointer-like types
Exceptions Returned error values
try/finally or with defer
Classes struct plus methods
Inheritance Composition and embedding
Duck typing Structural interfaces
Type hints Actual compile-time types
asyncio Goroutines, channels, sync, context
requests net/http
json encoding/json
argparse flag, or a third-party CLI package for larger CLIs
pytest built-in testing package
Virtualenv Usually not needed; modules manage dependencies
pip install package go get package@version for dependencies
pipx install tool go install tool@version for commands
black / formatting choices gofmt / go fmt
Script files Packages and commands

The table helps with orientation, but do not overuse it. Good Go usually looks more verbose and flatter than Python.


4. Project structure, packages, modules, and commands

4.1 The smallest executable

package main

import "fmt"

func main() {
	fmt.Println("hello")
}

Important pieces:

package main

A package named main can build an executable command.

func main()

This is the process entry point. There is no if __name__ == "__main__".

4.2 Packages are directories

In Python, a file is usually the key module unit. In Go, a directory is the package unit.

project/
  go.mod
  main.go
  config.go

If both files say:

package main

then they are part of the same package and compile together.

A common beginner mistake is expecting each file to be isolated. It is not. Files in the same package share package-level declarations.

4.3 Modules

A Go module is a versioned collection of packages. The go.mod file declares the module path, Go version, and dependencies.

go mod init github.com/example/mytool

Example go.mod:

module github.com/example/mytool

go 1.26

require github.com/some/dependency v1.2.3

A package inside this module might be imported as:

import "github.com/example/mytool/internal/config"

4.4 Common layout

For a small CLI or service:

mytool/
  go.mod
  cmd/
    mytool/
      main.go
  internal/
    config/
      config.go
    client/
      client.go
    instances/
      instances.go
  README.md

Meaning:

  • cmd/mytool: executable entrypoint.
  • internal/...: packages that cannot be imported from outside the parent module tree.
  • config, client, instances: domain packages.

Avoid starting with too many layers. This is often enough:

myservice/
  go.mod
  main.go
  config.go
  server.go
  store.go

Add directories when boundaries are obvious.

4.5 Package naming

Good package names are short, lowercase, and domain-specific:

config
server
client
auth
store
instances
billing

Suspicious names:

utils
helpers
common
misc
base
manager
factory

These are not forbidden, but they often hide unclear design.

4.6 Daily commands

go run ./cmd/mytool
go build ./cmd/mytool
go test ./...
go test -race ./...
go fmt ./...
go vet ./...
go mod tidy

Useful variants:

# Run one test by name.
go test ./internal/config -run TestLoadConfig

# Run all tests without using cached successful results.
go test ./... -count=1

# Show verbose test output.
go test ./... -v

# List packages in the module.
go list ./...

# Explain the selected Go environment.
go env

# See the selected toolchain behavior.
go env GOTOOLCHAIN

4.7 go get versus go install

Use go get to change dependencies in the current module:

go get github.com/example/lib@v1.2.3
go mod tidy

Use go install to install a command-line tool:

go install golang.org/x/vuln/cmd/govulncheck@latest

4.8 Cross-compilation

Go makes cross-compilation straightforward for many programs:

GOOS=linux GOARCH=amd64 go build -o mytool-linux-amd64 ./cmd/mytool
GOOS=linux GOARCH=arm64 go build -o mytool-linux-arm64 ./cmd/mytool
GOOS=darwin GOARCH=arm64 go build -o mytool-darwin-arm64 ./cmd/mytool

For fully static-style Linux builds, cgo changes the story:

CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o mytool ./cmd/mytool

Be careful with DNS, certificates, libc assumptions, and packages that require cgo.

4.9 Toolchains

Modern Go can select toolchains based on go.mod, go.work, and GOTOOLCHAIN. This means a repository can effectively declare the minimum Go version it expects.

Practical advice:

  • Keep the go line intentional.
  • Do not randomly bump it in generated changes.
  • In CI, print go version.
  • In reproducible environments, decide whether automatic toolchain downloads are acceptable.

5. Basic syntax and semantics

5.1 Declarations

var name string = "alice"
var count int = 3
var enabled bool

region := "eu-west-1"
timeoutSeconds := 30

const serviceName = "inventory-api"

Inside functions, := is very common:

x := 10

It declares a new variable and infers its type.

Outside functions, use var or const:

var buildVersion = "dev"
const defaultPort = 8080

5.2 Reassignment versus declaration

x := 1 // declaration
x = 2  // assignment

This is invalid because x already exists and no new variable is introduced:

x := 1
x := 2 // compile error

But this is valid because y is new:

x := 1
x, y := 2, 3

That can be useful, but it can also hide shadowing bugs.

5.3 Control flow

if needs no parentheses:

if count > 10 {
	fmt.Println("many")
} else {
	fmt.Println("few")
}

Very common pattern:

if err := doThing(); err != nil {
	return err
}

Here err only exists inside the if statement.

5.4 Loops

Go has one loop keyword: for.

Classic loop:

for i := 0; i < 3; i++ {
	fmt.Println(i)
}

While-style loop:

for running {
	doWork()
}

Infinite loop:

for {
	serve()
}

Range over a slice:

names := []string{"alice", "bob", "carol"}

for i, name := range names {
	fmt.Println(i, name)
}

Ignore a value with _:

for _, name := range names {
	fmt.Println(name)
}

5.5 switch

switch status {
case "running":
	fmt.Println("ok")
case "stopped", "failed":
	fmt.Println("not healthy")
default:
	fmt.Println("unknown")
}

Go switch does not fall through by default. Use fallthrough explicitly, rarely.

Conditionless switch:

switch {
case age < 18:
	fmt.Println("minor")
case age < 65:
	fmt.Println("adult")
default:
	fmt.Println("senior")
}

5.6 Formatting

Use:

go fmt ./...

Go formatting is intentionally not a personal style choice. This is one of the best things about Go in teams.


6. Types, zero values, conversions, and naming

6.1 Zero values

Every Go type has a zero value.

var n int        // 0
var s string     // ""
var ok bool      // false
var p *int       // nil
var xs []int     // nil slice
var m map[string]int // nil map

Python has no equivalent for an unassigned local variable. In Go, declared variables always have a value.

Zero values are central to Go design. A good Go type often has a useful zero value.

Example:

type Counter struct {
	value int
}

func (c *Counter) Inc() {
	c.value++
}

This works immediately:

var c Counter
c.Inc()

6.2 Numeric types and conversions

Go does not freely mix numeric types.

var x int = 10
var y int64 = 20

// z := x + y // compile error
z := int64(x) + y

Common numeric types:

int
int64
uint64
float64
byte // alias for uint8
rune // alias for int32

Practical advice:

  • Use int for counts and indexes inside memory.
  • Use explicit-width types like int64 for wire formats, timestamps, storage, and APIs.
  • Avoid unsigned integers unless the API or bit operation really requires them.

6.3 Exported names

Go uses capitalization for visibility.

func LoadConfig() {} // exported from package
func parseConfig() {} // package-private

type Config struct {
	Region string // exported field
	secret string // package-private field
}

There is no public, private, or _private convention.

6.4 Type aliases versus defined types

Defined type:

type Region string

This creates a distinct type from string.

func Deploy(region Region) {}

Deploy("eu-west-1") // allowed because string literal can convert

r := "eu-west-1"
// Deploy(r) // compile error
Deploy(Region(r))

Type alias:

type Region = string

This is just another name for string.

Use defined types when the distinction matters.

6.5 Constants

const defaultTimeout = 30
const serviceName = "inventory"

Enums are usually represented with typed constants:

type InstanceState string

const (
	InstanceRunning InstanceState = "running"
	InstanceStopped InstanceState = "stopped"
	InstanceFailed  InstanceState = "failed"
)

For integer-like enums:

type Level int

const (
	LevelDebug Level = iota
	LevelInfo
	LevelWarn
	LevelError
)

iota is useful but can be overused. For public APIs, string constants are often more stable and debuggable.

6.6 Shadowing

Shadowing means declaring a new variable with the same name in an inner scope.

err := doOne()
if err != nil {
	return err
}

if result, err := doTwo(); err != nil {
	return err
} else {
	fmt.Println(result)
}

This is normal. But accidental shadowing can break code:

var client *Client

if enabled {
	client := NewClient() // new local variable, outer client remains nil
	_ = client
}

// client is still nil here

When reviewing generated Go, watch for := in places where = was intended.


7. Strings, bytes, and Unicode

7.1 Strings are bytes, not characters

A Go string is an immutable sequence of bytes.

s := "ą"
fmt.Println(len(s)) // 2 in UTF-8, not 1

This surprises Python users because Python's len("ą") counts Unicode code points.

7.2 byte and rune

byte // alias for uint8
rune // alias for int32, usually a Unicode code point

Iterating bytes:

s := "hello"

for i := 0; i < len(s); i++ {
	fmt.Println(s[i])
}

Iterating runes/code points:

for i, r := range "hello ą" {
	fmt.Println(i, r, string(r))
}

The index i is still a byte offset.

7.3 Building strings

Avoid repeated string concatenation in big loops:

var b strings.Builder

for _, part := range parts {
	b.WriteString(part)
}

result := b.String()

For bytes, use bytes.Buffer or []byte.

7.4 Paths: path versus path/filepath

Use path/filepath for OS filesystem paths:

full := filepath.Join(baseDir, "config.json")

Use path for slash-separated paths such as URLs or object keys:

key := path.Join("logs", "2026", "app.log")

8. Slices, arrays, and maps

8.1 Arrays are fixed-size values

var a [3]int
b := [3]int{1, 2, 3}

The length is part of the array type. [3]int and [4]int are different types.

Arrays are not the common Go replacement for Python lists. Slices are.

8.2 Slices are descriptors over arrays

xs := []int{1, 2, 3}
xs = append(xs, 4)

Always assign the result of append:

xs = append(xs, 4)

A slice has:

  • pointer to underlying array;
  • length;
  • capacity.
xs := make([]string, 0, 100)
fmt.Println(len(xs)) // 0
fmt.Println(cap(xs)) // 100

8.3 Slice aliasing

a := []int{1, 2, 3, 4}
b := a[1:3]

b[0] = 99
fmt.Println(a) // [1 99 3 4]

This happens because slices can share the same underlying array.

If you need a copy:

b := append([]int(nil), a[1:3]...)

Or:

b := make([]int, 2)
copy(b, a[1:3])

8.4 Nil slice versus empty slice

var a []string       // nil slice
b := []string{}      // empty non-nil slice
c := make([]string, 0)

Usually you can treat nil and empty slices the same:

fmt.Println(len(a)) // 0

But serialization can differ. For example, encoding/json marshals a nil slice as null, while an empty slice marshals as [].

If API shape matters, initialize empty slices explicitly.

8.5 Maps

counts := map[string]int{
	"error":   3,
	"warning": 7,
}

counts["info"] = 10

Check key existence:

value, ok := counts["missing"]
if !ok {
	fmt.Println("not present")
} else {
	fmt.Println(value)
}

Absent keys return the zero value:

counts := map[string]int{}
fmt.Println(counts["missing"]) // 0

That is why the ok form matters.

8.6 Nil maps

var m map[string]string

fmt.Println(len(m)) // 0
fmt.Println(m["x"]) // ""

// m["x"] = "y" // panic

Create a writable map with make:

m := make(map[string]string)
m["x"] = "y"

8.7 Map iteration order

Do not depend on map iteration order.

for k, v := range m {
	fmt.Println(k, v)
}

If you need stable output:

keys := make([]string, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
sort.Strings(keys)

for _, k := range keys {
	fmt.Println(k, m[k])
}

8.8 Sets

A common set representation:

regions := map[string]struct{}{
	"eu-west-1": {},
	"us-east-1": {},
}

_, ok := regions["eu-west-1"]

struct{} takes no space as a value.

For readability, map[string]bool is sometimes fine too.

8.9 Concurrent map access

Do not read/write ordinary maps concurrently without synchronization.

Use one of:

  • sync.Mutex;
  • sync.RWMutex;
  • a single owner goroutine;
  • sync.Map for specialized concurrent map use cases.

9. Structs, methods, pointers, and receivers

9.1 Structs

Python dataclass:

@dataclass
class Instance:
    id: str
    region: str
    running: bool

Go struct:

type Instance struct {
	ID      string
	Region  string
	Running bool
}

Create one:

inst := Instance{
	ID:      "i-123",
	Region:  "eu-west-1",
	Running: true,
}

Prefer named fields for clarity. Positional struct literals are fragile for non-trivial structs.

9.2 JSON struct tags

type Instance struct {
	ID      string            `json:"id"`
	Region  string            `json:"region"`
	Tags    map[string]string `json:"tags,omitempty"`
	Running bool              `json:"running"`
}

The tag is metadata used by packages such as encoding/json.

9.3 Methods

type Instance struct {
	ID      string
	Running bool
}

func (i Instance) IsRunning() bool {
	return i.Running
}

The (i Instance) part is the receiver.

Usage:

inst := Instance{ID: "i-123", Running: true}
fmt.Println(inst.IsRunning())

9.4 Pointer receivers

Use pointer receivers when the method mutates the value:

func (i *Instance) Stop() {
	i.Running = false
}

Also consider pointer receivers when:

  • the struct is large;
  • the type contains synchronization primitives;
  • you want method behavior to be consistent across the type;
  • nil receiver behavior is intentional.

Use value receivers when:

  • the type is small and immutable-like;
  • copying is cheap;
  • you want value semantics.

9.5 Pointers

x := 10
p := &x

fmt.Println(*p) // 10

*p = 20
fmt.Println(x) // 20

Go has pointers, but normal Go code does not use pointer arithmetic.

Use pointers for:

  • mutation;
  • avoiding large copies;
  • optional/nil-able values;
  • shared state, deliberately;
  • implementing interfaces that require pointer receiver methods.

Do not use pointers everywhere by default.

Bad:

func PrintName(name *string) {
	fmt.Println(*name)
}

Better:

func PrintName(name string) {
	fmt.Println(name)
}

Use a pointer when nil is meaningful or mutation is needed.

9.6 new versus make

new(T) allocates a zero value of type T and returns *T:

p := new(int)
*p = 10

make initializes slices, maps, and channels:

xs := make([]string, 0, 10)
m := make(map[string]int)
ch := make(chan string)

Most everyday code uses struct literals more often than new:

cfg := &Config{Region: "eu-west-1"}

10. Functions, multiple returns, errors, and defer

10.1 Functions

func add(a, b int) int {
	return a + b
}

Multiple parameters of the same type:

func connect(host string, port int) error {
	return nil
}

Multiple return values:

func parseHostPort(input string) (string, int, error) {
	// ...
	return "localhost", 8080, nil
}

10.2 Errors are values

Go does not use exceptions for ordinary failure.

func divide(a, b int) (int, error) {
	if b == 0 {
		return 0, errors.New("division by zero")
	}

	return a / b, nil
}

Call it:

result, err := divide(10, 2)
if err != nil {
	return err
}

fmt.Println(result)

The most common Go pattern:

value, err := doThing()
if err != nil {
	return fmt.Errorf("do thing: %w", err)
}

The %w wraps the original error so callers can inspect it.

10.3 Error wrapping and matching

Sentinel error:

var ErrNotFound = errors.New("not found")

Return it wrapped:

func load(id string) error {
	if id == "" {
		return fmt.Errorf("load instance: %w", ErrNotFound)
	}
	return nil
}

Check it:

if errors.Is(err, ErrNotFound) {
	// handle missing resource
}

Custom error type:

type APIError struct {
	StatusCode int
	Message    string
}

func (e *APIError) Error() string {
	return fmt.Sprintf("api error %d: %s", e.StatusCode, e.Message)
}

Check it:

var apiErr *APIError
if errors.As(err, &apiErr) {
	fmt.Println(apiErr.StatusCode)
}

10.4 Good error messages

Good:

return fmt.Errorf("read config %q: %w", path, err)

Bad:

return fmt.Errorf("error: %w", err)

Good errors form a breadcrumb trail:

start server: load config "config.json": read file: permission denied

Do not capitalize ordinary error messages unless they begin with a proper noun. Do not add trailing periods unless the error is a full multi-sentence message.

10.5 Avoid ignoring errors

Bad:

data, _ := os.ReadFile(path)

Usually better:

data, err := os.ReadFile(path)
if err != nil {
	return fmt.Errorf("read %q: %w", path, err)
}

Rare acceptable cases:

_ = os.Remove(tmpPath) // best-effort cleanup

Even then, a comment helps.

10.6 defer

defer runs when the current function returns.

f, err := os.Open(path)
if err != nil {
	return err
}
defer f.Close()

body, err := io.ReadAll(f)
if err != nil {
	return err
}

Common patterns:

mu.Lock()
defer mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

Defers run in last-in-first-out order:

defer fmt.Println("first")
defer fmt.Println("second")
// prints second, then first

Arguments are evaluated when defer is declared, not when it runs:

x := 1
defer fmt.Println(x)
x = 2
// prints 1

10.7 Panic and recover

Use panic for programmer errors or unrecoverable internal invariants, not normal control flow.

Reasonable:

func mustCompile(pattern string) *regexp.Regexp {
	return regexp.MustCompile(pattern)
}

Suspicious:

if err != nil {
	panic(err)
}

In CLIs, it is usually better to return an error to main, log it, and exit with a non-zero code.


11. Interfaces and composition

11.1 Go interfaces are structural

Python duck typing:

def save(writer):
    writer.write(b"hello")

Go interface:

type Writer interface {
	Write(p []byte) (n int, err error)
}

A type implements an interface automatically if it has the required methods. There is no implements keyword.

11.2 Standard library examples

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

type Closer interface {
	Close() error
}

Many Go APIs are built around tiny interfaces.

11.3 Define interfaces at the consumer side

Good:

type InstanceLister interface {
	ListInstances(ctx context.Context, region string) ([]Instance, error)
}

func PrintInstances(ctx context.Context, lister InstanceLister, region string, out io.Writer) error {
	instances, err := lister.ListInstances(ctx, region)
	if err != nil {
		return fmt.Errorf("list instances: %w", err)
	}

	for _, inst := range instances {
		fmt.Fprintf(out, "%s\t%s\n", inst.ID, inst.State)
	}

	return nil
}

Bad:

type CloudClient interface {
	CreateInstance(...)
	DeleteInstance(...)
	ListInstances(...)
	GetInstance(...)
	TagInstance(...)
	UntagInstance(...)
	StartInstance(...)
	StopInstance(...)
	RebootInstance(...)
	// dozens more
}

Keep interfaces small and focused.

11.4 Interfaces are not classes

An interface describes behavior. A struct stores data. Do not create an interface for every struct.

Suspicious:

type ConfigInterface interface {
	GetRegion() string
}

Usually better:

type Config struct {
	Region string
}

Use interfaces where they create a useful boundary:

  • external service calls;
  • storage backends;
  • clock/time injection;
  • logging/output;
  • tests and fakes;
  • plugin-like behavior.

11.5 Composition and embedding

Go has no class inheritance. Use composition.

type Instance struct {
	ID     string
	Region string
}

type AWSInstance struct {
	Instance
	AccountID string
}

Embedding promotes fields and methods:

aws := AWSInstance{
	Instance:  Instance{ID: "i-123", Region: "eu-west-1"},
	AccountID: "123456789012",
}

fmt.Println(aws.ID)

Embedding is not inheritance. It is composition with convenience.

11.6 Interface values and typed nils

An interface value contains both a dynamic type and a dynamic value. This can surprise Python users.

type Notifier interface {
	Notify() error
}

type EmailNotifier struct{}

func (e *EmailNotifier) Notify() error {
	return nil
}

var email *EmailNotifier = nil
var notifier Notifier = email

fmt.Println(notifier == nil) // false

The interface is not nil because it contains a dynamic type: *EmailNotifier.

Practical advice:

  • Return concrete nils carefully.
  • Prefer returning nil directly for interface return values when there is no value.
  • Be cautious with error implementations that may be nil pointers.

11.7 any and empty interface

any is an alias for interface{}.

func Print(value any) {
	fmt.Println(value)
}

Use any when you genuinely accept any type. Do not use it to avoid learning the type system.


12. Standard library essentials for cloud/Linux work

12.1 Files

data, err := os.ReadFile("config.json")
if err != nil {
	return fmt.Errorf("read config: %w", err)
}

Write a file:

err := os.WriteFile("out.txt", []byte("hello\n"), 0o644)
if err != nil {
	return fmt.Errorf("write output: %w", err)
}

Open and stream:

f, err := os.Open(path)
if err != nil {
	return err
}
defer f.Close()

scanner := bufio.NewScanner(f)
for scanner.Scan() {
	line := scanner.Text()
	fmt.Println(line)
}

if err := scanner.Err(); err != nil {
	return err
}

bufio.Scanner is convenient but has token-size limits. For very large lines or binary streams, use bufio.Reader or configure the scanner buffer.

12.2 JSON

type Config struct {
	Region   string            `json:"region"`
	LogLevel string            `json:"log_level"`
	Tags     map[string]string `json:"tags"`
}

Decode:

var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
	return fmt.Errorf("parse config: %w", err)
}

Encode:

out, err := json.MarshalIndent(cfg, "", "  ")
if err != nil {
	return fmt.Errorf("encode config: %w", err)
}

Streaming decode from HTTP response:

var payload Payload
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
	return fmt.Errorf("decode response: %w", err)
}

Common JSON pitfalls:

  • unexported fields are ignored;
  • field names need tags if JSON uses snake_case;
  • nil slices become null;
  • empty slices become [];
  • numbers may need care if decoding into map[string]any;
  • omitempty omits zero values, which may be wrong for booleans or numbers.

12.3 HTTP client

Use request context and explicit timeout behavior.

func fetchJSON(ctx context.Context, url string) (*Payload, error) {
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	client := &http.Client{
		Timeout: 10 * time.Second,
	}

	resp, err := client.Do(req)
	if err != nil {
		return nil, fmt.Errorf("send request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("bad status: %s", resp.Status)
	}

	var payload Payload
	if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}

	return &payload, nil
}

Notes:

  • Always close response bodies.
  • Use context for cancellation.
  • Consider a shared http.Client; do not create one for every request in hot paths.
  • The default client has no total timeout; be intentional.
  • For APIs, think about retries, idempotency, rate limits, and backoff.

12.4 HTTP server

func main() {
	mux := http.NewServeMux()

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

	server := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  5 * time.Second,
		WriteTimeout: 10 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		panic(err)
	}
}

For production servers, add graceful shutdown with signals and context.

12.5 CLI flags

fs := flag.NewFlagSet("mytool", flag.ContinueOnError)
region := fs.String("region", "eu-west-1", "cloud region")
timeout := fs.Duration("timeout", 10*time.Second, "request timeout")

if err := fs.Parse(args); err != nil {
	return err
}

fmt.Println(*region, *timeout)

For larger CLIs, third-party libraries are common, but learn flag first.

12.6 Environment variables

region := os.Getenv("AWS_REGION")
if region == "" {
	region = "eu-west-1"
}

Check existence separately when empty string is meaningful:

value, ok := os.LookupEnv("FEATURE_FLAG")

12.7 Time

Go's time layouts use the reference time:

Mon Jan 2 15:04:05 MST 2006

Examples:

now := time.Now().UTC()
fmt.Println(now.Format(time.RFC3339))

parsed, err := time.Parse(time.RFC3339, "2026-05-06T12:00:00Z")
if err != nil {
	return err
}

Custom layout:

day := now.Format("2006-01-02")

This feels strange at first. Memorize 2006-01-02 15:04:05.

12.8 Running commands

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "kubectl", "get", "pods", "-o", "json")
out, err := cmd.Output()
if err != nil {
	return fmt.Errorf("run kubectl: %w", err)
}

fmt.Println(string(out))

For shell pipelines, avoid passing untrusted input through sh -c. Prefer argument lists.

12.9 Logging with log/slog

slog.Info("created instance",
	"id", inst.ID,
	"region", inst.Region,
)

Use structured fields instead of formatting everything into one string.

slog.Error("request failed",
	"url", url,
	"status", resp.StatusCode,
	"error", err,
)

For cloud logs, this maps naturally to searchable fields.


13. Context: cancellation, timeouts, and request scope

context.Context carries cancellation, deadlines, and request-scoped values across API boundaries.

Use it for:

  • HTTP requests;
  • cloud SDK calls;
  • database queries;
  • subprocesses;
  • worker shutdown;
  • long-running operations.

13.1 Function signature pattern

func ListInstances(ctx context.Context, region string) ([]Instance, error) {
	// pass ctx to I/O calls
	return nil, nil
}

Rules:

  • ctx is usually the first parameter.
  • Do not store context in structs.
  • Always call the cancel function returned by WithCancel, WithTimeout, or WithDeadline.
  • Use context values sparingly.
  • Do not use context as a general dependency injection container.

13.2 Timeout example

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

instances, err := ListInstances(ctx, "eu-west-1")
if err != nil {
	return err
}

_ = instances

13.3 Cancellation in loops

func worker(ctx context.Context, jobs <-chan Job) error {
	for {
		select {
		case job, ok := <-jobs:
			if !ok {
				return nil
			}

			if err := process(ctx, job); err != nil {
				return err
			}

		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

13.4 Context values

Acceptable:

  • request ID;
  • trace ID;
  • auth metadata in framework/library boundaries.

Suspicious:

ctx = context.WithValue(ctx, "db", db)
ctx = context.WithValue(ctx, "config", cfg)

Pass real dependencies as parameters or struct fields.


14. Concurrency: goroutines, channels, mutexes, and leaks

14.1 Goroutines

go doWork()

This runs doWork concurrently.

A goroutine is cheap compared to an OS thread, but it is not free. It must have a lifetime plan.

Bad:

go func() {
	for {
		doWork()
	}
}()

Question: how does it stop?

Better:

go func() {
	for {
		select {
		case <-ctx.Done():
			return
		default:
			doWork()
		}
	}
}()

But be careful: the default case can create a busy loop. Often you want a timer, channel receive, or blocking operation instead.

14.2 WaitGroup

var wg sync.WaitGroup

for i := 0; i < 3; i++ {
	wg.Add(1)

	go func(id int) {
		defer wg.Done()
		fmt.Println("worker", id)
	}(i)
}

wg.Wait()

This waits for all goroutines to finish.

14.3 Channels

ch := make(chan string)

go func() {
	ch <- "done"
}()

msg := <-ch
fmt.Println(msg)

Buffered channel:

ch := make(chan string, 10)
ch <- "one"
ch <- "two"

Close a channel when no more values will be sent:

close(ch)

Receive until closed:

for item := range ch {
	fmt.Println(item)
}

Usually, the sender closes the channel, not the receiver.

14.4 Directional channels

func produce(out chan<- Job) {
	out <- Job{}
}

func consume(in <-chan Job) {
	job := <-in
	_ = job
}

Use directional channels in function signatures to document and enforce intent.

14.5 select

select {
case msg := <-messages:
	fmt.Println("message:", msg)
case <-ctx.Done():
	return ctx.Err()
}

select waits for one of several channel operations.

14.6 Worker pool pattern

func runWorkers(ctx context.Context, jobs <-chan Job, workers int) error {
	var wg sync.WaitGroup
	errs := make(chan error, workers)

	for i := 0; i < workers; i++ {
		wg.Add(1)

		go func() {
			defer wg.Done()

			for {
				select {
				case job, ok := <-jobs:
					if !ok {
						return
					}

					if err := process(ctx, job); err != nil {
						errs <- err
						return
					}

				case <-ctx.Done():
					errs <- ctx.Err()
					return
				}
			}
		}()
	}

	wg.Wait()
	close(errs)

	for err := range errs {
		if err != nil {
			return err
		}
	}

	return nil
}

This is illustrative, not a universal template. Production worker pools often need cancellation on first error, bounded queues, retries, metrics, and shutdown behavior.

For many real programs, errgroup from golang.org/x/sync/errgroup is a clean option, but learn the primitives first.

14.7 Mutexes are normal

Do not force channels into every problem.

Use channels for communication and coordination. Use mutexes to protect shared state.

type Cache struct {
	mu    sync.RWMutex
	items map[string]string
}

func NewCache() *Cache {
	return &Cache{items: make(map[string]string)}
}

func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()

	value, ok := c.items[key]
	return value, ok
}

func (c *Cache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()

	c.items[key] = value
}

14.8 Race detector

Run:

go test -race ./...

The race detector only detects races exercised at runtime. Good tests matter.

14.9 Loop variable capture

Older Go versions had a famous pitfall where closures captured the loop variable, not the per-iteration value.

Safe explicit pattern:

for _, item := range items {
	item := item

	go func() {
		process(item)
	}()
}

Modern Go changed loop-variable scoping for modules declaring Go 1.22 or later, but you will still see the explicit pattern in older code and generated code. It remains harmless and often improves clarity.

14.10 Goroutine leak checklist

When reviewing Go code, ask:

  • What starts this goroutine?
  • What stops it?
  • What happens if the receiver stops reading?
  • Is there a context or close signal?
  • Can a send block forever?
  • Can a channel remain unclosed forever?
  • Does a timer or ticker need cleanup?

Ticker cleanup:

ticker := time.NewTicker(time.Second)
defer ticker.Stop()

15. Testing, benchmarking, fuzzing, and testability

15.1 Basic test file

Production file:

// config.go
package config

import "strings"

func NormalizeRegion(region string) string {
	return strings.ToLower(strings.TrimSpace(region))
}

Test file:

// config_test.go
package config

import "testing"

func TestNormalizeRegion(t *testing.T) {
	got := NormalizeRegion("  EU-WEST-1 ")
	want := "eu-west-1"

	if got != want {
		t.Fatalf("NormalizeRegion() = %q, want %q", got, want)
	}
}

Run:

go test ./...

15.2 Table-driven tests

func TestNormalizeRegion(t *testing.T) {
	tests := []struct {
		name   string
		input  string
		want   string
	}{
		{
			name:  "lowercase unchanged",
			input: "eu-west-1",
			want:  "eu-west-1",
		},
		{
			name:  "trim and lowercase",
			input: "  EU-WEST-1 ",
			want:  "eu-west-1",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			got := NormalizeRegion(tt.input)
			if got != tt.want {
				t.Fatalf("NormalizeRegion(%q) = %q, want %q", tt.input, got, tt.want)
			}
		})
	}
}

This is canonical Go style.

15.3 Same package versus external package tests

Same package:

package config

Can test unexported functions.

External package:

package config_test

Tests only the public API, like a real consumer.

Use both when useful. Most package-level tests can be same package; public API tests can be external.

15.4 Temporary files and directories

func TestLoadConfig(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "config.json")

	if err := os.WriteFile(path, []byte(`{"region":"eu-west-1"}`), 0o644); err != nil {
		t.Fatal(err)
	}

	cfg, err := LoadConfig(path)
	if err != nil {
		t.Fatal(err)
	}

	if cfg.Region != "eu-west-1" {
		t.Fatalf("region = %q", cfg.Region)
	}
}

15.5 Testing HTTP clients with httptest

func TestFetchJSON(t *testing.T) {
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		_, _ = w.Write([]byte(`{"message":"ok"}`))
	}))
	defer server.Close()

	ctx := context.Background()
	payload, err := fetchJSON(ctx, server.URL)
	if err != nil {
		t.Fatal(err)
	}

	if payload.Message != "ok" {
		t.Fatalf("message = %q", payload.Message)
	}
}

15.6 Testability through small interfaces

Instead of mocking a huge cloud SDK, hide the exact dependency behind a small interface.

type InstanceLister interface {
	ListInstances(ctx context.Context, region string) ([]Instance, error)
}

Fake implementation:

type fakeLister struct {
	instances []Instance
	err       error
}

func (f fakeLister) ListInstances(ctx context.Context, region string) ([]Instance, error) {
	if f.err != nil {
		return nil, f.err
	}
	return f.instances, nil
}

This is often cleaner than heavyweight mocking.

15.7 Benchmarks

func BenchmarkNormalizeRegion(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = NormalizeRegion("  EU-WEST-1 ")
	}
}

Run:

go test ./... -bench .
go test ./... -bench . -benchmem

15.8 Fuzz tests

Fuzzing is useful for parsers, decoders, normalizers, and boundary-heavy code.

func FuzzNormalizeRegion(f *testing.F) {
	f.Add("EU-WEST-1")
	f.Add("  us-east-1 ")

	f.Fuzz(func(t *testing.T, input string) {
		out := NormalizeRegion(input)
		if strings.Contains(out, " ") {
			t.Fatalf("output contains space: %q", out)
		}
	})
}

Run:

go test ./... -fuzz FuzzNormalizeRegion

15.9 Parallel tests

func TestThing(t *testing.T) {
	t.Parallel()
	// test code
}

Be careful with shared state, environment variables, ports, current directory, and global configuration.


16. Generics: useful, but not the first tool

Go has generics, but most everyday Go still uses concrete types, interfaces, and simple functions.

16.1 Generic function

func First[T any](items []T) (T, bool) {
	if len(items) == 0 {
		var zero T
		return zero, false
	}

	return items[0], true
}

Use it:

name, ok := First([]string{"alice", "bob"})

16.2 comparable

Map keys must be comparable.

func Contains[T comparable](items []T, target T) bool {
	for _, item := range items {
		if item == target {
			return true
		}
	}
	return false
}

16.3 Generic type

type Result[T any] struct {
	Value T
	Err   error
}

16.4 When to use generics

Use generics for:

  • reusable algorithms over types;
  • containers;
  • reducing duplicate code where the operation is truly type-independent;
  • type-safe helpers for slices, maps, and results.

Do not use generics to avoid designing real domain types.

Bad:

func Process[T any](value T) error {
	// giant runtime type switch
	return nil
}

Better:

func ProcessInstance(inst Instance) error {
	return nil
}

16.5 Interfaces versus generics

Use interfaces for behavior:

type Reader interface {
	Read(p []byte) (int, error)
}

Use generics for type-independent data manipulation:

func Map[T, U any](items []T, fn func(T) U) []U {
	out := make([]U, 0, len(items))
	for _, item := range items {
		out = append(out, fn(item))
	}
	return out
}

But do not rush to create a functional-programming utility library. Explicit loops are normal Go.

16.6 Generic method limitation

Methods on generic types can use the type parameters of the receiver type:

type Box[T any] struct {
	value T
}

func (b Box[T]) Value() T {
	return b.value
}

But methods cannot introduce a separate independent type parameter list. Use a generic function instead.


17. Memory, performance, and runtime intuition

17.1 Values and references

These are value types:

  • integers;
  • booleans;
  • floats;
  • arrays;
  • structs.

These are small descriptors or references to underlying data:

  • slices;
  • maps;
  • channels;
  • functions;
  • interfaces;
  • pointers.

When you pass a slice by value, you copy the slice descriptor, not the underlying array.

func mutate(xs []int) {
	xs[0] = 99
}

func main() {
	xs := []int{1, 2, 3}
	mutate(xs)
	fmt.Println(xs) // [99 2 3]
}

But appending may allocate a new underlying array:

func appendOne(xs []int) {
	xs = append(xs, 1)
}

The caller will not see the new slice length unless you return it:

func appendOne(xs []int) []int {
	return append(xs, 1)
}

17.2 Allocation intuition

This can allocate:

return &Config{Region: "eu-west-1"}

That is fine. Go has garbage collection. Do not contort code to avoid every allocation.

Use benchmarks before optimizing.

17.3 Escape analysis

The compiler decides whether values live on stack or heap.

You can inspect decisions:

go build -gcflags='-m' ./...

Treat this as an advanced diagnostic, not daily required reading.

17.4 Avoid premature pointer use

A pointer is not automatically faster. It can add indirection, aliasing, and heap pressure.

Good default:

  • pass small immutable-ish structs by value;
  • pass large or mutable structs by pointer;
  • pass slices/maps normally;
  • avoid storing pointers to tiny scalar values unless nil/optional matters.

17.5 Preallocation

If you know the output size, preallocate.

out := make([]string, 0, len(users))
for _, user := range users {
	if user.Enabled {
		out = append(out, user.Name)
	}
}

For maps:

m := make(map[string]Instance, len(instances))

17.6 Profiling

Go has strong built-in profiling support through runtime/pprof and net/http/pprof.

Typical production concern areas:

  • allocation rate;
  • goroutine leaks;
  • lock contention;
  • CPU hotspots;
  • latency under container CPU limits;
  • network timeouts and retries;
  • JSON encoding/decoding costs.

For a one-day course, the key lesson is: write simple code first, benchmark/profile real bottlenecks second.


18. Production workflow: CI, security, builds, and diagnostics

18.1 Minimum local workflow

go fmt ./...
go test ./...
go vet ./...
go mod tidy

Add race detection regularly:

go test -race ./...

For larger codebases, run -race in CI or nightly if it is too slow for every commit.

18.2 Dependency hygiene

go mod tidy
go list -m all
go get example.com/module@v1.2.3
go get -u ./...

Do not hand-edit go.sum. It contains cryptographic hashes used by Go tooling to authenticate module downloads.

18.3 Module versions

Go modules use semantic import versioning.

For v2 and later, the module path includes the major version:

module example.com/mylib/v2

Imports also include /v2:

import "example.com/mylib/v2/client"

This is a common source of confusion.

18.4 Local replacements

During local development:

replace example.com/theirmodule => ../theirmodule

Useful, but do not accidentally ship a local-only replace unless intended.

18.5 Private modules

For private repositories, configure GOPRIVATE:

go env -w GOPRIVATE=github.com/mycompany/*

This tells Go tooling not to use the public module proxy/checksum database for matching private paths.

18.6 Vulnerability scanning

Install:

go install golang.org/x/vuln/cmd/govulncheck@latest

Run:

govulncheck ./...

This is especially relevant for cloud services and infrastructure tools that pull in many transitive dependencies.

18.7 CI baseline

A simple CI job should run something like:

go version
go mod tidy
git diff --exit-code go.mod go.sum
go fmt ./...
git diff --exit-code
go vet ./...
go test ./...
govulncheck ./...

Optionally:

go test -race ./...

18.8 Build metadata

Inject version information:

package main

var version = "dev"

Build:

go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" ./cmd/mytool

18.9 Signals and graceful shutdown

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()

if err := run(ctx, os.Args[1:]); err != nil {
	slog.Error("failed", "error", err)
	os.Exit(1)
}

For HTTP server shutdown:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := server.Shutdown(shutdownCtx); err != nil {
	return fmt.Errorf("shutdown server: %w", err)
}

18.10 Containers

Go is excellent for containers because it can produce small single binaries.

Typical multi-stage Dockerfile shape:

FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /mytool ./cmd/mytool

FROM gcr.io/distroless/static-debian12
COPY --from=build /mytool /mytool
ENTRYPOINT ["/mytool"]

Be mindful of:

  • CA certificates;
  • cgo needs;
  • DNS behavior;
  • timezone data;
  • container CPU/memory limits;
  • signal handling;
  • running as non-root.

19. Idiomatic style and code review checklist

19.1 Good Go style

Good Go is usually:

  • explicit;
  • flat;
  • small;
  • named clearly;
  • organized by domain;
  • easy to test;
  • light on abstraction;
  • boring in the best way.

19.2 Naming

Use short names for short scopes:

for i, v := range values {
	fmt.Println(i, v)
}

Use descriptive names for wider scopes:

func ListInstances(ctx context.Context, region string) ([]Instance, error)

Avoid stutter:

// Bad if package name is config:
config.ConfigManager

// Better:
config.Loader
config.Config

19.3 Function shape

Prefer guard clauses:

func handle(input Input) error {
	if err := validate(input); err != nil {
		return fmt.Errorf("validate input: %w", err)
	}

	result, err := doWork(input)
	if err != nil {
		return fmt.Errorf("do work: %w", err)
	}

	return save(result)
}

Avoid deeply nested code:

if ok {
	if ready {
		if allowed {
			// too much nesting
		}
	}
}

19.4 Review checklist for generated Go

When Codex or another tool writes Go, check these first.

Error handling

Suspicious:

value, _ := doThing()

Better:

value, err := doThing()
if err != nil {
	return fmt.Errorf("do thing: %w", err)
}

Context

Network, database, subprocess, cloud SDK, and long-running functions should usually accept context:

func DoThing(ctx context.Context, input Input) error

Resource cleanup

Look for:

defer file.Close()
defer resp.Body.Close()
defer cancel()
defer ticker.Stop()

Goroutine lifetime

Suspicious:

go func() {
	for {
		...
	}
}()

Ask how it stops.

Shared state

Suspicious:

m := map[string]string{}

go func() {
	m["x"] = "y"
}()

Use synchronization or a single owner.

Interfaces

Suspicious:

type HugeClient interface {
	Create(...)
	Update(...)
	Delete(...)
	List(...)
	Watch(...)
	Sync(...)
	Validate(...)
}

Prefer small consumer-side interfaces.

Package names

Suspicious:

utils
helpers
common
misc

Prefer domain names.

Tests

Ask for table-driven tests and failure cases, not just happy paths.

Over-abstraction

Suspicious:

type UserManagerFactoryBuilder interface {
	BuildUserManagerFactory() UserManagerFactory
}

Go does not need ceremony for its own sake.

19.5 Comments and documentation

Comment exported symbols in libraries:

// LoadConfig reads and validates a JSON configuration file.
func LoadConfig(path string) (*Config, error) {
	...
}

Good comments explain API behavior, invariants, and surprising decisions. Do not comment every obvious line.


20. End-to-end reference pattern

This section shows a compact cloud-style CLI shape. It is not an exercise; it is a pattern to study.

20.1 Domain package

package instances

import (
	"context"
	"fmt"
	"io"
)

type Instance struct {
	ID     string            `json:"id"`
	Region string            `json:"region"`
	State  string            `json:"state"`
	Tags   map[string]string `json:"tags,omitempty"`
}

type Lister interface {
	ListInstances(ctx context.Context, region string) ([]Instance, error)
}

func Print(ctx context.Context, lister Lister, region string, out io.Writer) error {
	items, err := lister.ListInstances(ctx, region)
	if err != nil {
		return fmt.Errorf("list instances in %s: %w", region, err)
	}

	for _, item := range items {
		if _, err := fmt.Fprintf(out, "%s\t%s\t%s\n", item.ID, item.Region, item.State); err != nil {
			return fmt.Errorf("write instance: %w", err)
		}
	}

	return nil
}

This demonstrates:

  • domain type;
  • JSON tags;
  • small consumer-side interface;
  • context propagation;
  • output abstraction with io.Writer;
  • explicit error wrapping.

20.2 HTTP implementation

package apiclient

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"net/url"
	"time"

	"github.com/example/mytool/internal/instances"
)

type Client struct {
	baseURL string
	http    *http.Client
}

func New(baseURL string) *Client {
	return &Client{
		baseURL: baseURL,
		http: &http.Client{
			Timeout: 15 * time.Second,
		},
	}
}

func (c *Client) ListInstances(ctx context.Context, region string) ([]instances.Instance, error) {
	u, err := url.JoinPath(c.baseURL, "instances")
	if err != nil {
		return nil, fmt.Errorf("build url: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
	if err != nil {
		return nil, fmt.Errorf("create request: %w", err)
	}

	q := req.URL.Query()
	q.Set("region", region)
	req.URL.RawQuery = q.Encode()

	resp, err := c.http.Do(req)
	if err != nil {
		return nil, fmt.Errorf("send request: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("bad status: %s", resp.Status)
	}

	var items []instances.Instance
	if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
		return nil, fmt.Errorf("decode response: %w", err)
	}

	return items, nil
}

20.3 CLI entrypoint

package main

import (
	"context"
	"flag"
	"fmt"
	"log/slog"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/example/mytool/internal/apiclient"
	"github.com/example/mytool/internal/instances"
)

func run(ctx context.Context, args []string) error {
	fs := flag.NewFlagSet("mytool", flag.ContinueOnError)

	baseURL := fs.String("base-url", "https://api.example.com", "API base URL")
	region := fs.String("region", "eu-west-1", "cloud region")
	timeout := fs.Duration("timeout", 10*time.Second, "request timeout")

	if err := fs.Parse(args); err != nil {
		return err
	}

	ctx, cancel := context.WithTimeout(ctx, *timeout)
	defer cancel()

	client := apiclient.New(*baseURL)

	if err := instances.Print(ctx, client, *region, os.Stdout); err != nil {
		return fmt.Errorf("print instances: %w", err)
	}

	return nil
}

func main() {
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	if err := run(ctx, os.Args[1:]); err != nil {
		slog.Error("failed", "error", err)
		os.Exit(1)
	}
}

Why this shape is good:

  • main is small;
  • run is testable because it accepts args;
  • real logic is outside main;
  • cancellation and timeout are explicit;
  • dependencies are injected through small interfaces;
  • output is abstracted with io.Writer;
  • errors are wrapped at each boundary.

20.4 Test the domain logic without HTTP

package instances

import (
	"bytes"
	"context"
	"errors"
	"strings"
	"testing"
)

type fakeLister struct {
	items []Instance
	err   error
}

func (f fakeLister) ListInstances(ctx context.Context, region string) ([]Instance, error) {
	if f.err != nil {
		return nil, f.err
	}
	return f.items, nil
}

func TestPrint(t *testing.T) {
	lister := fakeLister{
		items: []Instance{
			{ID: "i-1", Region: "eu-west-1", State: "running"},
		},
	}

	var out bytes.Buffer

	err := Print(context.Background(), lister, "eu-west-1", &out)
	if err != nil {
		t.Fatal(err)
	}

	if !strings.Contains(out.String(), "i-1") {
		t.Fatalf("output missing instance ID: %q", out.String())
	}
}

func TestPrintListError(t *testing.T) {
	wantErr := errors.New("api failed")
	lister := fakeLister{err: wantErr}

	err := Print(context.Background(), lister, "eu-west-1", &bytes.Buffer{})
	if err == nil {
		t.Fatal("expected error")
	}

	if !errors.Is(err, wantErr) {
		t.Fatalf("error does not wrap wanted error: %v", err)
	}
}

This is the core Go testing style: small fakes, explicit data, table-driven tests where useful, and no framework needed.


21. Common pitfalls

21.1 Translating Python one-liners directly

Python:

names = [u.name.lower() for u in users if u.enabled]

Go:

var names []string

for _, user := range users {
	if !user.Enabled {
		continue
	}

	names = append(names, strings.ToLower(user.Name))
}

This is normal Go. Do not fight it.

21.2 Ignoring errors

Bad:

result, _ := doThing()

Better:

result, err := doThing()
if err != nil {
	return fmt.Errorf("do thing: %w", err)
}

21.3 Forgetting to assign append

Bad:

append(xs, 1)

Good:

xs = append(xs, 1)

21.4 Writing to a nil map

Bad:

var m map[string]string
m["x"] = "y" // panic

Good:

m := make(map[string]string)
m["x"] = "y"

21.5 Confusing nil and empty in APIs

var xs []string  // JSON null
ys := []string{} // JSON []

If clients expect [], initialize the slice.

21.6 Depending on map order

Bad:

for k := range m {
	fmt.Println(k)
}

Good for stable output:

keys := make([]string, 0, len(m))
for k := range m {
	keys = append(keys, k)
}
sort.Strings(keys)

21.7 Overusing pointers

Bad:

func RegionName(region *string) string

Usually better:

func RegionName(region string) string

21.8 Underusing pointers for mutation

Bad:

func (c Cache) Set(key, value string) {
	c.items[key] = value
}

This may look like it works for maps because the map descriptor is copied but still points to shared map data. However, as a method design, it is misleading. Mutable structs usually use pointer receivers:

func (c *Cache) Set(key, value string) {
	c.items[key] = value
}

21.9 Not closing resources

Bad:

resp, err := http.Get(url)
if err != nil {
	return err
}
// forgot resp.Body.Close()

Good:

resp, err := http.Get(url)
if err != nil {
	return err
}
defer resp.Body.Close()

21.10 Creating goroutines without shutdown

Bad:

go watchForever()

Better:

go watch(ctx)

Then make watch honor ctx.Done().

21.11 Using channels when a mutex is simpler

Bad:

// elaborate channel protocol just to protect a map

Good:

mu.Lock()
m[key] = value
mu.Unlock()

21.12 Using huge interfaces

Bad:

type EverythingClient interface {
	Create(...)
	Update(...)
	Delete(...)
	List(...)
	Watch(...)
}

Better:

type InstanceGetter interface {
	GetInstance(ctx context.Context, id string) (*Instance, error)
}

21.13 Treating context as optional in I/O code

Bad:

func Fetch(url string) ([]byte, error)

Better:

func Fetch(ctx context.Context, url string) ([]byte, error)

21.14 Storing context in structs

Bad:

type Client struct {
	ctx context.Context
}

Usually better:

type Client struct {
	http *http.Client
}

func (c *Client) Get(ctx context.Context, id string) error {
	return nil
}

21.15 Overusing generics

Bad:

func Save[T any](thing T) error

Better:

func SaveInstance(instance Instance) error

Use domain types until a generic abstraction is clearly valuable.

21.16 Java-style architecture

Suspicious:

controllers/
services/
managers/
factories/
providers/
repositories/

Sometimes appropriate, but often excessive.

Prefer domain-oriented packages:

instances/
billing/
auth/
config/
server/
store/

21.17 Misreading time layouts

Wrong instinct:

now.Format("YYYY-MM-DD")

Go:

now.Format("2006-01-02")

21.18 Global mutable state

Bad:

var client = NewClient()

Better:

func run(ctx context.Context, args []string, client Client) error

Globals make tests and concurrency harder.

21.19 Returning too much abstraction

Bad:

func NewClient() ClientInterface

Often better:

func NewClient() *Client

Accept interfaces. Return concrete types.

21.20 Too much framework too early

Go's standard library is strong. Before adding a framework, learn:

  • net/http;
  • encoding/json;
  • context;
  • testing;
  • log/slog;
  • flag;
  • os, io, bufio, path/filepath.

22. Learning resources

Prefer official resources first. There is a lot of low-quality Go material online.

Official references

Important note on Effective Go

Effective Go remains useful for idioms, naming, formatting, interfaces, and the general feel of Go. However, it was written for Go's early era and does not cover important modern topics such as modules and generics. Read it, but do not treat it as the only modern Go guide.

What to study after this course

A practical follow-up order:

  1. Read standard library code around net/http, io, and context.
  2. Build a small CLI with JSON config and HTTP calls.
  3. Add table-driven tests and httptest.
  4. Add context cancellation and signal handling.
  5. Add a worker pool with bounded concurrency.
  6. Run go test -race ./... and fix issues.
  7. Add govulncheck to CI.
  8. Learn enough generics to read modern helper libraries.
  9. Profile one real program with pprof.
  10. Read production Go code from a project you trust.

Final condensed philosophy

Write Go like this:

func DoThing(ctx context.Context, input Input) (*Output, error) {
	if err := validate(input); err != nil {
		return nil, fmt.Errorf("validate input: %w", err)
	}

	result, err := callDependency(ctx, input)
	if err != nil {
		return nil, fmt.Errorf("call dependency: %w", err)
	}

	return &Output{Result: result}, nil
}

Prefer:

  • explicit data;
  • explicit errors;
  • small functions;
  • small interfaces;
  • composition;
  • boring names;
  • standard library first;
  • context-aware I/O;
  • table-driven tests;
  • race detection;
  • module hygiene.

Avoid:

  • clever abstractions;
  • deep inheritance-like designs;
  • huge interfaces;
  • hidden global state;
  • ignored errors;
  • goroutines without cancellation;
  • channels where a mutex is simpler;
  • translating Python idioms directly;
  • using generics to hide unclear design.

Go's strength is not that it lets you express everything with maximum elegance. Its strength is that, with discipline, six months later the code still looks obvious.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment