From Python to Go

How six years of Python expertise gave me the confidence to master any language—and why Go became my next adventure.


Introduction: The Comfort Zone Paradox

For over six years, Python was my world. Django, FastAPI, asyncio, microservices—you name it, I built it. I thought I had found “my language.” The syntax felt natural, the ecosystem was vast, and honestly, I never seriously considered switching.

Then life happened. Layoffs hit my company, and suddenly I was back in the job market. As I scrolled through listings, something interesting caught my eye: Go positions. Lots of them. Well-paying ones. At companies building the infrastructure I admired.

Here’s the thing about being a senior engineer for 6+ years: you realize that programming languages are just tools. The fundamentals—data structures, algorithms, system design, clean architecture—those transcend any syntax. So I made a decision that initially terrified me:

I would learn Go. Not just dabble—truly master it for technical interviews.

This article is everything I learned, organized in a way I wish someone had given me when I started. Whether you’re a Python developer curious about Go, preparing for interviews, or just want to understand what makes Go special, this is for you.


Why Go? Understanding the Landscape

Before diving into syntax, let’s understand why Go exists and where it shines.

The Origin Story

Go was created at Google in 2007 by Robert Griesemer, Rob Pike, and Ken Thompson (yes, the Ken Thompson who co-created Unix and C). They were frustrated with the existing choices:

  • C/C++: Powerful but slow compilation, complex dependency management
  • Java: Verbose, heavy runtime
  • Python/Ruby: Great for development speed, but runtime performance limitations

They wanted something that combined:
- Fast compilation (like interpreted languages)
- Fast execution (like C)
- Easy concurrency (better than anything available)
- Simple, readable syntax

combined

Where Go Dominates

Go has become the de facto language for:

Domain Notable Projects
Cloud Infrastructure Docker, Kubernetes, Terraform
Networking Cloudflare’s edge servers, Caddy
DevOps Tools Prometheus, Grafana agents, Helm
Distributed Systems CockroachDB, etcd, Consul
API Development High-performance microservices

The Mental Model Shift: Python vs Go

The biggest challenge wasn’t syntax—it was rewiring my brain. Here’s the paradigm shift:

Philosophy Comparison

The “Pythonic” vs “Go Way” Examples

Python: Flexibility is King

# Multiple valid approaches to the same problem
numbers = [1, 2, 3, 4, 5]

# List comprehension
squares = [x**2 for x in numbers]

# Map with lambda
squares = list(map(lambda x: x**2, numbers))

# Generator expression
squares = (x**2 for x in numbers)

# Traditional loop
squares = []
for x in numbers:
    squares.append(x**2)

Go: One Clear Path

// There's essentially one way to do this
numbers := []int{1, 2, 3, 4, 5}
squares := make([]int, len(numbers))

for i, x := range numbers {
    squares[i] = x * x
}

At first, this felt limiting. Coming from Python’s expressiveness, Go seemed… boring? But here’s what I learned: in large codebases with multiple contributors, boring is beautiful. Every Go developer writes similar code, making it incredibly easy to read and maintain.


Go Fundamentals: The Complete Guide

1. Variables and Types

Go is statically typed, but with type inference that makes it feel almost dynamic.

package main

import "fmt"

func main() {
    // Explicit type declaration
    var name string = "Lucas"
    var age int = 28

    // Type inference (the Go way)
    city := "Brasília"          // string inferred
    experience := 6.5           // float64 inferred
    isRemote := true            // bool inferred

    // Multiple declarations
    var (
        language = "Go"
        version  = 1.21
    )

    // Zero values (no null/None surprises!)
    var uninitializedInt int       // 0
    var uninitializedString string // "" (empty string)
    var uninitializedBool bool     // false

    fmt.Printf("Hello, I'm %s from %s\n", name, city)
}

Key Insight for Python Devs: Go has no None. Every type has a “zero value.” This eliminates an entire class of NoneType errors!

2. The Type System: A Visual Guide

types

3. Collections: Arrays, Slices, and Maps

This is where Python developers need to pay attention. Go’s collection types have subtle but important differences.

Arrays vs Slices

package main

import "fmt"

