A Condensed Tutorial for Experienced Developers

// you already know how to code — here's what's different

The Go Philosophy

Go was designed at Google in 2007 by Griesemer, Pike, and Thompson — people who were frustrated with C++ compile times and Java complexity. It compiles to native binaries, has a garbage collector, and deliberately has fewer features than you expect.

Core ethos: Simplicity over cleverness. Explicit over implicit. One way to do things. If you're fighting the language, you're probably holding it wrong.

FeatureGo's stance
GenericsAdded in 1.18 — minimal, not like C++ templates
InheritanceDoesn't exist. Use composition + interfaces.
ExceptionsDoesn't exist. Errors are values, returned explicitly.
Operator overloadingDoesn't exist. Intentionally.
Ternary operatorDoesn't exist. Write the if.
Macros / preprocessorDoesn't exist.
Function overloadingDoesn't exist.
ClassesDoesn't exist. Structs + methods instead.
ConcurrencyFirst-class via goroutines + channels (CSP model)
GCYes — low-latency, concurrent mark-and-sweep
Compile speedExtremely fast — seconds even for large projects
BinariesSingle static binary, no runtime deps to ship

Basic Syntax — Fast

Go is statically typed, compiled, and uses C-family syntax — you'll feel at home immediately, but a few things will surprise you.

package main    // every file declares a package; "main" is the entry package

import (
    "fmt"
    "math"
)

func main() {
    // := is declare+assign; type is inferred
    x := 42
    name := "gopher"
    pi := math.Pi

    // explicit type declaration
    var count int = 0

    fmt.Println(x, name, pi, count)

    // for is the ONLY loop — no while, no do-while
    for i := 0; i < 5; i++ {
        fmt.Println(i)
    }

    // "while" in Go
    n := 10
    for n > 0 { n-- }

    // infinite loop
    for {
        break
    }
}

Unused variables and unused imports are compile errors, not warnings. Go enforces this to keep code clean. The blank identifier _ discards values you don't need.

Multiple Return Values

This is Go's answer to out-parameters, tuples, and exceptions — functions return multiple values directly:

func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, fmt.Errorf("division by zero")
    }
    return a / b, nil
}

result, err := divide(10, 3)
if err != nil {
log.Fatal(err)
}

defer — cleanup the C++ destructor way

func readFile(path string) {
    f, err := os.Open(path)
    if err != nil { log.Fatal(err) }
    defer f.Close()   // runs when function returns, regardless of how

```
// ... do stuff with f
```

}
// defers stack LIFO — useful for multiple cleanups

Types, Structs & Composition

No classes. Structs carry data; methods are defined separately and attached to a type — any type.

type Point struct {
    X, Y float64
}

// method on Point — note the receiver (p Point)
func (p Point) Distance() float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

// pointer receiver — to mutate the struct
func (p *Point) Scale(factor float64) {
    p.X *= factor
    p.Y *= factor
}

// Composition — embed structs (not inheritance!)
type Circle struct {
    Point          // embedded — Circle "promotes" Point's fields + methods
    Radius float64
}

c := Circle{Point{3, 4}, 5}
fmt.Println(c.Distance())   // promoted from Point
fmt.Println(c.X)            // promoted field

Slices — not arrays

Arrays are fixed-size and rarely used directly. Slices are the go-to sequence type — a view over an underlying array.

// slice literal
nums := []int{1, 2, 3}
nums = append(nums, 4, 5)

// make with length + capacity (like ArrayList preallocate)
buf := make([]byte, 0, 1024)

// range loop — like Python enumerate / Java for-each
for i, v := range nums {
    fmt.Printf("%d: %d\n", i, v)
}

// slicing (half-open, like Python)
sub := nums[1:3]   // [2 3] — shares the same backing array

Slices are reference types. A sub-slice shares memory with its parent. Modifying one affects the other. Use copy() when you need independence.

Maps

scores := make(map[string]int)
scores["alice"] = 99

// check existence — the two-value form
val, ok := scores[“bob”]
if !ok { fmt.Println(“not found”) }

// delete
delete(scores, “alice”)

Pointers — familiar but safer

x := 42
p := &x         // *int pointer
*p = 100        // dereference
// No pointer arithmetic. No void*. GC manages lifetime.
// new() allocates zero-value heap memory, returns pointer
pp := new(int)   // *int pointing to 0

