Go Basics — Concurrent, Compiled, and Clear

Status: Active | Last Updated: 2026-09-15 Category: Languages — Go Prerequisites: Basic programming concepts (see kb/basics/) Tags: go, golang, goroutines, channels, modules Estimated Time: 4-6 hours (Self-paced, includes lab time)

Summary

Go (also called Golang) is a compiled, statically typed language designed at Google for simplicity and concurrency. It compiles to a single binary, has fast compile times, and makes concurrent programming straightforward with goroutines and channels. This article covers Go fundamentals, its toolchain, and when to use Go over TypeScript or Rust.

What You'll Learn (Core Competencies)

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. Next Steps

1. Architectural Overview & Core Schema

Why Go

Go was designed to be simple, fast to compile, and able to handle concurrent workloads at scale. Key properties:

The Compilation and Runtime Model

Go compiles to a self-contained native binary. There is no VM, no interpreter, and no runtime dependency beyond the Go runtime itself (which is embedded in the binary):

Layer What Exists Notes
Source Go source files *.go
Compile-time go build or gc Generates native machine code
Runtime Go scheduler + GC Embedded in the binary; manages goroutines and memory
Output Single executable Static binary, no external dependencies (except CGO)

The Go runtime includes a scheduler that multiplexes thousands of goroutines onto a smaller number of OS threads (one per logical CPU by default). Because of the scheduler, goroutines are cheap: a goroutine starts with a ~2 KB stack that grows and shrinks dynamically. You can have hundreds of thousands of goroutines in a single process without exhausting memory.

Error Handling as Values

Go has no exceptions for routine control flow. Errors are ordinary values returned as the last return value of every function that can fail. The convention is (value, error):

result, err := doSomething()
if err != nil {
    return nil, fmt.Errorf("doing something: %w", err)
}

This is not verbose ceremony — it is a design philosophy. Every error is visible at the call site, not silently caught by a blanket handler somewhere else in the stack. The error interface has a single method: Error() string. The %w verb in fmt.Errorf wraps the error, enabling errors.Is() and errors.As() to inspect the causal chain.

Composition Over Inheritance

Go has no inheritance. Types are composed: a struct embeds another struct to reuse its fields and methods. This is zero-cost at runtime (no vtable dispatch) and avoids the fragile base class problem. Duck typing through interfaces means any type that implements an interface's methods satisfies it — no explicit declaration is needed.


2. Deep Dive & Implementation

Syntax and Types

Go looks like C but without the sharp edges. Types are declared with names, not after variables. There is no inheritance — Go uses composition via embedded structs. All variables must have a type (explicit or inferred at declaration):

package main

import "fmt"

func main() {
    name := "fogserv"       // string (inferred via short declaration)
    count := 42             // int (inferred)
    ratio := 3.14           // float64 (inferred)
    enabled := true         // bool

    fmt.Printf("Hello, %s\n", name)
}

Short declaration (:=) is only allowed inside functions. At package level, use var or const. The var declaration can include initialization or be deferred:

var port int = 3000
var message = "hello"     // type inferred
var count int             // zero value (0)

const maxConnections = 1000

Go's basic types: bool, string, int, int8, int16, int32, int64, uint (and unsigned variants), float32, float64, complex64, complex128, byte (alias for uint8), rune (alias for int32, for Unicode code points). The int and uint sizes depend on the target architecture (32-bit or 64-bit). Use explicit-size types when the size matters for protocols or serialization.

Zero values are defined for every type: 0 for numerics, "" for strings, false for bool, nil for pointers/slices/maps/channels/functions/interfaces, and the zero-value struct for struct types. There is no null pointer exception — dereferencing a nil pointer causes a runtime panic only if the pointer is never assigned.

Structs instead of Classes

Go has no classes. Use structs with methods:

type Server struct {
    Host string
    Port int
}

func (s Server) Address() string {
    return fmt.Sprintf("%s:%d", s.Host, s.Port)
}

func (s *Server) SetPort(p int) {
    s.Port = p     // pointer receiver allows mutation
}

Value receivers (func (s Server)) copy the struct. Pointer receivers (func (s *Server)) avoid copying and allow mutation. Use pointer receivers for large structs and when mutating state.

