A structured learning guide for developers coming from Java, C, or Python who want to learn Go. I put this together because when I started learning Go myself, I kept wishing for a resource that mapped Go’s idioms back to languages I already knew. Github Repo of Code Examples

Introduction to Go

Go (also called Golang) was created at Google in 2009 by Robert Griesemer, Rob Pike, and Ken Thompson. It was designed to address the challenges of building large-scale, concurrent software systems while keeping the language simple and productive.

Go’s Philosophy

Go is built on three core principles that distinguish it from most other languages, and the more I worked with Go, the more I appreciated how consistently these principles are applied:

Simplicity over cleverness. Go deliberately omits features found in other languages, no classes, no inheritance, no generics (until 1.18), no exceptions, no operator overloading. The language specification is small enough to hold in your head. This means there’s usually one obvious way to do something, making Go code easy to read and maintain across large teams.

Composition over inheritance. Go has no class hierarchy. Instead of inheriting behaviour from parent classes (as in Java or Python), Go uses struct embedding and interfaces to compose behaviour. Interfaces are satisfied implicitly, a type implements an interface simply by having the right methods, with no implements keyword. This leads to loosely coupled, flexible designs.

Explicit error handling. Go does not have exceptions. Functions that can fail return an error value, and the caller is expected to check it immediately. This makes error paths visible in the code rather than hidden in try/catch blocks. While it can feel verbose at first, it eliminates the surprise of uncaught exceptions propagating through your call stack. Here’s what clicked for me: the verbosity is the point, you always know exactly where errors are handled.

Why Learn Go?

  • Fast compilation and execution (compiles to native machine code)
  • Built-in concurrency with goroutines and channels
  • Simple dependency management with Go modules
  • Excellent standard library (HTTP servers, JSON, crypto, testing, all built in)
  • Widely used for cloud infrastructure, CLI tools, microservices, and DevOps tooling

Go Tooling Basics

Go ships with a powerful set of built-in tools. Unlike Java (which needs Maven/Gradle) or Python (which needs pip/virtualenv), Go’s toolchain handles building, formatting, analysis, and dependency management out of the box. I was genuinely surprised by how much is included when I first started exploring the toolchain.

go run

Compiles and runs a Go source file in one step. Great for quick iteration during development.

go run main.go

This compiles the file to a temporary binary, executes it, and cleans up. It does not produce a permanent binary.

go build

Compiles a Go source file (or package) into a binary executable.

go build -o myapp main.go

The -o flag specifies the output binary name. Without it, the binary is named after the directory or file. The resulting binary is a statically linked executable with no external dependencies, you can copy it to any compatible machine and run it.

go fmt

Automatically formats Go source code to the canonical style. Go has one official formatting style, and go fmt enforces it. This eliminates style debates in code reviews, something I wish every language had.

go fmt ./...

The ./... pattern means “all packages in the current directory and subdirectories.” Run this before committing code.

go vet

Performs static analysis to find common mistakes that the compiler doesn’t catch, things like unreachable code, incorrect format strings, or suspicious constructs.

go vet ./...

Always run go vet alongside go fmt. It catches bugs that compile fine but are almost certainly wrong.

go mod

Manages Go modules, Go’s dependency management system (introduced in Go 1.11, default since Go 1.16).

# Initialize a new module
go mod init my-project

# Download dependencies
go mod download

# Clean up unused dependencies
go mod tidy

The go.mod file tracks your module name and dependencies (similar to package.json in Node.js or requirements.txt in Python). The go.sum file contains checksums for dependency verification.


Core Features

Source file: 01_core_features.go

Run it: go run 01_core_features.go

This covers the fundamental building blocks of Go. If you’re coming from Java, C, or Python, this is where you’ll see how Go handles the basics differently.

Variables and Types

Go supports two forms of variable declaration. The long form uses var with an explicit type, while the short form uses := with type inference:

// Long-form declaration with explicit type (like Java/C)
var age int = 30
var pi float64 = 3.14159
var name string = "Gopher"
var isReady bool = true

// Short-form declaration with type inference (unique to Go)
count := 42
ratio := 2.718
greeting := "Hello, Go!"
active := false

Go initialises variables to their zero value if not explicitly assigned, 0 for numbers, "" for strings, false for booleans. This is similar to Java’s defaults but unlike Python, where you must always assign a value.

var zeroInt int       // 0
var zeroFloat float64 // 0.0
var zeroString string // ""
var zeroBool bool     // false

Collections: Arrays, Slices, and Maps