Functions as First-Class Values

Functions are values — pass them, return them, store them. Closures capture by reference.

// function type
type Transformer func(int) int

func apply(nums []int, fn Transformer) []int {
    result := make([]int, len(nums))
    for i, v := range nums {
        result[i] = fn(v)
    }
    return result
}

doubled := apply([]int{1,2,3}, func(x int) int { return x * 2 })

// closure — counter factory
func makeCounter() func() int {
    n := 0
    return func() int {
        n++
        return n
    }
}
next := makeCounter()
next() // 1
next() // 2

Variadic functions

func sum(nums ...int) int {
    total := 0
    for _, n := range nums { total += n }
    return total
}
sum(1, 2, 3)
s := []int{1, 2, 3}
sum(s...)   // spread a slice into variadic — like JS spread

Goroutines — Lightweight Threads

This is Go's killer feature. Goroutines are multiplexed over OS threads by the Go runtime — you can spin up millions of them. They're not OS threads, not green threads — they're M:N scheduled coroutines.

func work(id int) {
    fmt.Printf("worker %d done\n", id)
}

// launch a goroutine — just add "go"
go work(1)
go work(2)

// goroutine with closure
go func() {
    fmt.Println("anonymous goroutine")
}()

// sync with WaitGroup (like CountDownLatch in Java)
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        work(id)
    }(i)   // pass i as arg — avoid closure-over-loop-variable bug
}
wg.Wait()

Classic bug: closing over a loop variable in a goroutine. By the time the goroutine runs, the loop may have advanced. Always pass i as an argument to the goroutine function.

sync.Mutex for shared state

type SafeMap struct {
    mu sync.Mutex
    m  map[string]int
}

func (s *SafeMap) Set(key string, val int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = val
}

Channels — Communicate, Don't Share

Go's mantra: "Don't communicate by sharing memory — share memory by communicating." Channels are typed conduits between goroutines.

// unbuffered channel — synchronous rendezvous
ch := make(chan int)

go func() { ch <- 42 }()   // send blocks until receiver is ready
val := <-ch               // receive

// buffered — send doesn't block until buffer full
bch := make(chan string, 10)

// close signals "no more sends"
close(ch)
// range over channel — drains until closed
for v := range ch { fmt.Println(v) }