func main() {
    // Arrays: Fixed size, value type (copied when passed)
    var arr [5]int = [5]int{1, 2, 3, 4, 5}

    // Slices: Dynamic, reference type (points to underlying array)
    slice := []int{1, 2, 3, 4, 5}

    // Creating slices
    fromArray := arr[1:4]           // [2, 3, 4] - slice from array
    withMake := make([]int, 5, 10)  // length 5, capacity 10

    // Slice operations
    slice = append(slice, 6, 7, 8)  // Append elements

    // GOTCHA: Slices share underlying arrays!
    original := []int{1, 2, 3, 4, 5}
    subset := original[1:3]  // [2, 3]
    subset[0] = 999          // This modifies original too!
    fmt.Println(original)    // [1, 999, 3, 4, 5]

    // Safe copy
    safeCopy := make([]int, len(original))
    copy(safeCopy, original)
}

Memory Layout Visualization:

┌─────────────────────────────────────────────────────┐
                    SLICE INTERNALS                   
├─────────────────────────────────────────────────────┤
                                                      
   slice := []int{1, 2, 3, 4, 5}                     
                                                      
   ┌─────────────┐                                   
      Slice                                        
   ├─────────────┤      ┌───┬───┬───┬───┬───┐       
    ptr     ────┼─────►│ 1  2  3  4  5        
    len = 5           └───┴───┴───┴───┴───┘       
    cap = 5           Underlying Array             
   └─────────────┘                                   
                                                      
   When you do: subset := slice[1:3]                 
                                                      
   ┌─────────────┐                                   
      subset                                       
   ├─────────────┤      ┌───┬───┬───┬───┬───┐       
    ptr     ────┼─────────►│ 2  3  4  5        
    len = 2           └───┴───┴───┴───┴───┘       
    cap = 4           Same Underlying Array!       
   └─────────────┘                                   
                                                      
└─────────────────────────────────────────────────────┘

Maps (Go’s Dictionary)

package main

import "fmt"

func main() {
    // Creating maps
    ages := map[string]int{
        "Alice": 30,
        "Bob":   25,
    }

    // Alternative with make
    scores := make(map[string]float64)
    scores["math"] = 95.5

    // Accessing values - THE COMMA-OK IDIOM
    age, exists := ages["Charlie"]
    if exists {
        fmt.Println("Charlie's age:", age)
    } else {
        fmt.Println("Charlie not found")
    }

    // Delete
    delete(ages, "Bob")

    // Iteration (order is NOT guaranteed!)
    for name, age := range ages {
        fmt.Printf("%s is %d years old\n", name, age)
    }
}

Python Equivalent Mental Map:

Python Go
list []T (slice)
tuple No direct equivalent (use structs or arrays)
dict map[K]V
set map[T]struct{} (idiom)
list.append(x) slice = append(slice, x)
x in dict _, ok := dict[x]

4. Functions: Where Go Gets Interesting

Multiple Return Values

This was one of my favorite discoveries. No more tuples or custom classes!

package main

import (
    "errors"
    "fmt"
)

// Multiple return values - GAME CHANGER
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// Named return values (useful for documentation)
func calculateStats(numbers []int) (min, max, sum int) {
    if len(numbers) == 0 {
        return // Returns zero values
    }

    min, max = numbers[0], numbers[0]
    for _, n := range numbers {
        if n < min {
            min = n
        }
        if n > max {
            max = n
        }
        sum += n
    }
    return // Named returns automatically returned
}

func main() {
    result, err := divide(10, 2)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)

    min, max, sum := calculateStats([]int{3, 1, 4, 1, 5, 9, 2, 6})
    fmt.Printf("Min: %d, Max: %d, Sum: %d\n", min, max, sum)
}

First-Class Functions

Just like Python, functions are first-class citizens:

package main

import "fmt"

// Function as parameter
func apply(numbers []int, transform func(int) int) []int {
    result := make([]int, len(numbers))
    for i, n := range numbers {
        result[i] = transform(n)
    }
    return result
}

// Closures
func counter() func() int {
    count := 0
    return func() int {
        count++
        return count
    }
}

func main() {
    numbers := []int{1, 2, 3, 4, 5}

    // Anonymous function (lambda equivalent)
    doubled := apply(numbers, func(x int) int {
        return x * 2
    })
    fmt.Println(doubled) // [2, 4, 6, 8, 10]

    // Using closure
    myCounter := counter()
    fmt.Println(myCounter()) // 1
    fmt.Println(myCounter()) // 2
    fmt.Println(myCounter()) // 3
}

