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.
- The Go mental model
- Suggested one-day pacing
- Python-to-Go map
- Project structure, packages, modules, and commands
- Basic syntax and semantics
- Types, zero values, conversions, and naming
- Strings, bytes, and Unicode
- Slices, arrays, and maps
- Structs, methods, pointers, and receivers
- Functions, multiple returns, errors, and defer
- Interfaces and composition
- Standard library essentials for cloud/Linux work
- Context: cancellation, timeouts, and request scope
- Concurrency: goroutines, channels, mutexes, and leaks
- Testing, benchmarking, fuzzing, and testability
- Generics: useful, but not the first tool
- Memory, performance, and runtime intuition
- Production workflow: CI, security, builds, and diagnostics
- Idiomatic style and code review checklist
- End-to-end reference pattern
- Common pitfalls
- Learning resources
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:
- Return errors explicitly.
- Prefer concrete data structures until abstraction is needed.
- Prefer small interfaces, usually defined by the consumer.
- Pass
context.Contextthrough I/O and long-running operations. - Use the standard library first.
- Test with
go test ./...constantly. - Do not hide complexity behind clever helpers.
- Let
gofmtdecide formatting.
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.
| 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.
package main
import "fmt"
func main() {
fmt.Println("hello")
}Important pieces:
package mainA package named main can build an executable command.
func main()This is the process entry point. There is no if __name__ == "__main__".
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 mainthen 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.
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/mytoolExample go.mod:
module github.com/example/mytool
go 1.26
require github.com/some/dependency v1.2.3A package inside this module might be imported as:
import "github.com/example/mytool/internal/config"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.
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.
go run ./cmd/mytool
go build ./cmd/mytool
go test ./...
go test -race ./...
go fmt ./...
go vet ./...
go mod tidyUseful 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 GOTOOLCHAINUse go get to change dependencies in the current module:
go get github.com/example/lib@v1.2.3
go mod tidyUse go install to install a command-line tool:
go install golang.org/x/vuln/cmd/govulncheck@latestGo 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/mytoolFor fully static-style Linux builds, cgo changes the story:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o mytool ./cmd/mytoolBe careful with DNS, certificates, libc assumptions, and packages that require cgo.
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
goline intentional. - Do not randomly bump it in generated changes.
- In CI, print
go version. - In reproducible environments, decide whether automatic toolchain downloads are acceptable.
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 := 10It declares a new variable and infers its type.
Outside functions, use var or const:
var buildVersion = "dev"
const defaultPort = 8080x := 1 // declaration
x = 2 // assignmentThis is invalid because x already exists and no new variable is introduced:
x := 1
x := 2 // compile errorBut this is valid because y is new:
x := 1
x, y := 2, 3That can be useful, but it can also hide shadowing bugs.
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.
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)
}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")
}Use:
go fmt ./...Go formatting is intentionally not a personal style choice. This is one of the best things about Go in teams.
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 mapPython 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()Go does not freely mix numeric types.
var x int = 10
var y int64 = 20
// z := x + y // compile error
z := int64(x) + yCommon numeric types:
int
int64
uint64
float64
byte // alias for uint8
rune // alias for int32Practical advice:
- Use
intfor counts and indexes inside memory. - Use explicit-width types like
int64for wire formats, timestamps, storage, and APIs. - Avoid unsigned integers unless the API or bit operation really requires them.
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.
Defined type:
type Region stringThis 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 = stringThis is just another name for string.
Use defined types when the distinction matters.
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.
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 hereWhen reviewing generated Go, watch for := in places where = was intended.
A Go string is an immutable sequence of bytes.
s := "ą"
fmt.Println(len(s)) // 2 in UTF-8, not 1This surprises Python users because Python's len("ą") counts Unicode code points.
byte // alias for uint8
rune // alias for int32, usually a Unicode code pointIterating 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.
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.
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")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.
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)) // 100a := []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])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)) // 0But 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.
counts := map[string]int{
"error": 3,
"warning": 7,
}
counts["info"] = 10Check 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"]) // 0That is why the ok form matters.
var m map[string]string
fmt.Println(len(m)) // 0
fmt.Println(m["x"]) // ""
// m["x"] = "y" // panicCreate a writable map with make:
m := make(map[string]string)
m["x"] = "y"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])
}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.
Do not read/write ordinary maps concurrently without synchronization.
Use one of:
sync.Mutex;sync.RWMutex;- a single owner goroutine;
sync.Mapfor specialized concurrent map use cases.
Python dataclass:
@dataclass
class Instance:
id: str
region: str
running: boolGo 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.
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.
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())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;
nilreceiver behavior is intentional.
Use value receivers when:
- the type is small and immutable-like;
- copying is cheap;
- you want value semantics.
x := 10
p := &x
fmt.Println(*p) // 10
*p = 20
fmt.Println(x) // 20Go 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.
new(T) allocates a zero value of type T and returns *T:
p := new(int)
*p = 10make 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"}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
}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.
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)
}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.
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 cleanupEven then, a comment helps.
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 firstArguments are evaluated when defer is declared, not when it runs:
x := 1
defer fmt.Println(x)
x = 2
// prints 1Use 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.
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.
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.
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.
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.
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.
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) // falseThe interface is not nil because it contains a dynamic type: *EmailNotifier.
Practical advice:
- Return concrete nils carefully.
- Prefer returning
nildirectly for interface return values when there is no value. - Be cautious with
errorimplementations that may be nil pointers.
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.
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.
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; omitemptyomits zero values, which may be wrong for booleans or numbers.
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.
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.
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.
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")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.
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.
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.
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.
func ListInstances(ctx context.Context, region string) ([]Instance, error) {
// pass ctx to I/O calls
return nil, nil
}Rules:
ctxis usually the first parameter.- Do not store context in structs.
- Always call the
cancelfunction returned byWithCancel,WithTimeout, orWithDeadline. - Use context values sparingly.
- Do not use context as a general dependency injection container.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
instances, err := ListInstances(ctx, "eu-west-1")
if err != nil {
return err
}
_ = instancesfunc 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()
}
}
}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.
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.
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.
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.
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.
select {
case msg := <-messages:
fmt.Println("message:", msg)
case <-ctx.Done():
return ctx.Err()
}select waits for one of several channel operations.
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.
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
}Run:
go test -race ./...The race detector only detects races exercised at runtime. Good tests matter.
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.
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()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 ./...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.
Same package:
package configCan test unexported functions.
External package:
package config_testTests 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.
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)
}
}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)
}
}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.
func BenchmarkNormalizeRegion(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = NormalizeRegion(" EU-WEST-1 ")
}
}Run:
go test ./... -bench .
go test ./... -bench . -benchmemFuzzing 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 FuzzNormalizeRegionfunc TestThing(t *testing.T) {
t.Parallel()
// test code
}Be careful with shared state, environment variables, ports, current directory, and global configuration.
Go has generics, but most everyday Go still uses concrete types, interfaces, and simple functions.
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"})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
}type Result[T any] struct {
Value T
Err error
}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
}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.
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.
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)
}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.
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.
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.
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))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.
go fmt ./...
go test ./...
go vet ./...
go mod tidyAdd race detection regularly:
go test -race ./...For larger codebases, run -race in CI or nightly if it is too slow for every commit.
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.
Go modules use semantic import versioning.
For v2 and later, the module path includes the major version:
module example.com/mylib/v2Imports also include /v2:
import "example.com/mylib/v2/client"This is a common source of confusion.
During local development:
replace example.com/theirmodule => ../theirmoduleUseful, but do not accidentally ship a local-only replace unless intended.
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.
Install:
go install golang.org/x/vuln/cmd/govulncheck@latestRun:
govulncheck ./...This is especially relevant for cloud services and infrastructure tools that pull in many transitive dependencies.
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 ./...Inject version information:
package main
var version = "dev"Build:
go build -ldflags "-X main.version=$(git describe --tags --always --dirty)" ./cmd/mytoolctx, 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)
}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.
Good Go is usually:
- explicit;
- flat;
- small;
- named clearly;
- organized by domain;
- easy to test;
- light on abstraction;
- boring in the best way.
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.ConfigPrefer 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
}
}
}When Codex or another tool writes Go, check these first.
Suspicious:
value, _ := doThing()Better:
value, err := doThing()
if err != nil {
return fmt.Errorf("do thing: %w", err)
}Network, database, subprocess, cloud SDK, and long-running functions should usually accept context:
func DoThing(ctx context.Context, input Input) errorLook for:
defer file.Close()
defer resp.Body.Close()
defer cancel()
defer ticker.Stop()Suspicious:
go func() {
for {
...
}
}()Ask how it stops.
Suspicious:
m := map[string]string{}
go func() {
m["x"] = "y"
}()Use synchronization or a single owner.
Suspicious:
type HugeClient interface {
Create(...)
Update(...)
Delete(...)
List(...)
Watch(...)
Sync(...)
Validate(...)
}Prefer small consumer-side interfaces.
Suspicious:
utils
helpers
common
misc
Prefer domain names.
Ask for table-driven tests and failure cases, not just happy paths.
Suspicious:
type UserManagerFactoryBuilder interface {
BuildUserManagerFactory() UserManagerFactory
}Go does not need ceremony for its own sake.
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.
This section shows a compact cloud-style CLI shape. It is not an exercise; it is a pattern to study.
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.
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
}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:
mainis small;runis testable because it acceptsargs;- 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.
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.
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.
Bad:
result, _ := doThing()Better:
result, err := doThing()
if err != nil {
return fmt.Errorf("do thing: %w", err)
}Bad:
append(xs, 1)Good:
xs = append(xs, 1)Bad:
var m map[string]string
m["x"] = "y" // panicGood:
m := make(map[string]string)
m["x"] = "y"var xs []string // JSON null
ys := []string{} // JSON []If clients expect [], initialize the slice.
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)Bad:
func RegionName(region *string) stringUsually better:
func RegionName(region string) stringBad:
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
}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()Bad:
go watchForever()Better:
go watch(ctx)Then make watch honor ctx.Done().
Bad:
// elaborate channel protocol just to protect a mapGood:
mu.Lock()
m[key] = value
mu.Unlock()Bad:
type EverythingClient interface {
Create(...)
Update(...)
Delete(...)
List(...)
Watch(...)
}Better:
type InstanceGetter interface {
GetInstance(ctx context.Context, id string) (*Instance, error)
}Bad:
func Fetch(url string) ([]byte, error)Better:
func Fetch(ctx context.Context, url string) ([]byte, error)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
}Bad:
func Save[T any](thing T) errorBetter:
func SaveInstance(instance Instance) errorUse domain types until a generic abstraction is clearly valuable.
Suspicious:
controllers/
services/
managers/
factories/
providers/
repositories/
Sometimes appropriate, but often excessive.
Prefer domain-oriented packages:
instances/
billing/
auth/
config/
server/
store/
Wrong instinct:
now.Format("YYYY-MM-DD")Go:
now.Format("2006-01-02")Bad:
var client = NewClient()Better:
func run(ctx context.Context, args []string, client Client) errorGlobals make tests and concurrency harder.
Bad:
func NewClient() ClientInterfaceOften better:
func NewClient() *ClientAccept interfaces. Return concrete types.
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.
Prefer official resources first. There is a lot of low-quality Go material online.
- How to Write Go Code
- Go Documentation
- A Tour of Go
- The Go Language Specification
- Effective Go
- Go Modules Reference
- go.mod file reference
- Go Toolchains
- Go package documentation
- Package context
- Package log/slog
- Data Race Detector
- Testing tutorial
- Generics tutorial
- Govulncheck tutorial
- Go release notes
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.
A practical follow-up order:
- Read standard library code around
net/http,io, andcontext. - Build a small CLI with JSON config and HTTP calls.
- Add table-driven tests and
httptest. - Add context cancellation and signal handling.
- Add a worker pool with bounded concurrency.
- Run
go test -race ./...and fix issues. - Add
govulncheckto CI. - Learn enough generics to read modern helper libraries.
- Profile one real program with pprof.
- Read production Go code from a project you trust.
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.