Control Flow

if requires braces, and the condition does not need parentheses. else if is else if. The switch statement is cleaner than chained if/else and can match on expressions, not just constants:

status := "running"
switch status {
case "running":
    fmt.Println("Service is up")
case "stopped":
    fmt.Println("Service is down")
default:
    fmt.Println("Unknown status")
}

// Switch on expression
switch score := calculate(); {
case score >= 90:
    grade = "A"
case score >= 80:
    grade = "B"
}

Loops use only for. for is Go's only loop construct — no while, no do/while. The three forms are: for i := 0; i < 10; i++ (classic), for condition (while-style), and for range collection (iterator-style).

Functions and Packages

Function Definitions

Functions are declared with func. They can have named return values, which become the return variables automatically. Named returns are useful when a function has multiple outputs and clear names improve readability:

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

q, err := divide(10, 2)

Go supports variadic functions with ...T. The variadic parameter becomes a slice of the parameter type inside the function:

func sum(nums ...int) int {
    total := 0
    for _, n := range nums {
        total += n
    }
    return total
}

sum(1, 2, 3, 4)     // 10
sum([]int{1,2,3}...) // pass a slice with spread

Packages and Visibility

A package groups related files. A file starts with package name. All identifiers (variables, functions, types) that start with a capital letter are exported (public); lowercase identifiers are unexported (private) to the package. This convention replaces public/private keywords.

Every Go file must declare its package. Executable packages are named main. Library packages use any other name, typically matching the directory.

package main

import (
    "fmt"
    "net/http"
    "os"
)

The import path maps to a module path plus directory. Go modules (introduced in Go 1.11, standard in Go 1.13+) define the module path in go.mod. Import paths must be absolute and begin with the module path or a domain name.

Modules and Dependencies

A Go module is a collection of Go packages with a go.mod file. Initialize with go mod init <module-path> (e.g., go mod init fogserv/cloud). Dependencies are declared explicitly and resolved by the Go module proxy.

Goroutines and Concurrency

Goroutines

A goroutine is a lightweight thread managed by the Go runtime. Start one with the go keyword before a function call:

func fetch(url string) string {
    resp, err := http.Get(url)
    if err != nil {
        return ""
    }
    defer resp.Body.Close()
    body, _ := io.ReadAll(resp.Body)
    return string(body)
}

func main() {
    go fetch("https://fogserv.cloud")
    go fetch("https://api.fogserv.cloud")
    time.Sleep(time.Second)   // wait for goroutines
}

Goroutines are multiplexed onto a smaller number of OS threads by the Go scheduler. The default is one OS thread per logical CPU. A goroutine starts with a small stack (2KB) that grows and shrinks dynamically. You can run thousands or millions of goroutines without exhausting system resources.

Channels

Channels are typed pipes for safe communication between goroutines. They prevent data races by enforcing that only one goroutine sends or receives at a time (the channel itself is synchronized). Create with make(chan T) or make(chan T, buffer):

ch := make(chan string)

go func() {
    ch <- "done"   // send — blocks until received
}()

result := <-ch     // receive — blocks until sent

Buffered channels (make(chan T, 2)) allow sends to proceed without a matching receive up to the buffer size. Use them to decouple producers from consumers or to implement rate limiting.

Close channels when done: close(ch). Receiving from a closed channel yields the zero value immediately (with v, ok := <-ch; ok is false when the channel is closed and empty). Never send on a closed channel — it panics.

Select and Patterns

select waits on multiple channel operations, similar to switch for channels. It blocks until one case can proceed. If multiple cases are ready, one is chosen randomly — this avoids starvation:

select {
case msg := <-ch1:
    fmt.Println("ch1:", msg)
case msg := <-ch2:
    fmt.Println("ch2:", msg)
case <-time.After(2 * time.Second):
    fmt.Println("timeout")
}

Use select for timeouts, multiplexing input sources, and coordinating goroutines. The default case makes the select non-blocking.

Error Handling

Go has no exceptions. Errors are ordinary values returned alongside the result. The convention is to return (value, error). Callers must check err != nil and handle it explicitly:

func readConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, fmt.Errorf("reading config: %w", err)
    }
    var cfg Config
    if err := json.Unmarshal(data, &cfg); err != nil {
        return Config{}, fmt.Errorf("parsing config: %w", err)
    }
    return cfg, nil
}

cfg, err := readConfig("config.json")
if err != nil {
    log.Fatal(err)
}

The fmt.Errorf with %w wraps the error, allowing errors.Is() and errors.As() to inspect the cause. Never use panic for routine error conditions — reserve panics for unrecoverable programming errors.

Error Types

Define custom error types when you need structured error information. Implement the error interface (a single Error() string method). Use errors.New() for simple messages and fmt.Errorf() for formatted messages:

type ConfigError struct {
    Path string
    Cause error
}

func (e ConfigError) Error() string {
    return fmt.Sprintf("config error at %s: %v", e.Path, e.Cause)
}

Toolchain: go build, test, mod

go build

Compiles the package or module into an executable binary (for main packages) or a library archive (for other packages). By default, it writes to the current directory. Use -o to specify an output path and -ldflags for link-time customization:

go build -o fogserv-server ./cmd/server
go build -ldflags="-s -w" .   # strip debug info, smaller binary

go build produces a binary that includes the Go runtime and has no external dependencies (unless using CGO). This makes deployment simple: copy the binary to the server and run.

go test

Go has built-in testing via go test. Test files are named *_test.go and declare package foo or package foo_test. Use testing.T for assertions:

func TestDivide(t *testing.T) {
    q, err := divide(10, 2)
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
    if q != 5.0 {
        t.Errorf("expected 5.0, got %f", q)
    }
}

Table-driven tests are the Go idiom: define input/output pairs in a slice and loop:

func TestDivideAll(t *testing.T) {
    cases := []struct{ a, b, want float64 }{
        {10, 2, 5},
        {7, 2, 3.5},
        {6, 3, 2},
    }
    for _, c := range cases {
        got, _ := divide(c.a, c.b)
        if got != c.want {
            t.Errorf("divide(%f,%f) = %f; want %f", c.a, c.b, got, c.want)
        }
    }
}

Run with go test ./... to test all packages. Add -race to enable the race detector, -count=1 to disable test caching, and -v for verbose output.

go mod

The module system manages dependencies. Key commands:

go mod init fogserv/cloud     # initialize a module
go get github.com/pkg/http@v2  # add a dependency
go mod tidy                    # clean up unused imports/deps
go mod download                # download all dependencies
go mod vendor                  # create vendor/ directory for offline builds

The go.sum file records cryptographic checksums for each dependency, ensuring reproducible builds. Commit both go.mod and go.sum to version control. Use go mod verify to check integrity.

Guided Checkpoint

Initialize a Go module, write a concurrent HTTP fetcher, run the tests, and build a binary:

cd /tmp/go-checkpoint
mkdir -p fetcher && cd fetcher
go mod init fetcher

cat > main.go <<'EOF'
package main

import (
    "fmt"
    "io"
    "net/http"
    "time"
)

func fetch(url string) (string, error) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return "", fmt.Errorf("fetching %s: %w", url, err)
    }
    defer resp.Body.Close()
    body, err := io.ReadAll(resp.Body)
    if err != nil {
        return "", fmt.Errorf("reading %s: %w", url, err)
    }
    return fmt.Sprintf("%s -> %d (%d bytes)", url, resp.StatusCode, len(body)), nil
}

func main() {
    urls := []string{
        "https://httpbin.org/get",
        "https://httpbin.org/status/200",
    }
    for _, url := range urls {
        go func(u string) {
            result, err := fetch(u)
            if err != nil {
                fmt.Println("error:", err)
                return
            }
            fmt.Println(result)
        }(url)
    }
    time.Sleep(6 * time.Second)
}
EOF

go mod tidy
go run .
# Expected: prints HTTP status lines for both URLs within 5s timeout

go build -o fetcher .
./fetcher
# Expected: same output as `go run`

3. Anti-Patterns & Common Pitfalls

These failure modes appear repeatedly in Go codebases. Each is detectable, preventable, and rooted in Go's concurrency model and error handling philosophy.

Anti-Pattern: Goroutine Leaks