Defer: The finally You Always Wanted

package main

import (
    "fmt"
    "os"
)

func readFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return err
    }
    defer file.Close() // ALWAYS executed when function returns

    // Even if panic occurs, defer runs
    // Multiple defers execute in LIFO order

    // ... process file ...
    return nil
}

func demonstrateDefer() {
    defer fmt.Println("1 - First defer (runs last)")
    defer fmt.Println("2 - Second defer")
    defer fmt.Println("3 - Third defer (runs first)")
    fmt.Println("Function body")
}

// Output:
// Function body
// 3 - Third defer (runs first)
// 2 - Second defer
// 1 - First defer (runs last)

5. Structs and Methods: Go’s Take on OOP

Go doesn’t have classes, but structs with methods provide everything you need.

package main

import (
    "fmt"
    "time"
)

// Struct definition
type User struct {
    ID        int
    Username  string
    Email     string
    CreatedAt time.Time
    IsActive  bool
}

// Method with value receiver (doesn't modify original)
func (u User) DisplayName() string {
    return fmt.Sprintf("%s (ID: %d)", u.Username, u.ID)
}

// Method with pointer receiver (can modify original)
func (u *User) Deactivate() {
    u.IsActive = false
}

// Constructor pattern (Go convention)
func NewUser(username, email string) *User {
    return &User{
        ID:        generateID(),
        Username:  username,
        Email:     email,
        CreatedAt: time.Now(),
        IsActive:  true,
    }
}

func generateID() int {
    // Simplified for example
    return int(time.Now().UnixNano() % 100000)
}

func main() {
    user := NewUser("lucas", "lucas@example.com")
    fmt.Println(user.DisplayName())

    user.Deactivate()
    fmt.Printf("Active: %v\n", user.IsActive)
}

Embedding: Composition Over Inheritance

package main

import "fmt"

type Animal struct {
    Name string
    Age  int
}

func (a Animal) Speak() string {
    return "..."
}

// Dog "inherits" from Animal through embedding
type Dog struct {
    Animal  // Embedded struct
    Breed   string
}

// Override the Speak method
func (d Dog) Speak() string {
    return "Woof!"
}

func main() {
    dog := Dog{
        Animal: Animal{Name: "Rex", Age: 3},
        Breed:  "German Shepherd",
    }

    // Access embedded fields directly
    fmt.Println(dog.Name)    // "Rex" - promoted from Animal
    fmt.Println(dog.Speak()) // "Woof!" - Dog's method
}


6. Interfaces: Go’s Secret Weapon

This is where Go truly shines. Interfaces are implicit—no implements keyword needed!

package main

import (
    "fmt"
    "math"
)

// Interface definition
type Shape interface {
    Area() float64
    Perimeter() float64
}

// Rectangle implements Shape (implicitly!)
type Rectangle struct {
    Width, Height float64
}

func (r Rectangle) Area() float64 {
    return r.Width * r.Height
}

func (r Rectangle) Perimeter() float64 {
    return 2 * (r.Width + r.Height)
}

// Circle also implements Shape
type Circle struct {
    Radius float64
}

func (c Circle) Area() float64 {
    return math.Pi * c.Radius * c.Radius
}

func (c Circle) Perimeter() float64 {
    return 2 * math.Pi * c.Radius
}

// Function accepting interface
func PrintShapeInfo(s Shape) {
    fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}

func main() {
    rect := Rectangle{Width: 10, Height: 5}
    circle := Circle{Radius: 7}

    PrintShapeInfo(rect)   // Works!
    PrintShapeInfo(circle) // Also works!

    // Slice of interfaces
    shapes := []Shape{rect, circle}
    for _, shape := range shapes {
        PrintShapeInfo(shape)
    }
}

The Empty Interface and Type Assertions

package main

import "fmt"

// interface{} or 'any' accepts anything (like Python's object)
func describe(i interface{}) {
    fmt.Printf("Value: %v, Type: %T\n", i, i)
}

func processValue(i interface{}) {
    // Type assertion
    if str, ok := i.(string); ok {
        fmt.Println("It's a string:", str)
        return
    }

    // Type switch
    switch v := i.(type) {
    case int:
        fmt.Println("Integer:", v*2)
    case string:
        fmt.Println("String:", v)
    case []int:
        fmt.Println("Int slice with length:", len(v))
    default:
        fmt.Println("Unknown type")
    }
}

func main() {
    describe(42)
    describe("hello")
    describe([]int{1, 2, 3})

    processValue(42)
    processValue("hello")
}

Common Interfaces You’ll Use

// Stringer - like Python's __str__
type Stringer interface {
    String() string
}

// Reader/Writer - foundation of Go's I/O
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

// Error - yes, errors are an interface!
type error interface {
    Error() string
}

7. Concurrency: Go’s Crown Jewel 👑

This is why I fell in love with Go. After years of wrestling with Python’s asyncio, threading, and multiprocessing, Go’s concurrency model felt like a revelation.

Goroutines: Lightweight Threads

package main

import (
    "fmt"
    "time"
)

func sayHello(name string) {
    for i := 0; i < 3; i++ {
        fmt.Printf("Hello, %s! (%d)\n", name, i)
        time.Sleep(100 * time.Millisecond)
    }
}

func main() {
    // Start goroutines with 'go' keyword
    go sayHello("Alice")
    go sayHello("Bob")

    // Main goroutine continues
    sayHello("Main")

    // Wait for goroutines (simple approach)
    time.Sleep(500 * time.Millisecond)
}

Goroutines vs Threads vs Async

Key Differences:

Aspect Python threads Python asyncio Go goroutines
Memory per unit ~1MB ~1KB ~2KB (grows)
Scalability Thousands Millions Millions
GIL limitation Yes N/A (single thread) No GIL
Syntax Complex async/await go keyword
Scheduling OS Event loop Go runtime
Blocking I/O Blocks thread Must use async Handled automatically

Channels: Communication Between Goroutines

This is Go’s answer to “how do concurrent units communicate safely?”

package main

import (
    "fmt"
    "time"
)

func main() {
    // Create a channel
    messages := make(chan string)

    // Sender goroutine
    go func() {
        time.Sleep(100 * time.Millisecond)
        messages <- "Hello from goroutine!" // Send to channel
    }()

    // Receive from channel (blocks until message arrives)
    msg := <-messages
    fmt.Println(msg)
}

Buffered vs Unbuffered Channels

UNBUFFERED CHANNEL (Synchronous)
================================

Sender                          Receiver
  │                                │
  │──── send "A" ────►             │
  │     (blocks)                   │
  │                    ◄── receive │
  │     (unblocks)                 │
  │                                │

Both must be ready at the same time!


BUFFERED CHANNEL (Asynchronous up to capacity)
==============================================

Channel Buffer (capacity 3)
┌─────┬─────┬─────┐
│     │     │     │
└─────┴─────┴─────┘

Sender sends "A"
┌─────┬─────┬─────┐
│  A  │     │     │  (doesn't block)
└─────┴─────┴─────┘

Sender sends "B"
┌─────┬─────┬─────┐
│  A  │  B  │     │  (doesn't block)
└─────┴─────┴─────┘

Sender sends "C"
┌─────┬─────┬─────┐
│  A  │  B  │  C  │  (doesn't block)
└─────┴─────┴─────┘

Sender tries to send "D"
┌─────┬─────┬─────┐
│  A  │  B  │  C  │  BLOCKS until receiver takes one!
└─────┴─────┴─────┘
package main

import "fmt"

func main() {
    // Unbuffered - synchronous
    unbuffered := make(chan int)

    // Buffered - can hold 3 values before blocking
    buffered := make(chan int, 3)

    buffered <- 1  // Doesn't block
    buffered <- 2  // Doesn't block
    buffered <- 3  // Doesn't block
    // buffered <- 4  // Would block!

    fmt.Println(<-buffered) // 1
    fmt.Println(<-buffered) // 2
    fmt.Println(<-buffered) // 3
}

Real-World Pattern: Worker Pool

package main

import (
    "fmt"
    "sync"
    "time"
)

func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
    defer wg.Done()

    for job := range jobs {
        fmt.Printf("Worker %d processing job %d\n", id, job)
        time.Sleep(100 * time.Millisecond) // Simulate work
        results <- job * 2
    }
}

func main() {
    numJobs := 10
    numWorkers := 3

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

    var wg sync.WaitGroup

    // Start workers
    for w := 1; w <= numWorkers; w++ {
        wg.Add(1)
        go worker(w, jobs, results, &wg)
    }

    // Send jobs
    for j := 1; j <= numJobs; j++ {
        jobs <- j
    }
    close(jobs)

    // Wait for workers and close results
    go func() {
        wg.Wait()
        close(results)
    }()

    // Collect results
    for result := range results {
        fmt.Println("Result:", result)
    }
}

Select: Multiplexing Channels

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(100 * time.Millisecond)
        ch1 <- "from channel 1"
    }()

    go func() {
        time.Sleep(200 * time.Millisecond)
        ch2 <- "from channel 2"
    }()

    // Select waits on multiple channels
    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("Received", msg1)
        case msg2 := <-ch2:
            fmt.Println("Received", msg2)
        case <-time.After(500 * time.Millisecond):
            fmt.Println("Timeout!")
        }
    }
}

Context: Cancellation and Timeouts

package main

import (
    "context"
    "fmt"
    "time"
)

func longOperation(ctx context.Context) error {
    select {
    case <-time.After(5 * time.Second):
        fmt.Println("Operation completed")
        return nil
    case <-ctx.Done():
        fmt.Println("Operation cancelled:", ctx.Err())
        return ctx.Err()
    }
}

func main() {
    // Context with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()

    if err := longOperation(ctx); err != nil {
        fmt.Println("Error:", err)
    }
}

8. Error Handling: The Go Philosophy

Coming from Python’s exceptions, Go’s error handling felt verbose at first. Now I appreciate its explicitness.

The Basics

package main

import (
    "errors"
    "fmt"
)

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

func main() {
    // The pattern you'll write thousands of times
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)
        return
    }
    fmt.Println("Result:", result)
}

Custom Errors

package main

import (
    "fmt"
)

// Custom error type
type ValidationError struct {
    Field   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}

func validateAge(age int) error {
    if age < 0 {
        return &ValidationError{
            Field:   "age",
            Message: "cannot be negative",
        }
    }
    if age > 150 {
        return &ValidationError{
            Field:   "age",
            Message: "unrealistic value",
        }
    }
    return nil
}

func main() {
    if err := validateAge(-5); err != nil {
        // Type assertion to get custom error fields
        if ve, ok := err.(*ValidationError); ok {
            fmt.Printf("Field: %s, Message: %s\n", ve.Field, ve.Message)
        }
    }
}

Error Wrapping (Go 1.13+)

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("resource not found")

func getUser(id int) error {
    // Wrap error with context
    return fmt.Errorf("getUser failed for id %d: %w", id, ErrNotFound)
}

func main() {
    err := getUser(123)

    // Check if error chain contains ErrNotFound
    if errors.Is(err, ErrNotFound) {
        fmt.Println("User not found!")
    }

    fmt.Println("Full error:", err)
    // Output: getUser failed for id 123: resource not found
}

Error Handling Patterns Comparison


9. Packages and Modules

Project Structure

myproject/
├── go.mod              # Module definition
├── go.sum              # Dependency checksums
├── main.go             # Entry point
├── internal/           # Private packages
   └── database/
       └── db.go
├── pkg/                # Public packages
   └── utils/
       └── helpers.go
└── cmd/                # Multiple executables
    └── api/
        └── main.go

Creating a Module

# Initialize module
go mod init github.com/username/myproject

# Add dependencies
go get github.com/gin-gonic/gin

# Tidy up (remove unused, add missing)
go mod tidy

Visibility Rules

package mypackage

// Exported (public) - starts with uppercase
var ExportedVariable = "I'm accessible from other packages"

func ExportedFunction() {}

type ExportedStruct struct {
    ExportedField   string  // Public field
    unexportedField string  // Private field
}

// Unexported (private) - starts with lowercase
var unexportedVariable = "I'm only accessible within this package"

func unexportedFunction() {}

10. Testing: Built Into the Language

One thing I love about Go: testing is a first-class citizen.

// math.go
package math

func Add(a, b int) int {
    return a + b
}

func Divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}
// math_test.go
package math

import (
    "testing"
)

func TestAdd(t *testing.T) {
    result := Add(2, 3)
    expected := 5

    if result != expected {
        t.Errorf("Add(2, 3) = %d; expected %d", result, expected)
    }
}