// select — multiplex channels (like epoll, switch on channels)
select {
case msg := <-ch1:
    fmt.Println("from ch1:", msg)
case msg := <-ch2:
    fmt.Println("from ch2:", msg)
case <-time.After(1 * time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("non-blocking: nothing ready")
}

Worker pool pattern

jobs := make(chan int, 100)
results := make(chan int, 100)

for w := 0; w < 3; w++ {   // 3 workers
go func() {
for j := range jobs {
results <- j * j
}
}()
}
for j := 1; j <= 9; j++ { jobs <- j }
close(jobs)
for r := 0; r < 9; r++ { fmt.Println(<-results) }

Interfaces — Implicit, Structural

No implements keyword. If your type has the methods, it satisfies the interface. This is structural typing — like TypeScript's duck typing, but at compile time.

type Stringer interface {
    String() string
}

type Person struct{ Name string }

// Person now implicitly satisfies Stringer
func (p Person) String() string {
    return "Person: " + p.Name
}

func printIt(s Stringer) {
    fmt.Println(s.String())
}

printIt(Person{"Alice"})   // works — no declaration needed

The empty interface / any

// interface{} (or "any" since Go 1.18) accepts any value
func printAny(v any) {
    fmt.Printf("%T: %v\n", v, v)   // %T prints the type
}

// type assertion
var i any = “hello”
s, ok := i.(string)   // safe assertion

// type switch
switch v := i.(type) {
case string:  fmt.Println(“string:”, v)
case int:     fmt.Println(“int:”, v)
default:      fmt.Println(“unknown”)
}
💡

Key stdlib interfaces to know: io.Reader, io.Writer, error, fmt.Stringer, sort.Interface. Composing these is how you write idiomatic Go.

Error Handling — Explicit, Always

No exceptions. Errors are values of type error — an interface with one method: Error() string. You handle them or ignore them explicitly.

// creating errors
import "errors"

var ErrNotFound = errors.New("not found")   // sentinel error

// custom error type
type ValidationError struct {
    Field   string
    Message string
}
func (e *ValidationError) Error() string {
    return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

// wrapping errors (Go 1.13+) — like cause-chaining in Java
func fetchUser(id int) (User, error) {
    u, err := db.Find(id)
    if err != nil {
        return User{}, fmt.Errorf("fetchUser %d: %w", id, err)  // %w wraps
    }
    return u, nil
}

// unwrapping
errors.Is(err, ErrNotFound)       // checks chain
errors.As(err, &valErr)           // extracts type from chain

// panic/recover — only for truly unexpected situations (like assert)
defer func() {
    if r := recover(); r != nil {
        fmt.Println("recovered:", r)
    }
}()

The repetitive if err != nil is intentional. Go treats error handling as a first-class concern, not an afterthought. Proposals to improve this syntax exist but nothing has landed yet.

Packages, Modules & Tooling

Go modules replaced GOPATH in Go 1.11. A module is a collection of packages, versioned together.

# create a new module
go mod init github.com/you/myapp

# add dependency (updates go.mod + go.sum)
go get github.com/some/pkg@v1.2.3

# tidy — remove unused deps
go mod tidy

# build + run
go build .
go run main.go

# test (files ending in _test.go)
go test ./...
go test -race ./...    # data race detector — use this!

# format (no debates — one canonical format)
go fmt ./...
gofmt -w .

# static analysis
go vet ./...

Visibility — Capital letter = exported

package mylib

type PublicStruct struct {   // exported — visible outside package
PublicField  string       // exported
privateField string       // unexported — package-private
}

func helperFunc() {}         // unexported
func PublicFunc() {}          // exported

Gotchas for Veterans

GotchaExplanation
nil interface ≠ nil pointer An interface holding a typed nil pointer is NOT nil. Classic source of bugs when returning concrete error types.
Slice shares backing array s2 := s1[1:3] shares memory. Appending to s2 beyond cap will reallocate, but mutations within cap affect s1.
map is not safe for concurrent writes Use sync.Mutex or sync.Map. The race detector will catch misuse.
String is bytes, not runes Strings are UTF-8 byte slices. len(s) = bytes. Range over string iterates runes. Use []rune(s) for Unicode-safe ops.
Goroutine leak Goroutines waiting on a channel that nobody closes will live forever. Use context.Context for cancellation.
Zero values are useful Every type has a zero value (0, "", false, nil). var mu sync.Mutex is valid and ready to use — no constructor needed.
Named return values func f() (n int, err error) — named returns initialize to zero. Bare return returns them. Useful but use sparingly.
init() runs automatically Each package can have multiple init() functions. They run after all variable initializations, before main.

Key Idioms & Patterns

Context for cancellation / deadlines

func doWork(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()  // cancelled or timed out
        default:
            // do one unit of work
        }
    }
}

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()   // always call cancel to release resources
doWork(ctx)

Functional options pattern (config without constructors)

type Server struct{ port int; timeout time.Duration }
type Option func(*Server)

func WithPort(p int) Option    { return func(s *Server) { s.port = p } }
func NewServer(opts …Option) *Server {
s := &Server{port: 8080}
for _, o := range opts { o(s) }
return s
}
srv := NewServer(WithPort(9090))

Table-driven tests

func TestAdd(t *testing.T) {
    tests := []struct{ a, b, want int }{
        {1, 2, 3},
        {0, 0, 0},
        {-1, 1, 0},
    }
    for _, tc := range tests {
        if got := Add(tc.a, tc.b); got != tc.want {
            t.Errorf("Add(%d,%d) = %d, want %d", tc.a, tc.b, got, tc.want)
        }
    }
}

Generics (Go 1.18+)

// type constraint
type Number interface {
    ~int | ~float64
}

func Sum[T Number](nums []T) T {
var total T
for _, n := range nums { total += n }
return total
}

Sum([]int{1, 2, 3})           // type inferred
Sum[float64]([]float64{1.5})  // explicit
// ~int means “int or any type with underlying type int”
📦

Essential stdlib packages to know: fmt, os, io, bufio, strings, strconv, time, sync, context, net/http, encoding/json, testing, log/slog (1.21+)

HTTP server — 5 lines

http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintf(w, "Hello, %s", r.URL.Query().Get("name"))
})
http.ListenAndServe(":8080", nil)  // nil = DefaultServeMux