A goroutine that is never closed and whose result is never received leaks. The Go runtime keeps it alive indefinitely, consuming memory and goroutine scheduler slots.

// Bad: goroutine started but result never read
func fetch(url string) {
    go func() {
        resp, _ := http.Get(url)  // goroutine leaks if caller never receives
        defer resp.Body.Close()
        // ...
    }()
}

// Good: channel is returned, caller can range over it or read once
func fetch(url string) <-chan string {
    ch := make(chan string)
    go func() {
        defer close(ch)
        resp, err := http.Get(url)
        if err != nil {
            ch <- "error: " + err.Error()
            return
        }
        defer resp.Body.Close()
        body, _ := io.ReadAll(resp.Body)
        ch <- fmt.Sprintf("%s -> %d", url, resp.StatusCode)
    }()
    return ch
}

Detection: use the go test -race detector and profile goroutine counts over time with pprof.

Fix: always return or close channels from goroutines. Use context.Context with a timeout to cancel in-flight work.

Anti-Pattern: Sending on a Closed Channel

Closing a channel signals "no more values will be sent." Sending on a closed channel panics immediately.

// Bad
ch := make(chan int)
close(ch)
ch <- 1  // panic: send on closed channel

Fix: ensure only one sender closes a channel — typically the producer, not the consumer. Use defer close(ch) at the start of the goroutine that owns it. A sync.WaitGroup is often a cleaner way to wait for multiple goroutines than channel close semantics.

Anti-Pattern: Ignoring Errors

_, err := f() discards the error. In Go, errors are not exceptional — they are the expected path for failures. Ignoring them means failures go undetected:

// Bad
_ = json.Unmarshal(data, &cfg)

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

Detection: run errcheck or enable the vet checks in your CI pipeline.

Fix: handle every error at the call site. If you genuinely cannot handle an error at this point, return it or log it. Underscore-assignment of errors should be extremely rare and deliberate.

Anti-Pattern: nil Slices vs Empty Slices

A nil slice is distinct from an empty slice []int{}. Serializing a nil slice produces null in JSON; serializing []int{} produces []. Returning nil from a function that returns a slice is idiomatic (meaning "no items"), but be aware of JSON encoding implications.

// Idiomatic nil slice — no allocation, serializes to null
var items []Item

// Explicit empty slice — always allocates, serializes to []
items = []Item{}

Fix: return nil when the absence of items is semantically meaningful. Use items == nil to check. In JSON API responses, decide whether "no items" should be null or [] and be consistent.

Anti-Pattern: Passing Large Structs by Value

Go passes arguments by value. For large structs, this copies every field. Passing a pointer avoids the copy:

// Bad for large structs — copies all fields
func process(cfg Config) { ... }

// Good — passes pointer, no copy
func process(cfg *Config) { ... }

Detection: go vet with the composite analyzer, or benchmark with testing.B.

Fix: use pointer receivers for large structs. For small structs (a few fields, no more than a cache line), value receivers are fine and can reduce GC pressure.


4. Independent Challenge

Design a concurrent task queue with graceful shutdown, without step-by-step guidance.

Produce a Go package that:

  1. Accepts work as a function func() error submitted to a queue.
  2. Runs up to N workers concurrently (configurable, default 10).
  3. Returns results through a channel: each completed task sends its result (index int, err error).
  4. Supports graceful shutdown: Shutdown() waits for in-flight tasks to complete but stops accepting new ones.
  5. Tracks metrics: total submitted, total completed, total failed.

Constraints:

Deliverable: a single .go file with an exported TaskQueue type. Verify goroutine count before and after shutdown using the runtime package. No solution is provided.


5. Consolidation & Key Invariants


6. Next Steps

Continue your Go learning:


Change Log

Date Change
2026-09-15 Full hydration: expanded all sections with substantive content, added code examples for every concept, added Next Steps and Change Log
2026-08-29 Initial stub created, section headers defined
2026-09-15 Migrated to canonical 6-section template (Architectural Overview, Deep Dive with Guided Checkpoint, Anti-Patterns, Independent Challenge, Consolidation, Next Steps). Preserved all prior content, prerequisites, and change log entries with dates.

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse