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)
- Write idiomatic Go with structs, functions, and methods using value and pointer receivers
- Manage concurrency with goroutines and channels, avoiding data races using the
selectstatement - Handle errors explicitly using the
errorreturn convention andfmt.Errorfwrapping - Use
go build,go test, andgo modto compile, test, and manage dependencies - Choose Go appropriately given its strengths (single binary, concurrency) and limitations (static typing, no generics before 1.18)
Table of Contents
- Architectural Overview & Core Schema
- Deep Dive & Implementation
- Anti-Patterns & Common Pitfalls
- Independent Challenge
- Consolidation & Key Invariants
- 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:
- Compiled to a single binary — no runtime dependency, easy to deploy
- Fast compilation — Go compiles in seconds, not minutes
- Goroutines — cheap concurrent functions (thousands per process)
- Channels — safe communication between goroutines
- Garbage collected — no manual memory management
- Strong standard library — networking, HTTP, JSON, encryption built in
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:
- Accepts work as a function
func() errorsubmitted to a queue. - Runs up to
Nworkers concurrently (configurable, default 10). - Returns results through a channel: each completed task sends its result
(index int, err error). - Supports graceful shutdown:
Shutdown()waits for in-flight tasks to complete but stops accepting new ones. - Tracks metrics: total submitted, total completed, total failed.
Constraints:
- Use only the standard library.
- The queue must not leak goroutines after
Shutdown()returns. - Workers must be cancellable via context (so in-flight work can be aborted if shutdown times out).
- The API must be safe for concurrent submission from multiple goroutines.
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
- Errors are values, not exceptions. Return
errorfrom every function that can fail. Never ignore errors silently. - Goroutines are cheap but not free. Each goroutine has a ~2 KB initial stack. A goroutine that is never closed or received from leaks. Always return or close the channel that carries the goroutine's result.
- Only one sender closes a channel. Closing a channel signals no more values. Sending on a closed channel panics.
- Pass large structs by pointer, small structs by value. A pointer receiver avoids copying; value receivers are fine for small structs and can reduce GC pressure.
- Use
context.Contextfor cancellation and timeouts in network and goroutine code. Pass it as the first argument to functions that perform I/O or spawn goroutines. go mod tidybefore every commit. It removes unused dependencies and ensuresgo.sumis accurate.- Use
go test -racein CI. The race detector catches data races that are otherwise intermittent and near-impossible to debug.
6. Next Steps
Continue your Go learning:
- Runtime — Understand Go's runtime: goroutine scheduler, GC, memory model, and how the runtime manages concurrency.
- Backend Development — Build APIs with Go's
net/http, integrate with databases, implement middleware patterns, and deploy services. - Containers — Package Go binaries in minimal Docker images with multi-stage builds for efficient deployment.
- Further Reading: The Go Programming Language spec, effective Go (go.dev/doc/effective_go), and the Go by Example site.
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. |