Programming 101 · Start here

Learn Go
from syntax to backends

A single-page course for newcomers and returning developers: language basics, modules and packages, concurrency, HTTP, SQL, and patterns used in real services. No prior Go required—comfort with any programming language helps.

go mod Types & structs Goroutines Channels net/http database/sql
12
Sections
40+
Code examples
Practice
01

Why Go for Backend?

Go excels at

✓ HTTP servers & microservices
✓ CLI tools & DevOps
✓ Data pipelines
✓ gRPC services
✓ High-concurrency systems

Why not Python/Node?

✓ Single binary deploy
✓ Native concurrency (goroutines)
✓ ~10x faster than Python
✓ ~3x less memory than JVM
✓ No runtime dependencies

Go is used by Docker, Kubernetes, Prometheus, Terraform, CockroachDB, and Cloudflare. If you work with cloud infrastructure, you will read Go code.
📚
How to use this page: Work top to bottom or jump via the sidebar. Expand each topic, copy examples into a .go file, and run with go run . after go mod init (see Modules & toolchain under Basics). Run tests with go test ./... and use go test -race when you add goroutines.
02

Basics & Types

🔤

Variables & Declaration

var, :=, constants, zero values

Basics

Go has two ways to declare variables. var for package-level and explicit typing. := (short declaration) inside functions for type inference. Uninitialized variables get their zero value automatically — no undefined behavior.

Zero values by type
TypeZero valueExample
int, int640var n int → 0
float640.0var f float64 → 0.0
string""var s string → ""
boolfalsevar b bool → false
pointernilvar p *int → nil
slice, mapnilvar m map[string]int → nil
go
package main

// Package-level: must use var
var serverPort int = 8080
var serviceName string  // zero value = ""

// Constant — cannot be changed
const MaxRetries = 3

func main() {
    // Short declaration — type inferred
    host := "localhost"
    port := 5432

    // Multiple assignment
    x, y := 10, 20

    // Explicit type (useful for clarity)
    var timeout int64 = 30

    // Blank identifier — discard value
    result, _ := someFunction()
}
Use := inside functions (most common). Use var at package level or when you need an explicit type. Never use var for everything — that's not idiomatic Go.
📁

Modules, packages & toolchain

go mod init, imports, go run / go build / go test

Toolchain101

Since Go 1.16+, modules are the standard way to declare dependencies. A module is a tree of packages with a go.mod file at the root; the first line is your module path (often your repo URL). A package is a directory of .go files with the same package clause; package main with a func main() builds an executable.

shell — start a project
mkdir hello && cd hello
go mod init example.com/hello
# go.mod created — edit module path to match your VCS URL for libraries

go get github.com/google/uuid@latest   # add a dependency
go mod tidy                             # drop unused, add missing

go run .                                # compile and run package in current dir
go build -o app .                       # binary named app
go test ./...                           # all packages under module
go — multi-package layout (common)
// cmd/server/main.go — thin entrypoint
package main

import "example.com/hello/internal/api"

func main() {
    api.Listen(":8080")
}

// internal/api — cannot be imported by other modules (enforced by compiler)
// pkg/ — public library surface if you publish the module
Use internal/ for code that must not become an external API. For application code, many teams use cmd/<name>/main.go plus internal/... and skip pkg/ until they actually publish a library.
📦

Slices & Maps

Go's primary collection types

Collections

Slices are Go's dynamic arrays — they reference an underlying array with a pointer, length, and capacity. Maps are unordered key-value stores backed by a hash table. Both are reference types.

go — slices
// Create slice: nil, empty, or with values
var ids []int                        // nil slice
names := []string{}                  // empty, non-nil
scores := []int{95, 87, 92}          // with values

// make(type, len, cap) — pre-allocate for performance
results := make([]string, 0, 100)   // cap 100, avoids re-alloc

// Append (may allocate new backing array)
ids = append(ids, 1, 2, 3)

// Slice of slice: [low:high] — shares backing array!
first := scores[0:2]   // [95, 87] — NOT a copy

// Range loop — i=index, v=value
for i, v := range scores {
    fmt.Printf("[%d] = %d\n", i, v)
}
go — maps
// Must initialize before use (nil map panics on write)
cache := make(map[string]int)
config := map[string]string{
    "host": "localhost",
    "port": "5432",
}

// Read: always use two-value form to check existence
val, ok := config["host"]   // ok = false if missing
if !ok {
    // key not found — handle it
}