Go has three main collection types. Arrays are fixed-size and rarely used directly. Slices are dynamic views over arrays and are the workhorse collection. Maps are key-value hash tables.

// Arrays: fixed size, value types
var numbers [3]int = [3]int{10, 20, 30}

// Slices: dynamic size (like Python lists or Java ArrayList)
fruits := []string{"apple", "banana", "cherry"}
fruits = append(fruits, "date")  // grow with append()

// Slicing (like Python's list[1:3])
subSlice := fruits[1:3]

// Maps (like Python dict or Java HashMap)
ages := map[string]int{
    "Alice": 30,
    "Bob":   25,
    "Carol": 35,
}

// Check if a key exists (idiomatic Go pattern)
val, ok := ages["Alice"]  // ok is true if key exists

Control Flow

Go’s control flow has a few key differences from other languages: no parentheses around conditions, braces are always required, there’s only one loop keyword (for), and switch cases don’t fall through by default.

// if/else: no parentheses, braces required
x := 15
if x > 10 {
    fmt.Println("greater than 10")
} else if x > 5 {
    fmt.Println("greater than 5")
}

// if with initialization statement (variable scoped to the block)
if y := x * 2; y > 20 {
    fmt.Println("y is greater than 20")
}

// for loop (the only loop keyword in Go)
for i := 0; i < 5; i++ {
    fmt.Println(i)
}

// for as a while loop
count := 0
for count < 3 {
    count++
}

// for-range over a slice (like Python's enumerate)
colors := []string{"red", "green", "blue"}
for i, color := range colors {
    fmt.Printf("[%d]=%s ", i, color)
}

// switch: no break needed, no fall-through by default
day := "Tuesday"
switch day {
case "Monday":
    fmt.Println("Start of the work week")
case "Tuesday", "Wednesday", "Thursday":
    fmt.Println("Midweek")
default:
    fmt.Println("Weekend")
}

Functions and Multiple Return Values

Go functions can return multiple values, which is used extensively for returning results alongside errors. This replaces exceptions in Java/Python, and once you get used to it, the explicitness is genuinely refreshing.

// Multiple return values
func addAndMultiply(a, b int) (int, int) {
    return a + b, a * b
}

// The (value, error) pattern, idiomatic Go
func safeDivide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Named return values
func divideWithRemainder(a, b int) (quotient int, remainder int) {
    quotient = a / b
    remainder = a % b
    return  // bare return: returns named values
}

Structs and Methods

Structs are Go’s primary custom data type. Unlike Java classes, Go structs have no inheritance. Methods are functions with a receiver argument that binds them to a type.

type Rectangle struct {
    Width  float64
    Height float64
}

// Value receiver (read-only, gets a copy)
func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

// Pointer receiver (can modify the struct)
func (r *Rectangle) Scale(factor float64) {
    r.Width *= factor
    r.Height *= factor
}

Interfaces

Go interfaces are satisfied implicitly, a type implements an interface simply by having the required methods. No implements keyword needed. This is “duck typing at compile time,” and it’s one of Go’s most elegant design decisions.

type Shape interface {
    Area() float64
    Perimeter() float64
}

// Rectangle and Circle both satisfy Shape automatically
shapes := []Shape{
    Rectangle{Width: 4, Height: 5},
    Circle{Radius: 3},
}

// Type assertion (like instanceof in Java)
var s Shape = Circle{Radius: 7}
if c, ok := s.(Circle); ok {
    fmt.Printf("Circle with Radius=%.2f\n", c.Radius)
}

Error Handling

Go has no exceptions. Functions return an error value, and the caller checks it immediately. This makes error handling explicit and visible, no hidden control flow.

// Creating errors
err := errors.New("something went wrong")

// Wrapping errors with context (like exception chaining in Java)
wrapped := fmt.Errorf("operation failed: %w", err)

// Checking error chains
if errors.Is(wrapped, err) {
    fmt.Println("wrapped contains the original error")
}

// Idiomatic error handling pattern
result, err := parsePositiveNumber("42")
if err != nil {
    fmt.Printf("error: %v\n", err)
} else {
    fmt.Printf("result=%d\n", result)
}

Advanced Features

Source file: 02_advanced_features.go

Run it: go run 02_advanced_features.go

This covers Go’s concurrency model, generics, and advanced patterns. The concurrency primitives are what really set Go apart, and they’re worth spending time understanding properly.

Goroutines

Goroutines are Go’s lightweight concurrency primitive. They’re like threads but far cheaper, a goroutine starts with just a few KB of stack, and Go can run millions of them on a handful of OS threads. When I first started experimenting with goroutines, the difference in overhead compared to Java threads was striking.

var wg sync.WaitGroup

for i := 1; i <= 3; i++ {
    wg.Add(1)
    go func(id int) {
        defer wg.Done()
        fmt.Printf("Goroutine %d: starting\n", id)
        time.Sleep(10 * time.Millisecond)
        fmt.Printf("Goroutine %d: done\n", id)
    }(i)  // pass i as argument to avoid closure over loop variable
}

wg.Wait()  // block until all goroutines complete

In Java, you’d use new Thread(() -> ...).start(), but Java threads map 1:1 to OS threads and cost ~1 MB each. In Python, the GIL prevents true parallelism for CPU-bound work. Goroutines achieve real parallelism without these limitations.

Channels

Channels are typed conduits for communication between goroutines. They enforce synchronisation: a send blocks until a receiver is ready (for unbuffered channels).

// Unbuffered channel: send blocks until receive
unbuffered := make(chan string)
go func() {
    unbuffered <- "hello from goroutine"
}()
msg := <-unbuffered

// Buffered channel: send doesn't block until buffer is full
buffered := make(chan int, 3)
buffered <- 10
buffered <- 20
buffered <- 30

// Channel direction in function signatures (compile-time safety)
func producer(out chan<- string) {  // send-only
    out <- "produced value"
}

// Closing channels and ranging
numCh := make(chan int, 5)
go func() {
    for i := 1; i <= 5; i++ {
        numCh <- i * i
    }
    close(numCh)
}()
for val := range numCh {
    fmt.Println(val)
}

Channels are similar to Java’s BlockingQueue or Python’s queue.Queue, but they’re built into the language and work seamlessly with goroutines.

Select

The select statement lets a goroutine wait on multiple channel operations simultaneously. It’s like a switch for channels.

select {
case msg1 := <-ch1:
    fmt.Println("received from ch1:", msg1)
case msg2 := <-ch2:
    fmt.Println("received from ch2:", msg2)
case <-time.After(20 * time.Millisecond):
    fmt.Println("timed out")
default:
    fmt.Println("no channel ready")
}

Sync Primitives: WaitGroup and Mutex

While channels are Go’s preferred communication mechanism, sometimes you need traditional synchronisation. sync.WaitGroup waits for goroutines to finish, and sync.Mutex protects shared data.

// WaitGroup: wait for goroutines to finish
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
    wg.Add(1)
    go func(idx int) {
        defer wg.Done()
        results[idx] = fmt.Sprintf("worker-%d", idx)
    }(i)
}
wg.Wait()

// Mutex: protect shared state
var mu sync.Mutex
counter := 0
mu.Lock()
counter++
mu.Unlock()

WaitGroup is like Java’s CountDownLatch. Mutex is like synchronized blocks in Java or threading.Lock in Python.

Generics

Go added generics (type parameters) in Go 1.18. They let you write functions and types that work with any type satisfying a constraint. The implementation is clean and fits Go’s philosophy of simplicity.

// Type constraint interface
type Ordered interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 |
        ~float32 | ~float64 |
        ~string
}

// Generic function
func maxValue[T Ordered](a, b T) T {
    if a > b {
        return a
    }
    return b
}

// Generic data structure
type Stack[T any] struct {
    items []T
}

func (s *Stack[T]) Push(val T) {
    s.items = append(s.items, val)
}

func (s *Stack[T]) Pop() (T, bool) {
    if len(s.items) == 0 {
        var zero T
        return zero, false
    }
    val := s.items[len(s.items)-1]
    s.items = s.items[:len(s.items)-1]
    return val, true
}

Go uses square brackets [T constraint] for type parameters, unlike Java’s angle brackets <T>. Go generics are resolved at compile time (monomorphisation), so there’s no runtime overhead.

Closures

A closure is a function that captures variables from its enclosing scope. Unlike Java lambdas, Go closures can modify captured variables.

// Closure that captures and modifies a variable
count := 0
counter := func() int {
    count++
    return count
}
fmt.Println(counter(), counter(), counter())  // 1, 2, 3

// Function factory using closures
func makeMultiplier(factor int) func(int) int {
    return func(x int) int {
        return x * factor
    }
}
double := makeMultiplier(2)
triple := makeMultiplier(3)

Defer, Panic, and Recover

defer schedules a function to run when the enclosing function returns (like finally in Java). panic stops normal execution, and recover catches panics in deferred functions.

// defer runs in LIFO order when the function returns
defer fmt.Println("runs last")
defer fmt.Println("runs second")
defer fmt.Println("runs first")

