The Complete Go (Golang) Guide
From zero to production — every concept, with code. After this, you jump straight into Gin.
01 Why Go?
Go was created at Google in 2007 by Robert Griesemer, Rob Pike, and Ken Thompson. It was designed to solve real problems:
- Fast compilation — compiles to native machine code in seconds
- Built-in concurrency — goroutines and channels are first-class citizens
- Garbage collected — no manual memory management, but still fast
- Static typing — catches bugs at compile time, not runtime
- Simple syntax — only 25 keywords (compare: C++ has 95+)
- Single binary — compiles to one binary, no dependencies to install
- Standard library — HTTP server, JSON, crypto, testing — all built-in
break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var
Go is used by: Docker, Kubernetes, Terraform, Prometheus, CockroachDB, Hugo, Caddy, Uber, Twitch, Dropbox, Cloudflare.
02 Setup & Hello World
Installation
# macOS
brew install go # installs Go via Homebrew
# Linux
wget https://go.dev/dl/go1.22.0.linux-amd64.tar.gz # download Go tarball
sudo tar -C /usr/local -xzf go1.22.0.linux-amd64.tar.gz # extract to /usr/local
export PATH=$PATH:/usr/local/go/bin # add go binary to PATH
# Verify
go version # go version go1.22.0 darwin/arm64
Your First Program
package main // every file must declare its package
import "fmt" // import the fmt (format) package
func main() { // program entry point
fmt.Println("Hello, World!") // print with newline
}
# Run it
go run main.go # compile + run in one step
# Build a binary
go build -o myapp main.go # -o names the output binary
./myapp # execute the compiled binary
- Every Go file starts with
packagedeclaration - The
mainpackage withfunc main()is the entry point - Unused imports = compile error (Go is strict!)
- Unused variables = compile error
- Opening brace
{MUST be on the same line (not next line) - No semicolons needed (compiler inserts them)
03 Modules & Packages
Creating a Module
# Initialize a new module
mkdir myproject && cd myproject # create and enter project dir
go mod init github.com/yourname/myproject # creates go.mod with module path
# This creates go.mod file
go.mod file
module github.com/yourname/myproject // unique module path
go 1.22 // minimum Go version required
require ( // external dependencies
github.com/gin-gonic/gin v1.9.1 // web framework
github.com/go-sql-driver/mysql v1.7.1 // MySQL driver
)
Package System — File Structure
- One folder = one package (all
.gofiles in a folder must have the same package name) - Exported = starts with Uppercase (e.g.,
User,GetName()) - Unexported = starts with lowercase (e.g.,
user,getName()) — private to package internal/packages can only be imported by code within the parent directory
Using Packages
// pkg/models/user.go
package models // folder name = package name
type User struct { // Uppercase = exported type
ID int // Uppercase = exported field
Name string // accessible outside package
age int // lowercase = unexported, private to this package
}
// Exported function — accessible from outside
func NewUser(id int, name string, age int) User {
return User{ID: id, Name: name, age: age} // set unexported field here
}
// unexported function — only accessible within 'models' package
func validate(u User) bool {
return u.age > 0 // age accessible inside package
}
// main.go
package main // entry point package
import ( // grouped imports
"fmt" // standard library
"github.com/yourname/myproject/pkg/models" // our package
)
func main() {
u := models.NewUser(1, "Yatin", 25) // create user via constructor
fmt.Println(u.Name) // OK: Name is exported
// fmt.Println(u.age) // ERROR: age is unexported
}
04 Variables & Types
Declaring Variables
package main // declares this file belongs to main
import "fmt" // needed for Println
func main() {
// Method 1: var keyword (can be used anywhere)
var name string = "Yatin" // explicit type + value
var age int = 25 // explicit int type
// Method 2: type inference
var city = "Delhi" // Go infers string
// Method 3: short declaration := (ONLY inside functions)
country := "India" // most common style
pi := 3.14 // inferred as float64
isActive := true // inferred as bool
// Method 4: multiple declarations
var (
x int = 10 // grouped vars are easier to read
y float64 = 20.5
z string = "hello"
)
// Multiple short declarations
a, b, c := 1, "two", 3.0 // each gets its own type
fmt.Println(name, age, city, country, pi, isActive, x, y, z, a, b, c) // use all vars
}
Zero Values (defaults when not initialized)
| Type | Zero Value | Example |
|---|---|---|
int, int8, int16, int32, int64 | 0 | var x int → 0 |
uint, uint8...uint64 | 0 | var x uint → 0 |
float32, float64 | 0.0 | var x float64 → 0 |
bool | false | var x bool → false |
string | "" | var x string → "" |
pointer, slice, map, channel, func, interface | nil | var x []int → nil |
Type Conversions (Go has NO implicit conversions)
func main() {
var i int = 42 // start with an int
var f float64 = float64(i) // explicit conversion required
var u uint = uint(f) // explicit
// String conversions
s := strconv.Itoa(42) // int → string: "42"
n, _ := strconv.Atoi("42") // string → int: 42
fs := fmt.Sprintf("%.2f", 3.14) // float → string: "3.14"
// byte slice ↔ string
bytes := []byte("hello") // string → []byte
str := string(bytes) // []byte → string
_ = i; _ = f; _ = u; _ = s; _ = n; _ = fs; _ = str // suppress unused-var errors
}
Type Aliases & Custom Types
// Custom type — creates a NEW type
type UserID int64 // prevents mixing IDs with plain ints
type Celsius float64 // named type for type safety
type Fahrenheit float64 // separate type from Celsius
func main() {
var id UserID = 12345 // can't accidentally pass as plain int64
var temp Celsius = 36.6 // body temperature in Celsius
// Cannot mix types even if underlying type is same
// var f Fahrenheit = temp // ERROR!
var f Fahrenheit = Fahrenheit(temp*9/5 + 32) // convert formula
_ = id; _ = f // discard to avoid unused-var error
}
// Type alias — just another name (same type)
type byte = uint8 // this is how byte is defined in Go
type rune = int32 // this is how rune is defined in Go
05 Constants & Iota
const Pi = 3.14159 // untyped constant, very precise
const AppName = "MyApp" // string constant, immutable
// Group of constants
const (
StatusOK = 200 // HTTP 200 OK
StatusNotFound = 404 // HTTP 404 Not Found
StatusError = 500 // HTTP 500 Server Error
)
// iota — auto-incrementing constant generator (starts at 0)
type Weekday int // custom int type for weekdays
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
Thursday // 4
Friday // 5
Saturday // 6
)
// iota with bit shifting — permission flags
type Permission uint // bitmask type for permissions
const (
Read Permission = 1 << iota // 1 (001)
Write // 2 (010)
Execute // 4 (100)
)
func main() {
myPerms := Read | Write // 3 (011)
if myPerms&Read != 0 { // bitwise AND to check flag
fmt.Println("Has read permission")
}
}
06 Operators
// Arithmetic: + - * / %
// Comparison: == != < > <= >=
// Logical: && || !
// Bitwise: & | ^ << >> &^
// Assignment: = := += -= *= /= %= &= |= ^= <<= >>=
// Address: & (address of) * (dereference)
// Channel: <- (send/receive)
func main() {
// Go has NO ternary operator (no ? :) — must use if-else
x := 10 // value to test
var result string // declared before if-else scope
if x > 5 {
result = "big" // assign inside if-branch
} else {
result = "small" // assign inside else-branch
}
// Bit clear (AND NOT) — unique to Go
a := 0b1010 // binary literal: 10
b := 0b1100 // binary literal: 12
fmt.Printf("%04b\n", a&^b) // 0010
_ = result // discard to avoid unused-var error
}
07 Control Flow
If-Else (with init statement)
// Standard
if x > 10 { // no parentheses needed around condition
fmt.Println("big")
} else if x > 5 { // chain more conditions
fmt.Println("medium")
} else { // catch-all
fmt.Println("small")
}
// With init statement (v is scoped to the if-else block)
if v := compute(); v > 100 { // init runs before condition
fmt.Println("over 100:", v)
}
// Very common pattern in Go:
if err := doSomething(); err != nil { // run & check error at once
return err // early return on error
}
For Loop (the ONLY loop in Go)
// Classic for
for i := 0; i < 10; i++ { // init; condition; post
fmt.Println(i)
}
// While-style
n := 1 // start value
for n < 100 { n *= 2 } // only condition, like while
// Infinite loop
for { // no condition = loops forever
break // use break/return to exit
}
// Range over slice
nums := []int{10, 20, 30} // slice to iterate
for i, v := range nums { fmt.Println(i, v) } // i=index, v=value
// Range — skip index
for _, v := range nums { fmt.Println(v) } // _ discards index
// Range over map
m := map[string]int{"a": 1, "b": 2} // map literal
for k, v := range m { fmt.Println(k, v) } // k=key, v=value
// Range over string (iterates runes, not bytes)
for i, ch := range "Hello" { fmt.Printf("%d: %c\n", i, ch) } // i=byte offset, ch=rune
// Labels with break/continue
outer: // label for the outer loop
for i := 0; i < 5; i++ {
for j := 0; j < 5; j++ {
if i+j == 4 { break outer } // breaks all the way out
}
}
Switch
// No fall-through by default (no break needed!)
switch day { // switch on variable value
case "Mon", "Tue", "Wed", "Thu", "Fri": // multiple values per case
fmt.Println("Weekday")
case "Sat", "Sun":
fmt.Println("Weekend")
default: // runs if no case matched
fmt.Println("Unknown")
}
// Type switch (very useful with interfaces)
switch v := i.(type) { // i.(type) extracts dynamic type
case int: fmt.Println("int", v) // v is int here
case string: fmt.Println("string", v) // v is string here
default: fmt.Printf("unknown: %T\n", v) // %T prints type name
}
// Expressionless switch (cleaner than if-else chains)
switch { // no value — each case is a bool expression
case score >= 90: fmt.Println("A")
case score >= 80: fmt.Println("B")
default: fmt.Println("F")
}
// fallthrough — explicitly fall to next case
switch 1 { // switch on literal value 1
case 1:
fmt.Println("one")
fallthrough // forces execution into case 2
case 2:
fmt.Println("two") // also prints
}
08 Functions
// Basic function
func add(a, b int) int { return a + b } // a,b share type int
// Multiple return values
func divide(a, b float64) (float64, error) { // result + error pattern
if b == 0 { return 0, fmt.Errorf("division by zero") } // guard clause
return a / b, nil // nil means no error
}
// Named return values
func swap(a, b string) (first, second string) { // pre-declared return vars
first = b; second = a // assign named returns directly
return // naked return
}
// Variadic functions
func sum(nums ...int) int { // ... accepts 0 or more ints
total := 0 // accumulator variable
for _, n := range nums { total += n } // add each number
return total
}
sum(1, 2, 3) // pass multiple args directly
numbers := []int{1, 2, 3} // existing slice
sum(numbers...) // spread slice
// Functions are first-class — assigned to variables
double := func(n int) int { return n * 2 } // anonymous func as var
fmt.Println(double(5)) // 10
// Function as parameter (higher-order)
func apply(nums []int, fn func(int) int) []int { // fn is a func param
result := make([]int, len(nums)) // pre-allocate output slice
for i, v := range nums { result[i] = fn(v) } // call fn on each element
return result
}
// init() — runs before main(), used for setup
func init() { // auto-called by runtime at startup
// package-level vars → init() → main()
fmt.Println("initializing...")
}
09 Arrays & Slices
Arrays (fixed size — rarely used directly)
var a [5]int // [0 0 0 0 0]
b := [3]string{"a", "b", "c"} // array literal with size
c := [...]int{1, 2, 3} // compiler counts: [3]int
// [3]int and [5]int are DIFFERENT types
// Arrays are VALUE types — assignment copies entire array
x := [3]int{1, 2, 3} // original array
y := x // full copy
y[0] = 99 // modify the copy only
fmt.Println(x) // [1 2 3] — unchanged!
_ = a; _ = b; _ = c // suppress unused-var errors
Slices (dynamic — 99% of the time)
// A slice has: pointer, length, capacity
s1 := []int{1, 2, 3} // literal
s2 := make([]int, 5) // len=5, cap=5, zeros
s3 := make([]int, 3, 10) // len=3, cap=10
var s4 []int // nil slice
// Append
s1 = append(s1, 4, 5) // [1 2 3 4 5]
s1 = append(s1, s2...) // append another slice
// Slicing (shares same underlying array!)
nums := []int{0, 1, 2, 3, 4, 5} // base slice
a := nums[1:4] // [1 2 3]
b := nums[:3] // [0 1 2]
c := nums[3:] // [3 4 5]
// True copy:
copySlice := make([]int, len(nums)) // allocate new backing array
copy(copySlice, nums) // copy src into dst
// Delete element at index i
i := 2 // index to remove
nums = append(nums[:i], nums[i+1:]...) // stitch around index
// Sort
sort.Ints(nums) // sort ints in-place ascending
sort.Slice(people, func(i, j int) bool { // custom sort via less-fn
return people[i].Age < people[j].Age // sort by Age ascending
})
_ = s3; _ = s4; _ = a; _ = b; _ = c // suppress unused-var errors
A slice header is: { ptr *Array, len int, cap int }
When append exceeds capacity, Go allocates a new array (2x for small, 1.25x for large) and copies elements. Always use: s = append(s, x)
10 Maps
m1 := map[string]int{"apple": 5, "banana": 3} // map literal
m2 := make(map[string]int) // empty, ready to use
var m3 map[string]int // nil map — PANICS on write!
// CRUD
m1["cherry"] = 7 // create/update
val := m1["apple"] // read
delete(m1, "banana") // delete
// Check if key exists (comma ok idiom)
if v, ok := m1["grape"]; ok { // ok is false if key missing
fmt.Println("found:", v)
}
// Iterate (order NOT guaranteed)
for k, v := range m1 { fmt.Println(k, v) } // random order each run
// Map of slices
graph := map[string][]string{ // adjacency list pattern
"a": {"b", "c"}, // a connects to b and c
"b": {"d"}, // b connects to d
}
// Maps are NOT safe for concurrent use! Use sync.Map or mutex
_ = m2; _ = m3; _ = val; _ = graph // suppress unused-var errors
11 Strings & Runes
// Strings are immutable byte slices
emoji := "Go is 🔥" // emoji is multi-byte (UTF-8)
fmt.Println(len(emoji)) // 10 (bytes!)
fmt.Println(len([]rune(emoji))) // 7 (characters)
// String builder (efficient concatenation)
var sb strings.Builder // avoids repeated allocs
for i := 0; i < 1000; i++ { sb.WriteString("hello ") } // append to buffer
result := sb.String() // convert buffer to string
// strings package essentials
strings.Contains(s, "World") // true
strings.HasPrefix(s, "Hello") // true
strings.HasSuffix(s, "!") // true
strings.ToUpper(s) // "HELLO, WORLD!"
strings.TrimSpace(" hi ") // "hi"
strings.Split("a,b,c", ",") // ["a","b","c"]
strings.Join([]string{"a","b"}, "-") // "a-b"
strings.ReplaceAll(s, "l", "L") // "HeLLo, WorLd!"
strings.Repeat("Go", 3) // "GoGoGo"
// Raw strings (backticks)
raw := `no \n escaping here` // useful for regex, JSON, HTML
// Sprintf for formatting
msg := fmt.Sprintf("Name: %s, Age: %d", "Yatin", 25) // build formatted string
_ = result; _ = raw; _ = msg // suppress unused-var errors
12 Structs
type User struct { // groups related data together
ID int // unique identifier
FirstName string
LastName string
Email string
Age int
Active bool // zero value is false (safe default)
}
// Create
u1 := User{ID: 1, FirstName: "Yatin", Email: "y@test.com", Active: true} // named fields
u2 := &User{FirstName: "Ptr"} // pointer to struct
u3 := new(User) // *User, all zero values
// Access — auto-dereferences pointers
u2.FirstName // same as (*u2).FirstName
// Struct tags (json, db, validate)
type Product struct {
ID int `json:"id" db:"product_id"` // maps to JSON key "id"
Name string `json:"name" validate:"required"` // validation tag
Price float64 `json:"price,omitempty"` // omit from JSON if zero
SKU string `json:"-"` // excluded from JSON
}
// Constructor pattern (Go has no constructors)
func NewUser(first, last, email string) *User { // returns pointer
return &User{FirstName: first, LastName: last, Email: email, Active: true} // set defaults here
}
// Anonymous struct
point := struct{ X, Y int }{10, 20} // one-off struct, no type name
_ = u1; _ = u3; _ = point // suppress unused-var errors
13 Pointers
x := 42 // regular variable on stack
p := &x // p is *int, points to x
fmt.Println(*p) // 42 (dereference)
*p = 100 // write through the pointer
fmt.Println(x) // 100 — changed through pointer!
// nil pointer
var np *int // nil
// *np → PANIC: nil pointer dereference!
// Go has NO pointer arithmetic (unlike C)
// Pass by value vs pointer
func doubleValue(n int) { n *= 2 } // copy, original unchanged
func doublePtr(n *int) { *n *= 2 } // modifies original
// When to use pointers:
// 1. Mutate the argument
// 2. Large structs (avoid copying)
// 3. Represent "absence" (nil)
// When NOT: small structs, slices/maps/channels (already refs)
_ = np // suppress unused-var error
14 Methods & Interfaces
Methods
type Rectangle struct { Width, Height float64 } // simple data struct
// Value receiver — gets a COPY
func (r Rectangle) Area() float64 { return r.Width * r.Height } // read-only, safe
// Pointer receiver — can MODIFY the struct
func (r *Rectangle) Scale(factor float64) { // r points to original
r.Width *= factor // mutate in-place
r.Height *= factor
}
// Rule: if ANY method needs pointer receiver, use pointer for ALL
Interfaces
// Interface = set of method signatures
// IMPLICITLY satisfied (no "implements" keyword)
type Shape interface { // any type with these methods satisfies Shape
Area() float64 // must implement Area
Perimeter() float64 // must implement Perimeter
}
type Circle struct { Radius float64 } // concrete type
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius } // π*r²
func (c Circle) Perimeter() float64 { return 2 * math.Pi * c.Radius } // 2πr
// Polymorphism
func PrintInfo(s Shape) { // accepts any Shape implementation
fmt.Printf("Area: %.2f\n", s.Area()) // calls correct method at runtime
}
// Empty interface — accepts ANY type
func printAnything(v any) { fmt.Println(v) } // any = interface{}
// Type assertion
s, ok := i.(string) // safe assertion
// Key stdlib interfaces:
// fmt.Stringer → String() string
// error → Error() string
// io.Reader → Read(p []byte) (n int, err error)
// io.Writer → Write(p []byte) (n int, err error)
// http.Handler → ServeHTTP(ResponseWriter, *Request)
// Interface composition
type ReadWriter interface { io.Reader; io.Writer } // embed two interfaces
// Best practice: accept interfaces, return structs
_ = s; _ = ok // suppress unused-var errors
An interface value is nil only if BOTH the type and value are nil:
var s *MyStruct = nil // typed nil pointer
var i Shape = s // interface holds (type=*MyStruct, value=nil)
fmt.Println(i == nil) // false! type is *MyStruct, value is nil
15 Embedding & Composition
// Go has NO inheritance. Uses COMPOSITION via embedding.
type Animal struct { Name, Sound string } // base struct
func (a Animal) Speak() string { return a.Name + " says " + a.Sound } // base method
type Dog struct {
Animal // embedded — promotes all fields & methods
Breed string // Dog-specific field
}
d := Dog{Animal: Animal{"Rex", "Woof"}, Breed: "German Shepherd"} // init embedded struct
d.Name // "Rex" (promoted from Animal)
d.Speak() // "Rex says Woof"
// Override methods
func (d Dog) Speak() string { return d.Name + " barks!" } // shadows Animal.Speak
16 Error Handling
// error is an interface: Error() string
err1 := errors.New("something went wrong") // simple static error
err2 := fmt.Errorf("user %d not found", 42) // formatted error message
// The Go pattern: always check errors
data, err := os.ReadFile(path) // returns ([]byte, error)
if err != nil { // nil means success
return nil, fmt.Errorf("reading %s: %w", path, err) // %w wraps
}
// Custom error types
type ValidationError struct { Field, Message string } // rich error info
func (e *ValidationError) Error() string { // satisfies error interface
return fmt.Sprintf("%s: %s", e.Field, e.Message)
}
// Sentinel errors
var ErrNotFound = errors.New("not found") // compare with errors.Is
// errors.Is — check through wrapping chain
if errors.Is(err, ErrNotFound) { /* handle */ } // works through wrapped errors
// errors.As — extract specific error type
var ve *ValidationError // target type to extract
if errors.As(err, &ve) { fmt.Println(ve.Field) } // ve populated if matched
_ = err1; _ = err2; _ = data // suppress unused-var errors
17 Goroutines & Channels
Goroutines
// Lightweight thread (~2KB stack vs ~1MB OS thread)
go doWork(1) // launch goroutine
// WaitGroup — wait for goroutines to finish
var wg sync.WaitGroup // counter: Add/Done/Wait
for i := 0; i < 5; i++ {
wg.Add(1) // increment counter before goroutine
go func(id int) { // id passed to avoid capture bug
defer wg.Done() // decrement when goroutine exits
doWork(id)
}(i) // pass i as argument immediately
}
wg.Wait() // block until counter hits zero
// BUG: loop variable captured by reference
for i := 0; i < 5; i++ {
go func() { fmt.Println(i) }() // prints 5,5,5,5,5
}
// FIX: pass as argument
for i := 0; i < 5; i++ {
go func(n int) { fmt.Println(n) }(i) // copy i into n
}
// Go 1.22+: loop vars are per-iteration (fixed!)
Channels
// Unbuffered — sender blocks until receiver ready
ch := make(chan int) // synchronous handoff
go func() { ch <- 42 }() // goroutine sends 42
val := <-ch // 42
// Buffered — blocks only when buffer full
bch := make(chan string, 3) // can send 3 items without waiting
// Direction constraints
func producer(ch chan<- int) { ch <- 1 } // send-only
func consumer(ch <-chan int) { <-ch } // receive-only
// Range over channel (until closed)
for v := range ch { fmt.Println(v) } // exits when ch is closed
// Worker pool pattern
jobs := make(chan int, 100) // input queue
results := make(chan int, 100) // output queue
for w := 0; w < 3; w++ { // 3 worker goroutines
go func() {
for job := range jobs { // read until jobs closed
results <- job * 2 // process and send result
}
}()
}
_ = val; _ = bch // suppress unused-var errors
18 Select Statement
// select — wait on multiple channel operations
select { // blocks until one case is ready
case msg := <-ch1: // receive from ch1
fmt.Println(msg)
case msg := <-ch2: // receive from ch2
fmt.Println(msg)
case <-time.After(1 * time.Second): // fires after 1s
fmt.Println("timeout!")
default: // non-blocking: runs if nothing else ready
fmt.Println("no message ready")
}
// Ticker — periodic events
ticker := time.NewTicker(500 * time.Millisecond) // fires every 500ms
defer ticker.Stop() // clean up goroutine when done
for {
select {
case <-done: return // exit on done signal
case t := <-ticker.C: fmt.Println("tick", t) // handle each tick
}
}
19 Sync Package
// Mutex
type SafeCounter struct {
mu sync.Mutex // protects v from concurrent access
v map[string]int // shared mutable state
}
func (c *SafeCounter) Inc(key string) {
c.mu.Lock() // acquire exclusive lock
defer c.mu.Unlock() // always release lock
c.v[key]++ // safe to modify now
}
// RWMutex — multiple readers OR one writer
type Cache struct { mu sync.RWMutex; data map[string]string }
func (c *Cache) Get(k string) string {
c.mu.RLock(); defer c.mu.RUnlock() // read lock allows concurrent reads
return c.data[k]
}
// sync.Once — run exactly once (singleton)
var once sync.Once // goroutine-safe one-time init
once.Do(func() { /* expensive init */ }) // only runs once ever
// sync.Pool — reuse objects
// sync.Map — concurrent map
// atomic — lock-free operations
var counter int64 // shared counter
atomic.AddInt64(&counter, 1) // increment atomically, no mutex needed
20 Context
// Carries deadlines, cancellation, and request-scoped values
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // 5 second deadline
defer cancel() // always call to free resources
select {
case <-ctx.Done(): // channel closed when cancelled/timed out
fmt.Println("cancelled:", ctx.Err()) // Err() tells why it stopped
case result := <-doWork(ctx): // work completed in time
fmt.Println(result)
}
// Values (use sparingly)
type ctxKey string // custom type avoids key collisions
ctx = context.WithValue(ctx, ctxKey("reqID"), "abc-123") // attach request ID
- Always pass
context.Contextas the first parameter - Never store context in a struct
- Use
context.Background()in main/init/tests - Always call
cancel()viadefer cancel()
21 Generics (Go 1.18+)
// Generic function
func Min[T constraints.Ordered](a, b T) T { // T must support < operator
if a < b { return a } // works for any Ordered type
return b
}
Min(3, 5) // 3 (type inferred)
Min("a", "b") // "a"
// Type constraints
type Number interface {
~int | ~int64 | ~float32 | ~float64 // ~ means "underlying type"
}
// Generic struct
type Stack[T any] struct { items []T } // T is the element type
func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } // add to top
func (s *Stack[T]) Pop() (T, bool) {
var zero T // zero value of type T
if len(s.items) == 0 { return zero, false } // empty stack
item := s.items[len(s.items)-1] // get last element
s.items = s.items[:len(s.items)-1] // shrink slice
return item, true
}
// Generic Map/Filter
func Map[T, U any](s []T, fn func(T) U) []U { // transform T slice into U slice
r := make([]U, len(s)) // pre-allocate result
for i, v := range s { r[i] = fn(v) } // apply fn to each element
return r
}
22 Closures & Higher-Order Functions
// Closure — captures variables from outer scope
func counter() func() int { // returns a function
count := 0 // captured: lives as long as closure
return func() int { count++; return count } // closure over count
}
next := counter() // each call gets its own count
next() // 1
next() // 2
// Factory pattern
func multiplier(factor int) func(int) int { // returns configured func
return func(n int) int { return n * factor } // factor is captured
}
double := multiplier(2) // specialised multiplier
double(5) // 10
23 Defer, Panic, Recover
// defer — executes when function returns (LIFO order)
f, _ := os.Open("file.txt") // open file, ignoring error for brevity
defer f.Close() // guaranteed cleanup
// panic — unrecoverable error (use RARELY)
panic("something terrible happened") // unwinds stack, prints trace
// recover — catch panics (ONLY inside deferred func)
defer func() { // must be deferred to catch panics
if r := recover(); r != nil { // r is the panic value
fmt.Println("Recovered:", r)
}
}()
// Real-world: recovery middleware in HTTP servers
func recoveryMiddleware(next http.HandlerFunc) http.HandlerFunc { // wraps a handler
return func(w http.ResponseWriter, r *http.Request) {
defer func() { // set up panic recovery for this request
if err := recover(); err != nil { // caught a panic
http.Error(w, "Internal Error", 500) // return 500 to client
}
}()
next(w, r) // call the actual handler
}
}
24 Linked List
type Node[T any] struct { // generic node for any type T
Val T // the data stored in this node
Next *Node[T] // pointer to next node (nil if last)
}
type LinkedList[T any] struct {
Head *Node[T] // first node (nil if empty)
Size int // track length
}
// Prepend — O(1)
func (ll *LinkedList[T]) Prepend(val T) {
ll.Head = &Node[T]{Val: val, Next: ll.Head} // new node points to old head
ll.Size++
}
// Append — O(n)
func (ll *LinkedList[T]) Append(val T) {
node := &Node[T]{Val: val} // new tail node
if ll.Head == nil { ll.Head = node; ll.Size++; return } // empty list
curr := ll.Head // start traversal from head
for curr.Next != nil { curr = curr.Next } // walk to last node
curr.Next = node; ll.Size++ // attach new node at end
}
// Reverse — O(n)
func (ll *LinkedList[T]) Reverse() {
var prev *Node[T] // becomes new tail (nil)
curr := ll.Head // current node being processed
for curr != nil {
next := curr.Next // save next before overwriting
curr.Next = prev // reverse the pointer
prev = curr // advance prev
curr = next // advance curr
}
ll.Head = prev // prev is now the new head
}
// Print: 0 -> 1 -> 2 -> nil
func (ll *LinkedList[T]) Print() {
for c := ll.Head; c != nil; c = c.Next { // walk the list
fmt.Printf("%v -> ", c.Val)
}
fmt.Println("nil") // mark end of list
}
25 Stack & Queue
// Stack (LIFO)
type Stack[T any] struct { items []T } // backed by a slice
func (s *Stack[T]) Push(item T) { s.items = append(s.items, item) } // add to top
func (s *Stack[T]) IsEmpty() bool { return len(s.items) == 0 } // check before Pop
func (s *Stack[T]) Pop() (T, bool) {
var zero T // safe zero to return on failure
if s.IsEmpty() { return zero, false } // nothing to pop
idx := len(s.items) - 1 // index of top element
item := s.items[idx] // save it before shrinking
s.items = s.items[:idx] // remove top element
return item, true
}
// Queue (FIFO)
type Queue[T any] struct { items []T } // backed by a slice
func (q *Queue[T]) Enqueue(item T) { q.items = append(q.items, item) } // add to back
func (q *Queue[T]) Dequeue() (T, bool) {
var zero T // safe zero to return on failure
if len(q.items) == 0 { return zero, false } // empty queue
item := q.items[0] // get front element
q.items = q.items[1:] // remove front element
return item, true
}
// Classic: valid parentheses
func isValid(s string) bool {
stack := &Stack[rune]{} // stack of opening brackets
pairs := map[rune]rune{')': '(', ']': '[', '}': '{'} // closing → opening
for _, ch := range s { // iterate each character
if ch == '(' || ch == '[' || ch == '{' {
stack.Push(ch) // push opening bracket
} else {
top, ok := stack.Pop() // must match last opening
if !ok || top != pairs[ch] { return false } // mismatch
}
}
return stack.IsEmpty() // valid only if all brackets matched
}
26 Binary Tree / BST
type TreeNode struct {
Val int // node value
Left, Right *TreeNode // child pointers (nil if absent)
}
// Insert — O(log n) avg
func insert(node *TreeNode, val int) *TreeNode {
if node == nil { return &TreeNode{Val: val} } // base: create leaf
if val < node.Val { node.Left = insert(node.Left, val) } // go left
else if val > node.Val { node.Right = insert(node.Right, val) } // go right
return node // return unchanged node (or duplicate)
}
// In-order (sorted output for BST)
func inOrder(n *TreeNode, res *[]int) {
if n == nil { return } // base case
inOrder(n.Left, res) // visit left subtree first
*res = append(*res, n.Val) // then visit current node
inOrder(n.Right, res) // then visit right subtree
}
// Level-order (BFS)
func levelOrder(root *TreeNode) [][]int {
if root == nil { return nil } // empty tree
var result [][]int // each inner slice is one level
queue := []*TreeNode{root} // BFS queue starts with root
for len(queue) > 0 {
sz := len(queue) // nodes at current level
var level []int // collect values for this level
for i := 0; i < sz; i++ {
n := queue[0]; queue = queue[1:] // dequeue front
level = append(level, n.Val) // record value
if n.Left != nil { queue = append(queue, n.Left) } // enqueue children
if n.Right != nil { queue = append(queue, n.Right) }
}
result = append(result, level) // store completed level
}
return result
}
// Max depth
func maxDepth(n *TreeNode) int {
if n == nil { return 0 } // nil node contributes 0 depth
l, r := maxDepth(n.Left), maxDepth(n.Right) // depth of each subtree
if l > r { return l + 1 } // +1 for current node
return r + 1
}
27 Graph
type Graph struct {
adj map[string][]string // adjacency list
directed bool // true = directed, false = undirected
}
func (g *Graph) AddEdge(from, to string) {
g.adj[from] = append(g.adj[from], to) // add forward edge
if !g.directed { g.adj[to] = append(g.adj[to], from) } // add reverse for undirected
}
// BFS
func (g *Graph) BFS(start string) []string {
visited := map[string]bool{start: true} // mark start as visited
queue := []string{start} // BFS frontier
var result []string // visited order
for len(queue) > 0 {
node := queue[0]; queue = queue[1:] // dequeue front
result = append(result, node) // record visit
for _, nb := range g.adj[node] {
if !visited[nb] { visited[nb] = true; queue = append(queue, nb) } // enqueue unvisited
}
}
return result
}
// DFS (recursive)
func (g *Graph) DFS(start string) []string {
visited := make(map[string]bool) // track visited nodes
var result []string // accumulate visit order
var dfs func(string) // declare before defining (recursive closure)
dfs = func(n string) {
visited[n] = true // mark before recursing
result = append(result, n) // record visit
for _, nb := range g.adj[n] {
if !visited[nb] { dfs(nb) } // recurse into unvisited
}
}
dfs(start) // kick off from start node
return result
}
28 Hash Table (from scratch)
// Go maps ARE hash tables. This is for understanding.
const tableSize = 64 // number of buckets
type entry struct { key string; value int; next *entry } // linked list node
type HashTable struct { buckets [tableSize]*entry; size int } // fixed array of chains
func hash(key string) int {
h := 0 // running hash value
for _, ch := range key { h = (h*31 + int(ch)) % tableSize } // polynomial rolling hash
return h // bucket index
}
func (ht *HashTable) Put(key string, val int) {
idx := hash(key) // compute bucket
for curr := ht.buckets[idx]; curr != nil; curr = curr.next {
if curr.key == key { curr.value = val; return } // update existing
}
ht.buckets[idx] = &entry{key, val, ht.buckets[idx]} // prepend to chain
ht.size++
}
func (ht *HashTable) Get(key string) (int, bool) {
for curr := ht.buckets[hash(key)]; curr != nil; curr = curr.next { // walk chain
if curr.key == key { return curr.value, true } // found
}
return 0, false // not found
}
29 I/O & Files
// Read entire file
data, err := os.ReadFile("config.json") // returns []byte
// Write entire file
os.WriteFile("out.txt", []byte("hello"), 0644) // 0644 = rw-r--r-- perms
// Read line by line
f, _ := os.Open("file.txt") // open read-only
defer f.Close() // always close the file
scanner := bufio.NewScanner(f) // wraps file in a scanner
for scanner.Scan() { fmt.Println(scanner.Text()) } // Text() returns current line
// Buffered write
f, _ = os.Create("out.txt") // create or truncate file
w := bufio.NewWriter(f) // buffer writes for efficiency
w.WriteString("line 1\n") // write to buffer
w.Flush() // don't forget!
// io.Copy between reader and writer
io.Copy(dst, src) // stream bytes from src to dst
// Directory operations
os.MkdirAll("path/to/dir", 0755) // create all parent dirs too
entries, _ := os.ReadDir(".") // list current directory
_ = data; _ = err; _ = entries // suppress unused-var errors
30 JSON & Encoding
type User struct {
ID int `json:"id"` // maps Go field to JSON key
Name string `json:"name"`
Email string `json:"email,omitempty"` // omit if empty string
Pass string `json:"-"` // never in JSON
}
// Struct → JSON
jsonBytes, _ := json.Marshal(user) // compact JSON bytes
jsonBytes, _ = json.MarshalIndent(user, "", " ") // pretty-printed JSON
// JSON → Struct
var u User // target struct to populate
json.Unmarshal([]byte(jsonStr), &u) // must pass pointer
// JSON → dynamic map
var data map[string]any // use when structure is unknown
json.Unmarshal([]byte(jsonStr), &data) // parse into map
// Streaming (for HTTP bodies)
json.NewEncoder(w).Encode(user) // write to io.Writer
json.NewDecoder(r.Body).Decode(&u) // read from io.Reader
_ = jsonBytes; _ = data // suppress unused-var errors
31 HTTP & Networking
// Simple server
http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello!") // write response body
})
log.Fatal(http.ListenAndServe(":8080", nil)) // nil = use DefaultServeMux
// Go 1.22+ enhanced routing
mux := http.NewServeMux() // custom router, not the default
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // extract {id} from URL
fmt.Fprintf(w, "User: %s", id)
})
mux.HandleFunc("POST /users", createUser) // method + path matching
// JSON API handler
func getUsers(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") // set before writing body
json.NewEncoder(w).Encode(users) // stream JSON to client
}
// HTTP Client with timeout
client := &http.Client{Timeout: 10 * time.Second} // always set a timeout
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) // ctx allows cancellation
req.Header.Set("Authorization", "Bearer token") // set auth header
resp, err := client.Do(req) // send request
defer resp.Body.Close() // always close body to free connection
_ = err // handle error in real code
32 Testing
// Table-driven tests — THE Go pattern
func TestDivide(t *testing.T) { // must start with Test
tests := []struct { // slice of anonymous structs
name string // test case name
a, b float64 // inputs
want float64 // expected result
wantErr bool // expect an error?
}{
{"normal", 10, 2, 5, false}, // 10/2 = 5, no error
{"zero div", 10, 0, 0, true}, // divide by zero, expect error
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { // subtest for each case
got, err := Divide(tt.a, tt.b) // call the function
if (err != nil) != tt.wantErr { // error presence check
t.Errorf("error = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want { // value check
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
// Benchmark
func BenchmarkAdd(b *testing.B) { // must start with Benchmark
for i := 0; i < b.N; i++ { Add(2, 3) } // b.N auto-tuned by framework
}
33 Reflection
import "reflect" // runtime type inspection
t := reflect.TypeOf(u) // get the type of u
fmt.Println(t.Name(), t.Kind(), t.NumField()) // name, kind (struct), field count
// Iterate fields + read tags
for i := 0; i < t.NumField(); i++ {
f := t.Field(i) // get StructField info
fmt.Println(f.Name, f.Type, f.Tag.Get("json")) // read json struct tag
}
// Modify via reflection (need pointer)
v := reflect.ValueOf(&u).Elem() // Elem() dereferences pointer
v.FieldByName("Name").SetString("Updated") // set field by name
// WARNING: Reflection is slow and bypasses type safety.
// Prefer generics for type-safe generic code.
34 Unsafe & Cgo
// Struct padding — field order matters!
type Bad struct { a bool; b int64; c bool } // 24 bytes
type Good struct { b int64; a bool; c bool } // 16 bytes (33% less!)
// unsafe.Sizeof
fmt.Println(unsafe.Sizeof(int64(0))) // 8
fmt.Println(unsafe.Sizeof("")) // 16 (ptr + len)
// Go has NO pointer arithmetic (unlike C)
_ = Bad{}; _ = Good{} // suppress unused-type errors
35 Design Patterns
// Singleton (sync.Once)
var dbOnce sync.Once; var db *DB // package-level state
func GetDB() *DB {
dbOnce.Do(func() { db = connect() }) // connect only once
return db // always returns same instance
}
// Options Pattern (very common)
type Option func(*Server) // each option mutates Server
func WithPort(p int) Option { return func(s *Server) { s.port = p } } // set port
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } } // set timeout
func NewServer(host string, opts ...Option) *Server {
s := &Server{host: host, port: 8080, timeout: 30*time.Second} // sensible defaults
for _, o := range opts { o(s) } // apply each option
return s
}
srv := NewServer("localhost", WithPort(3000)) // override only port
// Repository Pattern
type UserRepo interface { // abstract over storage
FindByID(ctx context.Context, id int) (*User, error) // read
Create(ctx context.Context, u *User) error // write
}
// Middleware Chain
type Middleware func(http.Handler) http.Handler // wraps a handler
func Chain(h http.Handler, mws ...Middleware) http.Handler {
for i := len(mws)-1; i >= 0; i-- { h = mws[i](h) } // apply in reverse for correct order
return h
}
_ = srv // suppress unused-var error
36 Production Project Structure
handler → service → repository → database
internal/domain has ZERO dependencies — pure Go structs and rules.
37 Performance & Profiling
// pprof — built-in profiler
import _ "net/http/pprof" // blank import registers pprof routes
go http.ListenAndServe("localhost:6060", nil) // start debug server
// go tool pprof http://localhost:6060/debug/pprof/heap
// go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// Key optimizations:
// 1. Pre-allocate: make([]int, 0, 1000)
// 2. strings.Builder for concatenation
// 3. sync.Pool for hot objects
// 4. Avoid interface{}/any (heap allocations)
// 5. go build -gcflags="-m" for escape analysis
// 6. Order struct fields by size (reduce padding)
// 7. Buffered I/O: bufio.NewWriterSize(f, 64*1024)
// 8. Race detector: go run -race main.go
38 Important Commands
The commands you'll actually use every day. Learn these and you're set.
| go run main.go | Compile + execute in one shot. Your "run this file" button. Also go run . for all files in dir. |
| go build -o myapp | Compile into a binary. Drop -o to use default name. This is what you ship to production. |
| go fmt ./... | Auto-format ALL your code. Go has ONE style — no debates. Run before every commit. |
| go vet ./... | Static analysis — catches bugs the compiler misses: wrong printf verbs, unreachable code, bad struct tags, etc. |
| go mod init github.com/you/project | Creates go.mod. Run once when starting a new project. This is your package.json. |
| go mod tidy | Syncs go.mod with your actual imports — adds missing deps, removes unused. Run after adding/removing imports. |
| go get github.com/pkg@v1.2.3 | Add or update a dependency. Use @latest for newest. This is your npm install. |
| go test ./... | Run ALL tests in all packages. The ./... means "recursively". This is the one you run most. |
| go test -v -run TestName | Run one specific test with verbose output. -run takes a regex. |
| go test -cover ./... | Shows coverage %. For detailed report: -coverprofile=c.out then go tool cover -html=c.out |
| go test -race ./... | Runs tests with the race detector. Catches concurrent data races. Use in CI — it's saved countless bugs. |
| go test -bench=. -benchmem | Run benchmarks. -benchmem shows allocations per op. Essential for perf work. |
| go build -gcflags="-m" | Escape analysis — tells you what allocates on heap vs stack. Add -m -m for verbose. |
| go run -race main.go | Run your app with race detection. Slower but catches goroutine data races at runtime. |
| go tool pprof http://localhost:6060/debug/pprof/heap | Analyze memory profile of a running app (needs import _ "net/http/pprof"). |
| GOOS=linux GOARCH=amd64 go build -o app | Build for Linux from your Mac. That's it. No Docker needed for compilation. Change GOOS/GOARCH for any target. |
| go build -ldflags="-s -w" | Smaller binary — strips debug symbols. Typical Go binary: 10-15MB, with this: 6-8MB. |
| go install | Build + put the binary in $GOPATH/bin. For CLI tools you want available globally. |
| go clean -cache | Nuke the build cache. Fixes weird "it should work but doesn't" issues. |
| go clean -testcache | Clear cached test results. Use when tests pass but shouldn't (or vice versa). |
| go env | Print all Go env vars. Useful when GOPATH, GOROOT, or GOPROXY seems off. |
| go doc fmt.Sprintf | Quick docs lookup from terminal. Faster than googling. |
39 Interview Questions
Junior Level
var x int is an explicit declaration — can be used at package level or inside functions. x := 0 is short variable declaration — can ONLY be used inside functions. Both initialize x to 0, but := also infers the type.
var x int // explicit type, zero value (0)
x := 0 // inferred as int, value 0
x := 0.0 // inferred as float64!
Go's philosophy is to keep codebases clean. Unused variables and imports are dead code — they confuse readers, slow down compilation, and often indicate bugs. The compiler enforces this as a hard error, not a warning, to prevent code rot.
Use _ (blank identifier) when you intentionally want to discard a value:
_, err := doSomething() // discard first return value
Every type has a zero value: int → 0, string → "", bool → false, pointers/slices/maps → nil. This means variables are always initialized — no undefined behavior. Many Go types are designed to be useful at their zero value (e.g., sync.Mutex{}, bytes.Buffer{}).
Array: fixed size, size is part of the type ([3]int ≠ [5]int), value type (copying copies all elements).
Slice: dynamic, reference type (header: pointer + length + capacity), backed by an array. Use slices 99% of the time.
arr := [3]int{1,2,3} // array — fixed
slc := []int{1,2,3} // slice — dynamic
Go treats errors as values, not exceptions. Functions return an error as the last return value. You check it explicitly with if err != nil. This is intentional — it makes error handling visible and forces you to think about every failure point. No hidden control flow.
f, err := os.Open("file.txt")
if err != nil {
return fmt.Errorf("open file: %w", err) // wrap & propagate
}
defer f.Close()
defer schedules a function call to run when the surrounding function returns. Multiple defers execute in LIFO (last-in-first-out) order. Common uses: closing files, unlocking mutexes, flushing buffers.
mu.Lock()
defer mu.Unlock() // guaranteed even if panic
Mid Level
Go allocates a new, larger underlying array (roughly 2x capacity for small slices, 1.25x for large), copies existing elements, then appends. This is why append returns a new slice — the pointer may have changed. Always: s = append(s, x).
If two slices shared the same array and one grows past capacity, they now point to different arrays — mutations no longer affect each other.
Value receiver (r Rect): gets a copy. Use for small, read-only structs.
Pointer receiver (r *Rect): gets a pointer, can mutate. Use when: you need to modify the struct, the struct is large, or for consistency (if one method needs pointer, use pointer for all).
Important: a value receiver can be called on a pointer, and vice versa — Go auto-converts. But an interface is satisfied differently: Shape implemented by *Rect means only *Rect (not Rect) satisfies it.
Go interfaces are implicit — a type satisfies an interface by implementing its methods, without declaring it. This enables decoupling without import dependencies. Internally, an interface value is a two-word pair: (type, value).
Best practices: keep interfaces small (1-3 methods), define them where they're used (not where implemented), and "accept interfaces, return structs."
A goroutine is a lightweight thread managed by the Go runtime. It starts with ~2KB stack (vs ~1-8MB for OS threads), is multiplexed onto OS threads by the Go scheduler (M:N scheduling), and context-switches faster since it doesn't need a syscall. You can easily run millions of goroutines.
The Go scheduler uses a work-stealing algorithm with per-P (processor) run queues for efficiency.
Unbuffered make(chan int): synchronous — sender blocks until receiver is ready. Use for synchronization / guaranteed handoff.
Buffered make(chan int, 10): sender only blocks when full. Use for: decoupling producer/consumer speeds, rate limiting, batch processing, semaphores.
Rule of thumb: start unbuffered, add buffer only when you need to decouple timing.
select blocks until one of its cases can proceed. If multiple are ready, it picks one randomly (fair scheduling). Common patterns: timeout with time.After, non-blocking with default, done channel for cancellation.
select {
case v := <-dataCh: process(v)
case <-ctx.Done(): return
case <-time.After(5*time.Second): return errTimeout
}
Senior Level
G = Goroutine (the task). M = Machine/OS thread (the executor). P = Processor (the scheduler context, holds run queue). Default GOMAXPROCS = number of CPU cores.
Each P has a local run queue of Gs. An M must acquire a P to execute Gs. When a goroutine blocks (syscall, channel, etc.), the M releases its P so another M can pick it up. Work-stealing: idle Ps steal Gs from busy Ps' queues.
This M:N model gives goroutine-level concurrency with OS-level parallelism.
Go uses a concurrent, tri-color mark-and-sweep GC. It runs concurrently with your program (mostly). Three phases: Mark Setup (STW, brief), Marking (concurrent), Mark Termination (STW, brief).
Tri-color: White (unmarked/garbage), Grey (reachable, not fully scanned), Black (reachable, fully scanned). Write barrier ensures correctness during concurrent marking.
Tuning: GOGC=100 (default) — triggers GC when heap doubles. Lower = more frequent GC, less memory. GOMEMLIMIT (Go 1.19+) sets a soft memory limit. Use GODEBUG=gctrace=1 to observe.
The compiler analyzes whether a variable can stay on the stack or must "escape" to the heap. Stack allocation is free (just move stack pointer), heap allocation requires GC.
Variables escape when: returned as pointer, stored in interface, captured by closure that outlives function, too large for stack.
Check with: go build -gcflags="-m". To optimize: return values not pointers when small, avoid unnecessary interface{}, pre-allocate slices.
Goroutine leaks happen when a goroutine blocks forever (waiting on channel, mutex, etc.). Prevention:
- Always use
context.Contextfor cancellation - Ensure channels are closed or have done signals
- Use buffered channels when producers might exit before consumers
- Set timeouts on operations
- In tests, use
goleak(uber-go/goleak) to detect leaks
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
select {
case r := <-work(ctx): // ...
case <-ctx.Done(): return ctx.Err()
}
errors.Is(err, target) checks if any error in the chain matches a specific value (for sentinel errors like ErrNotFound). Uses == comparison or custom Is() method.
errors.As(err, &target) checks if any error in the chain matches a specific type and extracts it (for custom error structs). Fills the target variable with the matched error.
if errors.Is(err, sql.ErrNoRows) { /* value match */ }
var ve *ValidationError
if errors.As(err, &ve) { /* type match, ve is populated */ }
Context carries: cancellation signals, deadlines/timeouts, and request-scoped values across API boundaries and goroutines. It forms a tree — cancelling a parent cancels all children.
Critical in servers: when a client disconnects, the request context is cancelled, which should propagate to all goroutines doing work for that request (DB queries, HTTP calls, etc.), preventing wasted resources.
Rules: first param, don't store in struct, always defer cancel().
Staff / VP Level
Use the pipeline pattern: chain stages connected by channels. Each stage is a goroutine (or pool) that reads from input channel, processes, writes to output channel.
Fan-out: multiple goroutines reading from same channel (parallel processing). Fan-in: merge multiple channels into one.
Key decisions: buffer sizes (tune to balance memory vs throughput), backpressure (bounded channels), cancellation (context), error handling (dedicated error channel or errgroup), graceful shutdown (close input, drain pipeline).
Use golang.org/x/sync/errgroup for managing groups of goroutines with error propagation.
CPUs read memory in word-sized chunks. Fields must be aligned to their size (int64 to 8-byte boundary). The compiler inserts padding bytes between misaligned fields.
type Bad struct { a bool; b int64; c bool } // 24 bytes (7+7 padding)
type Good struct { b int64; a bool; c bool } // 16 bytes (6 padding)
Order fields from largest to smallest. For hot structs in slices, this saves cache lines and memory. Use fieldalignment linter or unsafe.Sizeof to check.
- Measure first: add pprof endpoints, collect CPU + heap profiles under realistic load
- CPU profile:
go tool pprof→ find hot functions, optimize algorithms - Memory profile: heap profile → find allocation-heavy paths, reduce with sync.Pool, pre-allocation, avoid interface{}
- Escape analysis:
-gcflags="-m"→ keep hot-path allocations on stack - Benchmarks:
go test -bench -benchmembefore and after changes - Trace:
go tool tracefor goroutine scheduling, GC pauses, latency analysis - GC tuning: adjust GOGC, set GOMEMLIMIT, observe with gctrace
Golden rule: don't optimize without a profile. Measure, change, measure again.
internal/ is enforced by the Go compiler — packages under it can only be imported by code rooted at the parent of internal. This creates a hard visibility boundary.
pkg/ is a convention (not enforced) for packages intended to be importable by external projects.
Default to internal/ — making something public is an API commitment. Only move to pkg/ when you explicitly want external consumers. Many modern Go projects skip pkg/ entirely and put public packages at the root.
40 Ready for Gin Framework
Every concept maps to what you learned above.
package main // executable package
import (
"net/http" // for status codes like StatusOK
"strconv" // for Atoi (string to int)
"github.com/gin-gonic/gin" // Gin web framework
)
type User struct {
ID int `json:"id"`
Name string `json:"name" binding:"required"` // required in request body
Email string `json:"email" binding:"required,email"` // must be valid email
}
var users = make(map[int]User) // in-memory store
var nextID = 1 // auto-increment ID counter
func main() {
r := gin.Default() // router with Logger + Recovery
r.Use(authMiddleware()) // apply auth to all routes
api := r.Group("/api/v1") // prefix all routes with /api/v1
{
api.GET("/users", getUsers) // list all users
api.GET("/users/:id", getUserByID) // :id is a URL param
api.POST("/users", createUser) // create new user
}
r.Run(":8080") // start server on port 8080
}
func getUsers(c *gin.Context) {
list := make([]User, 0) // ensure JSON array not null
for _, u := range users { list = append(list, u) } // collect all users
c.JSON(http.StatusOK, gin.H{"data": list}) // gin.H is map[string]any
}
func getUserByID(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id")) // Param extracts :id from URL
u, ok := users[id] // look up user by ID
if !ok { c.JSON(404, gin.H{"error": "not found"}); return } // not found
c.JSON(http.StatusOK, u) // return user as JSON
}
func createUser(c *gin.Context) {
var u User // target for JSON binding
if err := c.ShouldBindJSON(&u); err != nil { // validate & decode body
c.JSON(400, gin.H{"error": err.Error()}); return // bad request
}
u.ID = nextID; nextID++ // assign auto-increment ID
users[u.ID] = u // store the new user
c.JSON(http.StatusCreated, u) // 201 Created response
}
func authMiddleware() gin.HandlerFunc { // returns a Gin handler
return func(c *gin.Context) {
token := c.GetHeader("Authorization") // read auth header
if token == "" { // no token provided
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"}) // stop chain
return
}
c.Set("userID", "123") // attach data to request context
c.Next() // proceed to next handler
}
}
Concept Mapping
| What You Learned | Used in Gin |
|---|---|
| Structs + JSON tags | Request/Response models, ShouldBindJSON |
| Interfaces | Gin's Handler, custom middleware |
| Error handling | Every handler checks errors |
| Closures | Middleware pattern |
| Maps | Route params, gin.H{} |
| Pointers | *gin.Context everywhere |
| Context | Request context, timeouts |
| Goroutines | Each request = its own goroutine |
| Options pattern | Server / middleware configuration |
| Testing | httptest for handler tests |
- Practice data structures — implement from memory
- Build a REST API with
net/http(no framework) - Then move to Gin — it's syntactic sugar over what you know
- Add PostgreSQL (
sqlxorGORM) - Add JWT authentication
- Deploy with Docker
Built for mastery. Now go build something.