// Write and delete
cache["user:42"] = 1
delete(cache, "user:42")

// Iterate (order is random)
for k, v := range config {
    fmt.Printf("%s = %s\n", k, v)
}
Maps are not safe for concurrent use. Use sync.Map or a mutex-protected map when multiple goroutines read/write. This is a common source of race conditions.
03

Functions

Functions, Defer & Closures

Multiple returns, defer, first-class functions

Functions

Go functions can return multiple values — the idiomatic pattern is (result, error). Defer schedules a function call to run when the surrounding function returns — essential for cleanup. Functions are first-class values.

go
// Multiple return values — idiomatic (result, error)
func fetchUser(id int) (*User, error) {
    if id <= 0 {
        return nil, fmt.Errorf("invalid id: %d", id)
    }
    return &User{ID: id}, nil
}

// Defer: runs LIFO when function returns
// — cleanup, unlock, close file
func readFile(path string) (string, error) {
    f, err := os.Open(path)
    if err != nil {
        return "", err
    }
    defer f.Close()   // always closes, even on panic

    // ... read file ...
}

// Named return values + defer for cleanup pattern
func withTx(db *sql.DB, fn func(*sql.Tx) error) (err error) {
    tx, _ := db.Begin()
    defer func() {
        if err != nil { tx.Rollback() } else { tx.Commit() }
    }()
    err = fn(tx)
    return
}

// Closures capture variables from outer scope
func counter() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}
Always defer close/unlock immediately after a successful open/lock — before any error handling that might return early. This is the most reliable pattern to prevent resource leaks.
04

Structs & Interfaces

🧱

Structs, Methods & Interfaces

Go's approach to object-oriented design

TypesInterfaces

Go has no classes. You compose behavior with structs + methods + interfaces. Interfaces are satisfied implicitly — any type with the right method set implements the interface. No implements keyword.

go
// Struct definition
type User struct {
    ID    int
    Name  string
    Email string
    age   int    // unexported (lowercase) = package-private
}

// Pointer receiver: modifies the struct + avoids copy
func (u *User) SetAge(age int) { u.age = age }

// Value receiver: read-only, gets a copy
func (u User) DisplayName() string {
    return fmt.Sprintf("%s <%s>", u.Name, u.Email)
}

// Interface — implicit satisfaction
type UserStore interface {
    Get(id int) (*User, error)
    Save(u *User) error
    Delete(id int) error
}

// PostgresStore implicitly satisfies UserStore
type PostgresStore struct { db *sql.DB }
func (s *PostgresStore) Get(id int) (*User, error)  { /* ... */ }
func (s *PostgresStore) Save(u *User) error          { /* ... */ }
func (s *PostgresStore) Delete(id int) error          { /* ... */ }

// Accept interface, not concrete type → easy to mock in tests
func NewUserService(store UserStore) *UserService {
    return &UserService{store: store}
}
Go proverb: "Accept interfaces, return concrete types." Your functions should take the smallest interface they need, and return the richest concrete type they can. This maximizes flexibility and testability.
05

Goroutines

Goroutines — Lightweight Concurrency

go keyword, WaitGroup, fan-out patterns

Concurrency

Goroutines are Go's lightweight threads — they start with ~2KB of stack (vs ~1MB for OS threads) and are multiplexed onto OS threads by the Go scheduler. You can run millions of goroutines simultaneously.

Goroutine fan-out — process N items concurrently
Main goroutine
go worker(1)
go worker(2)
go worker(3)
wg.Wait()
go worker(4)
go worker(5)
go worker(6)

All blue boxes run concurrently. Main waits for all to finish via WaitGroup.

go
import "sync"

func processItems(items []Item) []Result {
    var wg sync.WaitGroup
    results := make([]Result, len(items))

    for i, item := range items {
        wg.Add(1)
        go func(idx int, it Item) {  // pass as params — avoid closure bug
            defer wg.Done()
            results[idx] = process(it)
        }(i, item)
    }

    wg.Wait()
    return results
}

// Goroutine with context cancellation
func worker(ctx context.Context, jobs <-chan Job) {
    for {
        select {
        case <-ctx.Done():
            return   // context cancelled — shut down cleanly
        case job, ok := <-jobs:
            if !ok { return }   // channel closed
            process(job)
        }
    }
}
Classic closure bug: go func() { fmt.Println(i) }() inside a loop captures the variable i by reference — all goroutines may print the same final value. Always pass loop variables as function parameters.
06