// panic and recover (Go's equivalent of try/catch)
func safeDivideInt(a, b int) (result int) {
    defer func() {
        if r := recover(); r != nil {
            fmt.Printf("recovered from panic: %v\n", r)
            result = 0
        }
    }()
    return a / b  // panics if b == 0
}

Go discourages using panic/recover for normal error handling, use error returns instead. Reserve panic for truly unrecoverable situations.

Context Package

The context package manages cancellation, deadlines, and request-scoped values across goroutines. It’s one of those patterns that seems like overhead at first but becomes indispensable once you’re building real services.

// Cancellable context
ctx, cancel := context.WithCancel(context.Background())
go func() {
    select {
    case <-ctx.Done():
        fmt.Println("cancelled:", ctx.Err())
    }
}()
cancel()

// Context with timeout
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()

// Context with values (for request-scoped data like request IDs)
type contextKey string
const requestIDKey contextKey = "requestID"
ctx := context.WithValue(context.Background(), requestIDKey, "req-12345")

Anti-Patterns

Source file: 03_antipatterns.go

Run it: go run 03_antipatterns.go

These are common mistakes I’ve seen (and made) when coming to Go from other languages, paired with the idiomatic Go alternative.

Anti-Pattern 1: Overuse of interface{}

Developers from Python or JavaScript sometimes use interface{} (Go’s empty interface) as a catch-all type, mimicking dynamic typing. This loses compile-time type safety, and it’s a pattern worth breaking early.

// BAD: Using interface{}, requires runtime type assertions, can panic
func sumWithInterfaceBad(a, b interface{}) interface{} {
    switch a := a.(type) {
    case int:
        return a + b.(int)
    case float64:
        return a + b.(float64)
    default:
        return 0
    }
}

// GOOD: Using generics, compile-time type safety, no assertions needed
type Numeric interface {
    ~int | ~int8 | ~int16 | ~int32 | ~int64 |
        ~float32 | ~float64
}

func sumGeneric[T Numeric](a, b T) T {
    return a + b
}

Anti-Pattern 2: Ignoring Errors

In Go, errors are values returned from functions. Ignoring them with _ is like writing except: pass in Python or ignoring return codes in C. I’ve run into real bugs caused by this in code reviews, it’s always worth checking.

// BAD: Ignoring the error, val is 0 and we'll never know why
val, _ := strconv.Atoi("not_a_number")

// GOOD: Always check errors immediately
val, err := strconv.Atoi("not_a_number")
if err != nil {
    fmt.Printf("error: %v\n", err)
}

Anti-Pattern 3: Unnecessary Pointers

C developers often reach for pointers by default. In Go, small structs are efficiently passed by value, and unnecessary pointers add nil-check complexity.

// BAD: Pointer for a small, read-only struct, adds nil risk for no benefit
func distanceFromOriginBad(p *Point) float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

// GOOD: Pass by value, simpler, safer, no nil risk
func distanceFromOriginGood(p Point) float64 {
    return math.Sqrt(p.X*p.X + p.Y*p.Y)
}

Use pointers when you need to mutate the original or when the struct is large. The anti-pattern is using them by default.

Anti-Pattern 4: Java-Style Getters/Setters

Java requires getter/setter methods because fields are typically private. In Go, exported fields (capitalised names) are the idiomatic way to expose struct data.

// BAD: Java-style boilerplate
type UserBad struct {
    name  string
    email string
}
func (u *UserBad) GetName() string  { return u.name }
func (u *UserBad) SetName(n string) { u.name = n }

// GOOD: Exported fields, direct, readable, idiomatic Go
type UserGood struct {
    Name  string
    Email string
}

Add methods only when you need validation, computed values, or to satisfy an interface.

Anti-Pattern 5: Channel Misuse Where a Mutex Suffices

Channels are for communication between goroutines. For simple shared state protection, sync.Mutex is simpler and more efficient. The Go proverb “share memory by communicating” doesn’t mean “use channels for everything.”

// BAD: Using a channel as a mutex, overly complex
type channelCounter struct {
    value int
    sem   chan struct{}
}
func (c *channelCounter) incrementBad() {
    c.sem <- struct{}{}  // acquire "lock"
    c.value++
    <-c.sem              // release "lock"
}

// GOOD: Using sync.Mutex, clear, simple, purpose-built
type mutexCounter struct {
    mu    sync.Mutex
    value int
}
func (c *mutexCounter) increment() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.value++
}

Anti-Pattern 6: Complex init() Functions

Go’s init() runs automatically before main(). Overloading it with complex setup (like Java’s static initialiser blocks) makes code hard to test and debug.