// Table-driven tests (Go idiom)
func TestDivide(t *testing.T) {
    tests := []struct {
        name      string
        a, b      float64
        expected  float64
        expectErr bool
    }{
        {"positive numbers", 10, 2, 5, false},
        {"division by zero", 10, 0, 0, true},
        {"negative result", -10, 2, -5, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            result, err := Divide(tt.a, tt.b)

            if tt.expectErr && err == nil {
                t.Error("expected error but got none")
            }
            if !tt.expectErr && err != nil {
                t.Errorf("unexpected error: %v", err)
            }
            if result != tt.expected {
                t.Errorf("got %f; expected %f", result, tt.expected)
            }
        })
    }
}

// Benchmarks
func BenchmarkAdd(b *testing.B) {
    for i := 0; i < b.N; i++ {
        Add(2, 3)
    }
}
# Run tests
go test ./...

# With coverage
go test -cover ./...

# Run benchmarks
go test -bench=. ./...

11. Common Patterns and Idioms

The Options Pattern (Functional Options)

package main

import "fmt"

type Server struct {
    host    string
    port    int
    timeout int
    maxConn int
}

// Option is a function that modifies Server
type Option func(*Server)

func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}

func WithTimeout(timeout int) Option {
    return func(s *Server) {
        s.timeout = timeout
    }
}

func WithMaxConnections(max int) Option {
    return func(s *Server) {
        s.maxConn = max
    }
}

func NewServer(host string, opts ...Option) *Server {
    // Default values
    s := &Server{
        host:    host,
        port:    8080,
        timeout: 30,
        maxConn: 100,
    }

    // Apply options
    for _, opt := range opts {
        opt(s)
    }

    return s
}

func main() {
    // Clean API with optional configuration
    server := NewServer("localhost",
        WithPort(9000),
        WithTimeout(60),
    )
    fmt.Printf("%+v\n", server)
}

Repository Pattern

package main

import (
    "context"
    "errors"
)

// Domain model
type User struct {
    ID    int
    Name  string
    Email string
}

// Repository interface
type UserRepository interface {
    GetByID(ctx context.Context, id int) (*User, error)
    Save(ctx context.Context, user *User) error
    Delete(ctx context.Context, id int) error
}

// In-memory implementation
type InMemoryUserRepo struct {
    users map[int]*User
}

func NewInMemoryUserRepo() *InMemoryUserRepo {
    return &InMemoryUserRepo{
        users: make(map[int]*User),
    }
}

func (r *InMemoryUserRepo) GetByID(ctx context.Context, id int) (*User, error) {
    user, exists := r.users[id]
    if !exists {
        return nil, errors.New("user not found")
    }
    return user, nil
}

func (r *InMemoryUserRepo) Save(ctx context.Context, user *User) error {
    r.users[user.ID] = user
    return nil
}

func (r *InMemoryUserRepo) Delete(ctx context.Context, id int) error {
    delete(r.users, id)
    return nil
}

// Service using the interface (easy to test!)
type UserService struct {
    repo UserRepository
}

func NewUserService(repo UserRepository) *UserService {
    return &UserService{repo: repo}
}

12. Building a Real API: Putting It All Together

Here’s a practical example combining everything we’ve learned:

package main

import (
    "context"
    "encoding/json"
    "log"
    "net/http"
    "strconv"
    "sync"
    "time"
)

// ============ Domain Models ============

type Task struct {
    ID          int       `json:"id"`
    Title       string    `json:"title"`
    Description string    `json:"description"`
    Completed   bool      `json:"completed"`
    CreatedAt   time.Time `json:"created_at"`
}

// ============ Repository ============

type TaskRepository struct {
    mu     sync.RWMutex
    tasks  map[int]*Task
    nextID int
}

func NewTaskRepository() *TaskRepository {
    return &TaskRepository{
        tasks:  make(map[int]*Task),
        nextID: 1,
    }
}

func (r *TaskRepository) Create(task *Task) *Task {
    r.mu.Lock()
    defer r.mu.Unlock()

    task.ID = r.nextID
    task.CreatedAt = time.Now()
    r.nextID++

    r.tasks[task.ID] = task
    return task
}

func (r *TaskRepository) GetAll() []*Task {
    r.mu.RLock()
    defer r.mu.RUnlock()

    tasks := make([]*Task, 0, len(r.tasks))
    for _, task := range r.tasks {
        tasks = append(tasks, task)
    }
    return tasks
}