Channels

🔀

Channels & Select

Pipeline patterns, fan-in, timeouts

ConcurrencyPipeline

Channels are typed conduits for communication between goroutines. Go's philosophy: "Do not communicate by sharing memory; share memory by communicating."

Pipeline pattern — each stage is a goroutine
Generate
goroutine
chan int
channel
Transform
goroutine
chan string
channel
Consume
goroutine
go
// Unbuffered: sender blocks until receiver is ready
ch := make(chan int)

// Buffered: sender only blocks when buffer is full
chBuf := make(chan int, 10)

// Directional channels in function signatures
func producer(out chan<- int) { out <- 42 }   // send-only
func consumer(in <-chan int) { v := <-in }    // recv-only

// Select: multiplex channels + timeout pattern
func fetchWithTimeout(url string) (string, error) {
    resultCh := make(chan string, 1)
    go func() {
        resultCh <- httpGet(url)
    }()

    select {
    case result := <-resultCh:
        return result, nil
    case <-time.After(5 * time.Second):
        return "", fmt.Errorf("timeout")
    }
}

// Close channel to signal "no more data"
// Range over channel reads until closed
for item := range dataCh {
    process(item)   // exits when dataCh is closed
}
Sending on a closed channel panics. The rule: the sender closes, never the receiver. Only close a channel when you are certain no more sends will happen.
07

Sync Primitives

🔒

Mutex, RWMutex & sync.Once

Protecting shared state from data races

SyncConcurrency
go
// Thread-safe in-memory cache
type Cache struct {
    mu   sync.RWMutex          // multiple readers OR one writer
    data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()           // shared read lock
    defer c.mu.RUnlock()
    v, ok := c.data[key]
    return v, ok
}

func (c *Cache) Set(key, val string) {
    c.mu.Lock()             // exclusive write lock
    defer c.mu.Unlock()
    c.data[key] = val
}

// sync.Once: run initialization exactly once
var (
    instance *DB
    once     sync.Once
)

func GetDB() *DB {
    once.Do(func() {
        instance = connectDB()   // runs exactly once, ever
    })
    return instance
}

// Atomic operations (no mutex needed for single values)
var requestCount atomic.Int64
requestCount.Add(1)
n := requestCount.Load()
Run go test -race ./... during development. Go's built-in race detector catches data races at runtime — it's the most valuable tool for concurrent code correctness.
08

Error Handling

Idiomatic Error Handling

fmt.Errorf %w, errors.Is/As, sentinel errors, panic/recover

Errors

Go errors are values — no exceptions. The pattern is always: check the error, handle it, or wrap and return it. Never ignore errors in production code.

Check immediately
if err != nil — always, every time, no exceptions
Wrap with context
fmt.Errorf("fetchUser(%d): %w", id, err) — add context, preserve original
Panic only for programmer errors
nil pointer deref, invalid state — not for expected runtime errors
go
// Sentinel errors — check identity with errors.Is
var ErrNotFound = errors.New("not found")
var ErrUnauthorized = errors.New("unauthorized")

// Custom error type — check type with errors.As
type ValidationError struct {
    Field   string
    Message string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation: %s: %s", e.Field, e.Message)
}

// Wrapping with %w — preserves error chain
func getUser(id int) (*User, error) {
    u, err := db.QueryUser(id)
    if err != nil {
        return nil, fmt.Errorf("getUser(%d): %w", id, err)
    }
    return u, nil
}

// Unwrapping — check type in wrapped chain
err := getUser(42)
if errors.Is(err, ErrNotFound) {
    // works even if err is wrapped multiple levels deep
}

var ve *ValidationError
if errors.As(err, &ve) {
    fmt.Println(ve.Field)   // access the concrete type
}

// Recover from panic — use in HTTP handler middleware
func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                http.Error(w, "internal server error", 500)
            }
        }()
        next.ServeHTTP(w, r)
    })
}
09

HTTP Server

🌐

Building an HTTP Server

net/http, routing, middleware, JSON API

HTTPREST
HTTP request flow in Go
Client
GET /users/42
TCP
Middleware
Logger, Auth
Recovery, CORS
chain
ServeMux
route matching
dispatch
Handler
func(w, r)
JSON response
go — complete HTTP server
package main