// BAD: Complex initialization hidden in init()
func complexInitSetup() map[string]interface{} {
    config := make(map[string]interface{})
    config["host"] = "localhost"
    config["port"] = 8080
    // Opens DB connections, reads files... all hidden from caller
    return config
}

// GOOD: Simple package-level vars with explicit setup
var (
    defaultHost = "localhost"
    defaultPort = 8080
    debugMode   = false
)

// GOOD: Explicit constructor function, testable and clear
func NewConfig(host string, port int, debug bool) Config {
    return Config{Host: host, Port: port, Debug: debug}
}

Cross-Language Comparison

The following table summarises key differences between Go and Java, C, and Python:

Feature Go Java C Python
Typing Static, inferred (:=) Static, explicit Static, explicit Dynamic
Compilation Compiled to native binary Compiled to bytecode (JVM) Compiled to native binary Interpreted
Concurrency Goroutines + channels Threads + ExecutorService pthreads threading/asyncio (GIL limits parallelism)
Error handling Return values (error type) Exceptions (try/catch) Return codes / errno Exceptions (try/except)
OOP model Structs + interfaces (composition) Classes + inheritance Structs + function pointers Classes + inheritance
Interfaces Implicit (structural typing) Explicit (implements) N/A (function pointers) Duck typing (runtime)
Generics Type parameters [T] (Go 1.18+) Type erasure <T> Macros / void* Duck typing (type hints optional)
Memory management Garbage collected Garbage collected Manual (malloc/free) Garbage collected
Package management Go modules (go mod) Maven / Gradle Manual / CMake pip / poetry
Formatting go fmt (one canonical style) Checkstyle / IDE settings clang-format Black / autopep8
Null safety Zero values (no null for value types) null references NULL pointers None
Closures Full closure support (mutable captures) Lambdas (effectively final captures) No closures Full closure support
Defer/cleanup defer keyword try/finally Manual cleanup try/finally, context managers

Build and Run Instructions

Prerequisites

Minimum Go version: 1.21

Go 1.21 or later is required to compile all source files in this project. Generics (used in 01_core_features.go and 02_advanced_features.go) were introduced in Go 1.18, and Go 1.21 provides mature standard library support.

Installing Go on Linux

Download and install Go using the official tarball method:

# Download the Go tarball (replace version as needed)
wget https://go.dev/dl/go1.21.0.linux-amd64.tar.gz

# Remove any previous Go installation and extract
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.21.0.linux-amd64.tar.gz

# Add Go to your PATH (add this to ~/.bashrc or ~/.profile for persistence)
export PATH=$PATH:/usr/local/go/bin

# Verify the installation
go version

You should see output like go version go1.21.0 linux/amd64.

Initialising the Go Module

If you’re starting from scratch (the go.mod file is already included in this project):

go mod init go-learning-guide

This creates a go.mod file that tracks the module name and Go version. It’s similar to package.json in Node.js or requirements.txt in Python.

Running and Building Each File

Each Go source file is a standalone program with its own main function. Run or build them individually:

Core Features (01_core_features.go):

go run 01_core_features.go
go build -o core 01_core_features.go
./core

Advanced Features (02_advanced_features.go):

go run 02_advanced_features.go
go build -o advanced 02_advanced_features.go
./advanced

Anti-Patterns (03_antipatterns.go):

go run 03_antipatterns.go
go build -o antipatterns 03_antipatterns.go
./antipatterns

Formatting and Checking Your Code

Before committing or sharing code, always format and vet:

go fmt ./...
go vet ./...

Troubleshooting

Missing Go Installation

If you see an error like:

bash: go: command not found

or:

'go' is not recognized as an internal or external command

This means Go is not installed or not in your PATH. Follow these steps:

  1. Check if Go is installed by looking for the Go directory:

    ls /usr/local/go/bin/go
    
  2. If Go is not installed, follow the Installing Go on Linux section above.

  3. If Go is installed but not in PATH, add it:

    export PATH=$PATH:/usr/local/go/bin
    

    Add this line to your ~/.bashrc or ~/.profile to make it permanent:

    echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
    source ~/.bashrc
    
  4. Verify the installation:

    go version
    

Module Not Initialised

If you see:

go: cannot find main module

Run go mod init go-learning-guide in the project directory. See the Initialising the Go Module section.

Compilation Errors

If a file fails to compile:

  1. Run go fmt to fix formatting issues
  2. Run go vet to identify common mistakes
  3. Check that your Go version is 1.21 or later with go version