func (r *TaskRepository) GetByID(id int) (*Task, bool) {
    r.mu.RLock()
    defer r.mu.RUnlock()

    task, exists := r.tasks[id]
    return task, exists
}

// ============ HTTP Handlers ============

type TaskHandler struct {
    repo *TaskRepository
}

func NewTaskHandler(repo *TaskRepository) *TaskHandler {
    return &TaskHandler{repo: repo}
}

func (h *TaskHandler) HandleTasks(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case http.MethodGet:
        h.listTasks(w, r)
    case http.MethodPost:
        h.createTask(w, r)
    default:
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
    }
}

func (h *TaskHandler) listTasks(w http.ResponseWriter, r *http.Request) {
    tasks := h.repo.GetAll()
    writeJSON(w, http.StatusOK, tasks)
}

func (h *TaskHandler) createTask(w http.ResponseWriter, r *http.Request) {
    var task Task
    if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    created := h.repo.Create(&task)
    writeJSON(w, http.StatusCreated, created)
}

func (h *TaskHandler) HandleTask(w http.ResponseWriter, r *http.Request) {
    idStr := r.URL.Path[len("/tasks/"):]
    id, err := strconv.Atoi(idStr)
    if err != nil {
        http.Error(w, "Invalid ID", http.StatusBadRequest)
        return
    }

    task, exists := h.repo.GetByID(id)
    if !exists {
        http.Error(w, "Task not found", http.StatusNotFound)
        return
    }

    writeJSON(w, http.StatusOK, task)
}

// ============ Helpers ============

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

// Middleware for logging
func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next(w, r)
        log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
    }
}

// Middleware for request timeout
func timeoutMiddleware(timeout time.Duration, next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), timeout)
        defer cancel()

        r = r.WithContext(ctx)
        next(w, r)
    }
}

// ============ Main ============

func main() {
    repo := NewTaskRepository()
    handler := NewTaskHandler(repo)

    // Register routes with middleware
    http.HandleFunc("/tasks", loggingMiddleware(
        timeoutMiddleware(5*time.Second, handler.HandleTasks),
    ))
    http.HandleFunc("/tasks/", loggingMiddleware(
        timeoutMiddleware(5*time.Second, handler.HandleTask),
    ))

    log.Println("Server starting on :8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}

Interview Preparation: What to Expect

Based on my preparation, here are the common topics:

Coding Interview Focus Areas

Common Interview Questions

  1. Explain the difference between slices and arrays
  2. How do goroutines differ from OS threads?
  3. What happens when you send to a nil channel?
  4. Explain the defer statement execution order
  5. How does Go handle dependency injection without frameworks?
  6. What’s the difference between value and pointer receivers?
  7. How would you implement a rate limiter?
  8. Explain context and when to use it

My Personal Study Roadmap


Key Takeaways

After this journey, here’s what I’ve learned:

  1. Languages are tools, not identities. Six years of Python made me a better Go developer because the fundamentals transfer.

  2. Go’s simplicity is deceptive. The language is small, but mastering patterns like channels and context takes time.

  3. Error handling feels verbose until it saves you. Explicit errors make debugging in production much easier.

  4. Concurrency changes how you think. Go’s model made me reconsider approaches I took for granted in Python.

  5. The ecosystem is mature. Between the standard library and tools like Gin, GORM, and Cobra, Go is production-ready for most use cases.


Resources & References

Official Documentation

Books

  • The Go Programming Language by Alan Donovan & Brian Kernighan - The definitive book
  • Concurrency in Go by Katherine Cox-Buday - Deep dive into Go’s concurrency
  • Learning Go by Jon Bodner - Modern, practical approach

Online Courses & Tutorials

Community

Tools

Interview Preparation


Final Thoughts

To anyone considering a language switch: trust your experience. The patterns you’ve learned—clean architecture, SOLID principles, testing strategies—they all apply. The syntax is just a new dialect.

Go has reinvigorated my passion for programming. Its simplicity forces you to think clearly, its concurrency model is genuinely elegant, and its performance is impressive.

Whether I land a Go position or continue with Python, this journey has made me a better engineer. And honestly? That was the real goal all along.

Discussion (0)

Share your thoughts and join the conversation

?
0/10000
Loading comments...