import (
    "encoding/json"
    "log/slog"
    "net/http"
)

type Server struct {
    store  UserStore
    router *http.ServeMux
}

func NewServer(store UserStore) *Server {
    s := &Server{store: store, router: http.NewServeMux()}
    s.router.HandleFunc("GET /users/{id}", s.getUser)   // Go 1.22+
    s.router.HandleFunc("POST /users", s.createUser)
    return s
}

func (s *Server) getUser(w http.ResponseWriter, r *http.Request) {
    id := r.PathValue("id")   // Go 1.22 path params
    user, err := s.store.Get(id)
    if errors.Is(err, ErrNotFound) {
        writeJSON(w, 404, map[string]string{"error": "not found"})
        return
    }
    writeJSON(w, 200, user)
}

// Middleware: wrap an http.Handler
func logMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        slog.Info("request", "method", r.Method, "path", r.URL.Path)
        next.ServeHTTP(w, r)
    })
}

// JSON helper
func writeJSON(w http.ResponseWriter, status int, v any) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(status)
    json.NewEncoder(w).Encode(v)
}

func main() {
    store := NewPostgresStore()
    srv := NewServer(store)
    handler := logMiddleware(recoverMiddleware(srv.router))

    server := &http.Server{
        Addr:         ":8080",
        Handler:      handler,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 30 * time.Second,
        IdleTimeout:  60 * time.Second,
    }
    slog.Info("listening", "addr", ":8080")
    server.ListenAndServe()
}
Go 1.22 added method + path pattern routing to the standard net/http package. For most APIs you no longer need gorilla/mux or chi — the stdlib is sufficient.
10

Database

🗄

database/sql & Connection Pooling

Queries, transactions, prepared statements, pgx

DatabaseSQL
go
import (
    "context"
    "database/sql"
    _ "github.com/lib/pq"   // postgres driver (side-effect import)
)

// Setup connection pool (not a single connection)
func NewDB(dsn string) (*sql.DB, error) {
    db, err := sql.Open("postgres", dsn)
    if err != nil { return nil, err }

    db.SetMaxOpenConns(25)       // max simultaneous connections
    db.SetMaxIdleConns(5)        // keep alive for reuse
    db.SetConnMaxLifetime(5 * time.Minute)
    return db, db.PingContext(context.Background())
}

// Query multiple rows
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
    rows, err := s.db.QueryContext(ctx,
        "SELECT id, name, email FROM users ORDER BY id")
    if err != nil { return nil, err }
    defer rows.Close()

    var users []User
    for rows.Next() {
        var u User
        if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
            return nil, fmt.Errorf("scan: %w", err)
        }
        users = append(users, u)
    }
    return users, rows.Err()  // always check rows.Err()
}

// Transaction with rollback-on-error
func (s *Store) TransferCredits(ctx context.Context, from, to, amount int) error {
    tx, err := s.db.BeginTx(ctx, nil)
    if err != nil { return err }
    defer tx.Rollback()   // no-op if Commit() already called

    if _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET credits = credits - $1 WHERE id = $2", amount, from);
        err != nil { return err }

    if _, err = tx.ExecContext(ctx,
        "UPDATE accounts SET credits = credits + $1 WHERE id = $2", amount, to);
        err != nil { return err }

    return tx.Commit()
}
Always use parameterized queries ($1, $2 in Postgres, ? in MySQL). Never format user input directly into SQL strings — that's an injection vulnerability.
11

Backend Patterns

Context — Timeouts & Cancellation

context.WithTimeout, WithCancel, WithValue

ContextTimeouts

context.Context propagates deadlines, cancellation signals, and request-scoped values down your call chain. Every blocking operation — HTTP calls, DB queries, file I/O — should accept a context.

go
// HTTP handler: derive context from request
func (s *Server) handleRequest(w http.ResponseWriter, r *http.Request) {
    // Add a 5-second timeout to the request context
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()  // always cancel to release resources

    result, err := s.store.Get(ctx, id)
    // If DB takes > 5s, ctx.Done() fires, query is cancelled
}

// Pass context through the entire call chain
// handler → service → repository → SQL query
func (svc *UserService) GetUser(ctx context.Context, id int) (*User, error) {
    return svc.repo.Find(ctx, id)   // ctx flows all the way down
}

// Store request-scoped values (user ID, trace ID)
type contextKey string
const userIDKey contextKey = "userID"

ctx = context.WithValue(ctx, userIDKey, userID)
uid := ctx.Value(userIDKey).(int)   // type assert
Context rules: Always the first parameter. Never store in a struct. Never pass nil (use context.Background() or context.TODO() at the top level). Always cancel.
🏭

Worker Pool — Bounded Concurrency

Process N items with at most K concurrent workers

PatternsConcurrency
go — worker pool
func workerPool(ctx context.Context, jobs []Job, workers int) []Result {
    jobCh := make(chan Job, len(jobs))
    resultCh := make(chan Result, len(jobs))

    // Start fixed number of worker goroutines
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobCh {
                resultCh <- process(ctx, job)
            }
        }()
    }

    // Feed jobs, close when done
    for _, j := range jobs { jobCh <- j }
    close(jobCh)

    // Wait and close results
    go func() { wg.Wait(); close(resultCh) }()

    var results []Result
    for r := range resultCh { results = append(results, r) }
    return results
}

// Semaphore pattern: limit with buffered channel
sem := make(chan struct{}, 10)   // max 10 concurrent
for _, item := range items {
    sem <- struct{}{}   // acquire
    go func(it Item) {
        defer func() { <-sem }()   // release
        process(it)
    }(item)
}

Testing

Table-driven tests, httptest, benchmarks

Testing
go
// Table-driven test — idiomatic Go
func TestAdd(t *testing.T) {
    tests := []struct {
        name     string
        a, b     int
        expected int
    }{
        {"positive", 2, 3, 5},
        {"negative", -1, -2, -3},
        {"zeros", 0, 0, 0},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got := Add(tt.a, tt.b)
            if got != tt.expected {
                t.Errorf("Add(%d, %d) = %d, want %d", tt.a, tt.b, got, tt.expected)
            }
        })
    }
}

// HTTP handler test with httptest
func TestGetUser(t *testing.T) {
    store := &MockStore{}   // implements UserStore interface
    srv := NewServer(store)

    req := httptest.NewRequest("GET", "/users/42", nil)
    w := httptest.NewRecorder()
    srv.router.ServeHTTP(w, req)

    if w.Code != 200 {
        t.Errorf("got status %d, want 200", w.Code)
    }
}

// Benchmark
func BenchmarkProcess(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Process(testData)
    }
}

// Run commands:
// go test ./...           — all tests
// go test -race ./...     — with race detector
// go test -bench=. ./...  — benchmarks
// go test -cover ./...    — coverage
12

Cheat Sheet

ConstructSyntaxNotes
Short varx := 42Inside functions only; type inferred
Package varvar x int = 42Package level; explicit type
Goroutinego func() {…}()Non-blocking; always pass loop vars as params
Channel makech := make(chan int, 10)Buffered; unbuffered = make(chan int)
Send / Recvch <- v / v := <-chBlocks if unbuffered and no partner
Selectselect { case v := <-ch: }Multiplex channels; default = non-blocking
Deferdefer f.Close()LIFO order; runs on return/panic
Error wrapfmt.Errorf("op: %w", err)%w preserves chain for errors.Is/As
Error checkerrors.Is(err, ErrNotFound)Works through wrapped chains
Interfacetype R interface { Read([]byte) }Implicit — any type with right methods qualifies
Pointer recvfunc (s *S) Set(v int)Modifies; avoids copy; use for most methods
Value recvfunc (s S) Get() intRead-only copy; use for small immutable types
Context timeoutctx, cancel := context.WithTimeout(…)Always defer cancel()
Mutexmu.Lock() / defer mu.Unlock()RWMutex for read-heavy workloads
Map safe readv, ok := m["key"]ok = false if missing; never skip the check
Slice appends = append(s, item)May allocate new array; assign result back
Range loopfor i, v := range sliceUse _ to discard index or value
JSON encodejson.NewEncoder(w).Encode(v)Struct tags: json:"field_name,omitempty"
Test racego test -race ./...Run on every PR; catches data races
Profilego tool pprof cpu.profnet/http/pprof for live profiling
go mod initCreates go.modModule path usually matches repo import path
go getAdd / upgrade depsUse @version or @latest; run go mod tidy
internal/Private packagesImportable only from parent tree of module
Go Proverbs
"Don't communicate by sharing memory; share memory by communicating."
"Accept interfaces, return concrete types."
"Errors are values."
"The bigger the interface, the weaker the abstraction."
"A little copying is better than a little dependency."