Go (Golang) — Fundamentals
From zero to writing real Go programs
Welcome to Go Fundamentals
Welcome! This course takes you from zero Go knowledge to writing real, idiomatic Go programs. Each lesson focuses on one core concept, explains it clearly, and ends with an interactive quiz to lock in what you have learned.
You will cover everything you need to get productive: the language basics, functions and closures, loops and data structures, structs and interfaces, and Go's killer feature — lightweight concurrency with goroutines.
17 lessonsEach lesson has 4 tabbed sections and a quiz. You unlock the next lesson by passing the quiz (≥ 2/3 correct).~7 hoursRead the sections at your own pace, copy-paste the code into the Go playground and experiment freely.PlaygroundRun any Go code snippet instantly at go.dev/play
— no local installation required to follow along.Beginner-friendlyPrior programming experience helps but is not required. Every concept is introduced from scratch with clear examples.
💡 💡 Click Start course 💡 below to begin with Lesson 1. Your progress is saved automatically in your browser.
Hello, Go!
Go (also called Golang) is a statically typed, compiled programming language designed at Google in 2009 by Robert Griesemer, Rob Pike, and Ken Thompson. It was built to solve real frustrations: slow compilation in C++, verbose boilerplate in Java, and the difficulty of writing safe concurrent code in either.
🚀 PerformanceCompiles to native machine code — comparable to C/C++ but far more pleasant to write. Startup times are near-instant with no runtime or VM overhead.😊 SimplicityOnly 25 keywords in the entire language. The specification fits in a single web page. Most developers become productive in Go within a week.⚡ ConcurrencyGoroutines and channels are first-class language features. Launching thousands of concurrent tasks costs as little as a few kilobytes of stack memory.🔒 SafetyStatic typing catches entire classes of bugs at compile time. Garbage collection eliminates memory leaks. No pointer arithmetic or undefined behaviour. Go is used extensively in production by companies like Google, Docker, Kubernetes, Uber, Dropbox, Twitch, Cloudflare, and HashiCorp. If you have ever used Docker or Kubernetes, you have already run Go programs.
The language's philosophy is deliberately conservative: readable, reliable, scalable. Go almost never adds syntax for the sake of convenience — the Go 1 compatibility guarantee has kept the language stable since 2012, meaning code written 10 years ago still compiles today.
💡 💡 Go is the language of choice for cloud-native infrastructure, microservices, REST APIs, CLI tools, and DevOps tooling. Docker, Kubernetes, Terraform, Prometheus, and many other widely-used projects are written entirely in Go.
Download Go from go.dev/dl for your operating system. The installer automatically sets up the required environment variables. After installation, open a new terminal and verify:
go version
# go version go1.22.0 linux/amd64
Inspect the Go workspace configuration with go env:
go env GOPATH # workspace root: ~/go
go env GOROOT # Go installation directory
go env GOBIN # directory for compiled binaries
GOROOTPath to the Go installation. Set automatically by the installer — you rarely need to touch this.GOPATHYour workspace root (default: ~/go). Downloaded module caches and installed tools live here.GOBINWhere go install puts compiled binaries. Add this to your PATH so installed tools are accessible.go envPrints all Go environment variables. Run go env -w KEY=VALUE to persist a setting permanently. For editor support, install the Go extension for VS Code (or the GoLand IDE). Both provide autocomplete, inline documentation, formatting, and integrated debugging.
💡 💡 Modern Go projects use modules (go mod), so GOPATH matters much less than it used to. You can put your projects anywhere on disk.
Create a file called main.go and write your first Go program:
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
Run it without compiling to a separate binary:
go run main.go
# Hello, Go!
Or compile to a standalone executable:
go build -o my-program main.go
./my-program
# Hello, Go!
package mainEvery file starts with a package declaration. The special name 'main' signals the executable entry point.import "fmt"Imports the fmt package (format). Provides Println, Printf, Sprintf and many other I/O helpers.func main()The entry point of an executable. Execution starts here. Required in the main package.fmt.Println()Prints text followed by a newline. Use Printf for formatting: fmt.Printf("Hello, %s!", name). Go programs are formatted with a canonical style enforced by the gofmt tool (or go fmt ./...). There are no arguments about brace placement or indentation — the tool decides. Most editors run it automatically on save.
💡 💡 Run go fmt ./... before committing. If you use VS Code with the Go extension, formatting happens automatically on save via gopls.
Create a new Go project using the module system:
mkdir my-project
cd my-project
go mod init github.com/yourname/my-project
A typical Go project layout:
my-project/
├── go.mod # module definition and dependencies
├── go.sum # checksums — do not edit by hand
├── main.go # entry point
├── cmd/ # additional executables
│ └── server/
│ └── main.go
├── internal/ # private code — not importable externally
│ └── handler.go
└── pkg/ # reusable public libraries
└── utils.go
go mod initCreates go.mod with the module path and Go version. Equivalent to package.json in Node.js or Cargo.toml in Rust.go mod tidyAutomatically adds missing imports and removes unused ones. Run it after changing dependencies.internal/Code here is only importable within your own module. Great for hiding implementation details from consumers.pkg/Convention for reusable code that other modules can import. Not enforced by the toolchain — it's just convention. To add a dependency, simply import it in your code and run go mod tidy. Or install it directly:
go get github.com/some/[email protected]
💡 💡 Run go mod tidy after adding or removing imports. It keeps go.mod and go.sum consistent and removes unused entries.
Variables and Types
Go provides several ways to declare variables. The short declaration := is by far the most common inside functions.
// 1. Explicit type
var x int = 5
var name string = "Go"
// 2. Type inference with var
var y = 10 // inferred as int
var msg = "hi" // inferred as string
// 3. Short declaration := (only inside functions!)
z := 15
lang := "Go"
// 4. Grouped var block — useful at package level
var (
a int = 1
b string = "two"
c bool = true
)
// 5. Multiple assignment and swap in one line
x, y := 1, 2
first, last := "Jane", "Smith"
first, last = last, first // swap without a temp variable!
var x int = 5Explicit type declaration. Use at package level, or when the type must be obvious from context.var x = 5Go infers the type from the value. x gets type int.x := 5Short declaration — most popular form. Only works inside functions. Creates a new variable.Grouped varDeclares multiple variables together. Cleaner than repeating var on separate lines.
⚠️ ⚠️ Go does not ⚠️ allow unused variables — the code will not compile. If you want to discard a value, use the blank identifier
_⚠️ .
Go is statically typed — every variable has a type known at compile time. Here are the core built-in types:
// Integers
var i int = 42 // platform-dependent (32 or 64 bit)
var i8 int8 = 127 // range: -128 to 127
var i16 int16 = 32767
var i32 int32 = 2147483647
var i64 int64 = 9223372036854775807
var u uint = 42 // unsigned — no negative values
// Floating-point
var f32 float32 = 3.14
var f64 float64 = 3.141592653589793 // use this by default
// Other primitive types
var b bool = true
var s string = "Hello, Go!"
var r rune = 'A' // alias for int32 — holds a Unicode code point
var by byte = 255 // alias for uint8
| Type | Size | Range / use |
|---|---|---|
int8 | 8 bit | -128 to 127 |
int16 | 16 bit | -32 768 to 32 767 |
int32 / rune | 32 bit | -2³¹ to 2³¹-1 / Unicode char |
int64 | 64 bit | -2⁶³ to 2⁶³-1 |
float32 | 32 bit | ~7 significant digits |
float64 | 64 bit | ~15 significant digits (recommended) |
bool | 1 bit | true / false |
string | dynamic | immutable UTF-8 byte sequence |
💡 💡 Use
int💡 for integer arithmetic andfloat64💡 for floating-point — these are the types the standard library expects by default.
Constants (const) are values computed at compile time. Once declared they can never be modified at runtime. They are ideal for named magic numbers.
// Simple constants
const Pi = 3.14159
const Version = "1.0.0"
// Grouped constant block
const (
East = "east"
West = "west"
)
// iota — auto-incrementing counter inside const blocks
type Weekday int
const (
Monday Weekday = iota // 0
Tuesday // 1
Wednesday // 2
Thursday // 3
Friday // 4
Saturday // 5
Sunday // 6
)
// iota with expressions — memory units
const (
_ = iota // discard 0
KB = 1 << (10 * iota) // 1024
MB // 1 048 576
GB // 1 073 741 824
TB // 1 099 511 627 776
)
constCompile-time constant. Can be typed (const x int = 5) or untyped (const x = 5). Untyped constants are more flexible.iotaSpecial identifier in a const block. Resets to 0 at each const block and increments by 1 per constant.Enum patternGo has no enum keyword. Use type MyType int + const block + iota — this is the idiomatic equivalent.Bit flagsiota works great for power-of-two flags: 1 << iota gives 1, 2, 4, 8, 16 …
💡 💡 Untyped constants like
const Pi = 3.14159💡 are more flexible than typed ones — they adapt to the numeric type of the expression they are used in.
Go requires explicit type conversion between numeric types — there are no implicit casts. This prevents entire categories of subtle bugs that plague C and C++ code.
// Numeric conversion
var i int = 42
var f float64 = float64(i) // int -> float64
var u uint = uint(f) // float64 -> uint (truncates decimal!)
// Beware: narrowing conversions can overflow silently
var big int64 = 1000000
var small int8 = int8(big) // overflow!
fmt.Println(small) // 64, not 1000000
// String <-> []byte / []rune
s := "Hello! 🐹"
b := []byte(s) // string -> UTF-8 bytes
r := []rune(s) // string -> Unicode code points
fmt.Println(len(b)) // 11 (bytes)
fmt.Println(len(r)) // 8 (characters)
// Number <-> string via strconv package
import "strconv"
s1 := strconv.Itoa(42) // int -> "42"
n, err := strconv.Atoi("42") // "42" -> int, nil
f2, err := strconv.ParseFloat("3.14", 64) // "3.14" -> float64
T(x)Syntax for type conversion. Go only converts when you ask explicitly — never silently.TruncationConverting float64 to int drops the decimal part — it does not round. float64(1.9) -> int gives 1.string(42)Returns the UTF-8 string for Unicode code point 42 — the character *, not the text "42". Use strconv.Itoa instead.strconvThe standard library package for converting between numbers and strings. Atoi, Itoa, ParseFloat, FormatFloat are the key functions.
⚠️ ⚠️ Never use
string(42)⚠️ to convert a number to text — it produces the Unicode character at code point 42, not the string "42". Usestrconv.Itoa(42)⚠️ instead.
Functions
Functions are declared with the func keyword. Go puts the parameter type after the name — the opposite of C and Java. Return types come after the parameter list.
// Basic function
func add(a int, b int) int {
return a + b
}
// When consecutive params share a type, declare the type once
func multiply(a, b int) int {
return a * b
}
// No return value
func greet(name string) {
fmt.Printf("Hello, %s!\n", name)
}
// Calling functions
result := add(3, 4) // 7
multiply(5, 6) // 30
greet("Alice") // Hello, Alice!
// fmt.Printf format verbs
fmt.Printf("int: %d\n", 42)
fmt.Printf("float: %.2f\n", 3.14159) // 3.14
fmt.Printf("string: %s\n", "Go")
fmt.Printf("bool: %t\n", true)
fmt.Printf("type: %T\n", 42) // int
fmt.Printf("value: %v\n", []int{1, 2, 3})
func name(params) typeBasic function syntax. Types come after parameter names — unlike C or Java.Shared typeWhen consecutive params have the same type: func f(a, b int) int — declare the type once at the end.fmt.Printf%d=int, %s=string, %f=float, %t=bool, %v=any value, %T=type of value.fmt.SprintfLike Printf but returns a string instead of printing. Used to build formatted strings.
💡 💡 Go enforces that every declared variable must be used — the same rule applies to imported packages. Unused imports are a compile error.
Go functions can return multiple values. This is the idiomatic alternative to exceptions: return a result and an error, then check the error at the call site.
// Return (result, error) — the standard Go pattern
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("division by zero")
}
return a / b, nil // nil means no error
}
// At the call site — always check the error
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Result:", result) // 5
// Discard a value with the blank identifier _
result, _ = divide(10, 3)
// Named return values — the function pre-declares its output vars
func minMax(nums []int) (min, max int) {
min, max = nums[0], nums[0]
for _, n := range nums {
if n < min { min = n }
if n > max { max = n }
}
return // "naked return" — returns the named variables
}
min, max := minMax([]int{3, 1, 4, 1, 5, 9})
(float64, error)Multiple return types in parentheses. The (result, error) pair is the standard Go error-handling pattern.if err != nilThe idiomatic error check. Always handle errors — ignoring them with _ is a code smell unless intentional.Blank identifier _Discards a return value. The compiler accepts unused blank identifiers, unlike regular variables.Named returnsfunc f() (x, y int) pre-declares x and y. They are zero-initialised and a bare return sends them back.
💡 💡 By convention, the error is always the last 💡 return value. Create errors with
errors.New("message")💡 or wrap them withfmt.Errorf("context: %w", err)💡 to preserve the chain.
Variadic functions accept any number of arguments of the same type. Inside the function, the variadic parameter is a regular slice.
// Variadic function — ... before the type
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
sum(1, 2, 3) // 6
sum(1, 2, 3, 4, 5) // 15
sum() // 0
// Pass a slice with the ... spread operator
nums := []int{1, 2, 3, 4}
sum(nums...) // 10
// Anonymous function (lambda)
square := func(x int) int {
return x * x
}
fmt.Println(square(5)) // 25
// Higher-order functions — functions that take or return functions
func apply(nums []int, f func(int) int) []int {
result := make([]int, len(nums))
for i, n := range nums {
result[i] = f(n)
}
return result
}
squares := apply([]int{1, 2, 3, 4}, square)
// [1 4 9 16]
...intVariadic param — accepts 0 or more values. Seen as []int inside the function body.nums...Unpacks a slice into variadic arguments. Like the spread operator (...) in JavaScript.Anonymous functionsA function literal assigned to a variable. Useful as callbacks and immediately-invoked expressions.Higher-order functionsFunctions are first-class values in Go — they can be passed as arguments and returned from other functions.
💡 💡 The built-in
append💡 function is variadic:append(slice, elem1, elem2)💡 orappend(slice1, slice2...)💡 .
A closure is a function that captures variables from its enclosing scope and keeps them alive even after the outer function has returned.
// Counter factory — classic closure
func newCounter() func() int {
n := 0
return func() int {
n++
return n
}
}
c1 := newCounter()
fmt.Println(c1()) // 1
fmt.Println(c1()) // 2
fmt.Println(c1()) // 3
c2 := newCounter()
fmt.Println(c2()) // 1 — independent from c1!
// Fibonacci generator
func fibonacci() func() int {
a, b := 0, 1
return func() int {
a, b = b, a+b
return a
}
}
fib := fibonacci()
for i := 0; i < 8; i++ {
fmt.Print(fib(), " ")
}
// 1 1 2 3 5 8 13 21
// defer — schedule cleanup for when the function exits
func readFile(name string) error {
f, err := os.Open(name)
if err != nil { return err }
defer f.Close() // runs when readFile returns — always
// ... safely read from f
return nil
}
ClosureThe inner function holds a reference — not a copy — to the outer function's variables. Changes are shared.Private stateClosures are an elegant way to encapsulate mutable state without defining a struct or a class.deferSchedules a call to run when the enclosing function exits. Multiple defers run in LIFO order.Resource safetydefer f.Close() right after opening a file guarantees cleanup even if the function returns early due to an error.
⚠️ ⚠️ Loop variable capture is a classic gotcha in Go: closures in a loop all capture the same ⚠️ variable. Fix with a local copy:
n := n⚠️ inside the loop body (or use the loop variable directly in Go 1.22+).
Loops and Conditions
The if statement in Go does not require parentheses around the condition, but curly braces are always required — even for single-line bodies.
// Basic if/else
x := 10
if x > 5 {
fmt.Println("greater than 5")
} else if x == 5 {
fmt.Println("equal to 5")
} else {
fmt.Println("less than 5")
}
// if with an initialiser — v is only visible inside the if/else block
if v := compute(); v > 100 {
fmt.Println("large:", v)
} else {
fmt.Println("small:", v)
}
// v is not accessible here
// Most common pattern: error check
if err := writeToFile(data); err != nil {
fmt.Println("write error:", err)
return
}
// Logical operators
if a > 0 && b > 0 { fmt.Println("both positive") } // AND
if a < 0 || b < 0 { fmt.Println("one is negative") } // OR
if !active { fmt.Println("inactive") } // NOT
No parenthesesif condition — no () around the condition. Braces are mandatory even for a one-line body.Initialiserif v := f(); v > 0 — runs f(), assigns to v, then tests. v lives only inside the if/else block.if err != nilThe dominant error-handling idiom in Go. Check immediately after every operation that can fail.&&, ||, !Standard logical operators. Identical to C, Java, and JavaScript. Short-circuit evaluation applies.
💡 💡 The initialiser form
if v := expr; condition💡 keeps the scope of v tightly bounded. Prefer it over declaring v before the if block.
Go's switch is more powerful than C or Java. There is no fall-through by default — no break needed at the end of each case.
// Basic switch
switch day {
case "Monday", "Tuesday", "Wednesday":
fmt.Println("early week")
case "Saturday", "Sunday":
fmt.Println("weekend!")
default:
fmt.Println("Thursday or Friday")
}
// Switch without expression — equivalent to if-else chain
switch {
case temperature < 0:
fmt.Println("freezing")
case temperature < 20:
fmt.Println("cool")
default:
fmt.Println("warm")
}
// fallthrough — explicitly continue to the next case
switch n {
case 1:
fmt.Println("one")
fallthrough
case 2:
fmt.Println("one or two")
}
// Type switch — check the dynamic type of an interface
func describe(i interface{}) string {
switch v := i.(type) {
case int: return fmt.Sprintf("int: %d", v)
case string: return fmt.Sprintf("string: %q", v)
default: return fmt.Sprintf("type: %T", v)
}
}
No break neededCases end automatically. Use fallthrough to explicitly continue into the next case body.Multiple valuescase "a", "b", "c": — one case handles many values, separated by commas.No-expression switchswitch — a clean, readable alternative to long if-else if chains.Type switchi.(type) checks the runtime type of an interface value. Pairs well with interface.
💡 💡 The expressionless switch
switch { case ... }💡 is a Go idiom for complex conditionals. It reads more cleanly than a long if-else if-else chain.
Go has exactly one loop keyword: for. It covers every looping pattern: counted loop, while, infinite loop, and range iteration.
// 1. Classic C-style for
for i := 0; i < 5; i++ {
fmt.Println(i) // 0 1 2 3 4
}
// 2. for as "while" — just the condition
n := 1
for n < 100 {
n *= 2
}
fmt.Println(n) // 128
// 3. Infinite loop — exit with break or return
for {
input := readInput()
if input == "quit" {
break
}
}
// 4. continue — skip the rest of this iteration
for i := 0; i < 10; i++ {
if i%2 == 0 {
continue // skip even numbers
}
fmt.Print(i, " ") // 1 3 5 7 9
}
// 5. Labels — break from a nested loop
outer:
for i := 0; i < 3; i++ {
for j := 0; j < 3; j++ {
if i+j >= 3 { break outer }
fmt.Printf("(%d,%d) ", i, j)
}
}
for i := 0; i < n; i++Classic counted loop with init, condition, and post statement.for condition Equivalent to while in other languages. Runs as long as the condition is true.for Infinite loop. Must be exited with break, return, or panic. Used for event loops and servers.break / continuebreak exits the loop; continue skips to the next iteration. Both support labels for nested loops.
💡 💡 There is no
do-while💡 in Go. Emulate it withfor { ... if condition { break } }💡 .
range iterates over slices, arrays, maps, strings, and channels. It is the idiomatic way to loop over collections.
// range over a slice
fruits := []string{"apple", "banana", "cherry"}
for i, f := range fruits {
fmt.Printf("%d: %s\n", i, f)
}
for _, f := range fruits { // ignore index
fmt.Println(f)
}
// Map — key-value iteration
scores := map[string]int{
"Alice": 95, "Bob": 82, "Carol": 91,
}
for name, score := range scores {
fmt.Printf("%s: %d\n", name, score)
}
// Warning: order is intentionally random!
// range over a string — yields runes (Unicode code points)
for i, r := range "Go! 🐹" {
fmt.Printf("[%d] %c\n", i, r)
}
// Slice operations
nums := []int{1, 2, 3}
nums = append(nums, 4, 5) // add elements
sub := nums[1:4] // slice [2 3 4]
cpy := make([]int, len(nums))
copy(cpy, nums) // copy
// Map operations
m := make(map[string]int) // create empty map
m["key"] = 42
val, ok := m["key"] // ok=true, val=42
delete(m, "key") // remove a key
for i, v := range sliceReturns index and value. Use _, v to ignore the index.for k, v := range mapReturns key and value. Iteration order is deliberately random.[]int{1, 2, 3}Slice literal. append() adds elements. s[a:b] creates a sub-slice sharing the underlying array.map[string]intHash map. make() creates an empty one. val, ok := m[key] safely checks for key existence.
⚠️ ⚠️ Map iteration order is intentionally randomised ⚠️ in Go to prevent programs from depending on insertion order. Never assume a specific order.
Structs and Methods
Go has no classes. Instead it uses struct — a named collection of fields that group related data together. Methods are added to structs separately.
// Define a struct
type Person struct {
FirstName string
LastName string
Age int
}
// Create instances
p1 := Person{"Jane", "Smith", 30} // positional — fragile
p2 := Person{FirstName: "Alice", Age: 25} // named fields — recommended!
var p3 Person // zero values: "" "" 0
// Access and modify fields
fmt.Println(p1.FirstName) // "Jane"
p1.Age = 31
// Embedding — composition over inheritance
type Address struct {
Street string
City string
}
type Employee struct {
Person // embedded struct
Title string
Address // also embedded
}
e := Employee{
Person: Person{"Jane", "Smith", 30},
Title: "Engineer",
Address: Address{"123 Main St", "Anytown"},
}
// Field promotion — access embedded fields directly
fmt.Println(e.FirstName) // same as e.Person.FirstName
fmt.Println(e.City) // same as e.Address.City
type T struct Define a struct. Exported fields start with a capital letter; unexported fields are lowercase.Named fieldsPerson{FirstName: "Jane"} is safe if fields are added later. Positional init breaks when you add a field.Embeddingtype Employee struct — promotes Person's fields and methods onto Employee. Not inheritance!Pointer to struct&Person creates a pointer. Go auto-dereferences: p.Field works the same as (*p).Field.
💡 💡 Always initialise structs using named fields. Positional initialisation breaks silently when the struct gains a new field at any position.
Methods are functions with a special receiver parameter that binds the function to a type. Go adds methods to any named type — not just structs.
type Circle struct {
Radius float64
}
// Value receiver — operates on a COPY of the struct
func (c Circle) Area() float64 {
return 3.14159 * c.Radius * c.Radius
}
func (c Circle) Perimeter() float64 {
return 2 * 3.14159 * c.Radius
}
// Pointer receiver — operates on the ORIGINAL struct
func (c *Circle) Scale(factor float64) {
c.Radius *= factor
}
// Usage
c := Circle{Radius: 5}
fmt.Printf("Area: %.2f\n", c.Area()) // 78.54
fmt.Printf("Perimeter: %.2f\n", c.Perimeter()) // 31.42
c.Scale(2)
fmt.Printf("Radius: %.1f\n", c.Radius) // 10.0
// Methods on non-struct types
type Celsius float64
func (c Celsius) ToFahrenheit() float64 {
return float64(c)*9/5 + 32
}
temp := Celsius(100)
fmt.Printf("%.1f°F\n", temp.ToFahrenheit()) // 212.0°F
// Stringer interface — auto-used by fmt
func (c Circle) String() string {
return fmt.Sprintf("Circle(r=%.2f)", c.Radius)
}
fmt.Println(c) // Circle(r=10.00)
(c Circle) valueMethod gets a copy. Changes do not affect the original. Use for read-only methods.(c *Circle) pointerMethod works on the original. Changes are visible outside. Use when the method mutates state.Receiver namingKeep receivers short — 1–2 letters, usually the first letter of the type: c for Circle. Consistent across all methods.Methods on aliasestype Celsius float64 — you can add methods to any named type, not just structs.
💡 💡 Be consistent: if any method on a type uses a pointer receiver, all methods should use pointer receivers. The Go compiler and linters enforce this.
Interfaces define behaviour — a set of method signatures. In Go, implementation is implicit: any type that has all the required methods satisfies the interface automatically.
// Define an interface
type Shape interface {
Area() float64
Perimeter() float64
}
// Rectangle implicitly satisfies Shape
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 from the previous section also satisfies Shape!
// Accept an interface — polymorphism without inheritance
func printShape(s Shape) {
fmt.Printf("Area: %.2f, Perimeter: %.2f\n", s.Area(), s.Perimeter())
}
c := Circle{5}
r := Rectangle{4, 6}
printShape(c) // Area: 78.54, Perimeter: 31.42
printShape(r) // Area: 24.00, Perimeter: 20.00
// Empty interface — accepts any value
func print(v interface{}) {
fmt.Printf("%T: %v\n", v, v)
}
print(42) // int: 42
print("hello") // string: hello
// Type assertion — recover the concrete type
var i interface{} = "hello"
s, ok := i.(string) // ok=true, s="hello"
n, ok := i.(int) // ok=false, n=0
// Compose interfaces
type ReadWriter interface {
io.Reader
io.Writer
}
Duck typingIf it walks like a duck... A type satisfies an interface by having the right methods — no explicit declaration.interfaceThe empty interface accepts any value. In Go 1.18+ you can write any (a predeclared alias).Type assertionv.(T) retrieves the concrete value. Use the 2-value form val, ok := i.(T) to avoid a panic on mismatch.CompositionInterfaces can embed other interfaces. io.ReadWriter = io.Reader + io.Writer — a common pattern in the stdlib.
💡 💡 Design interfaces at the consumer 💡 side, not the producer side. Define the interface where it is used, with only the methods you need. Small interfaces are more composable.
Goroutines are lightweight threads managed by the Go runtime. Creating one costs about 2 KB of stack. Channels let goroutines communicate safely without sharing memory.
import (
"fmt"
"sync"
"time"
)
// Launch a goroutine with the go keyword
func say(s string) {
for i := 0; i < 3; i++ {
fmt.Println(s)
time.Sleep(100 * time.Millisecond)
}
}
go say("world") // runs concurrently
say("hello") // runs in the current goroutine
// Channel — typed conduit for communication between goroutines
ch := make(chan int) // unbuffered channel
go func() {
ch <- 42 // send a value — blocks until receiver is ready
}()
v := <-ch // receive — blocks until a value is sent
fmt.Println(v) // 42
// Buffered channel — doesn't block until the buffer is full
bch := make(chan string, 3)
bch <- "a"
bch <- "b"
bch <- "c"
fmt.Println(<-bch) // "a"
// WaitGroup — wait for a group of goroutines to finish
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Printf("goroutine %d\n", n)
}(i)
}
wg.Wait()
fmt.Println("all goroutines done")
go func()Launches the function as a goroutine. Tiny overhead: ~2 KB stack, no OS thread created. Thousands run happily in parallel.chan TTyped channel. ch <- v sends; v := <-ch receives. Unbuffered channels synchronise sender and receiver.sync.WaitGroupCoordinates goroutine completion. Add(n) before launching, Done() in each goroutine, Wait() to block until all finish.Go motto"Do not communicate by sharing memory; share memory by communicating." — channels over mutexes whenever possible.
💡 💡 For more complex coordination, explore
sync.Mutex💡 for mutual exclusion,select💡 for multi-channel operations, andcontext.Context💡 for cancellation and timeouts.
Collections in Depth
An array is a fixed-size, numbered sequence of elements of a single type. The length is part of the type — [3]int and [4]int are different types — which is why arrays are rare in everyday Go. They are the foundation that slices are built on.
// Declaration — length is fixed at compile time
var nums [3]int // [0 0 0] — zero-valued
primes := [5]int{2, 3, 5, 7, 11}
// Let the compiler count the elements with ...
days := [...]string{"Mon", "Tue", "Wed"} // length 3
// Index access and assignment
primes[0] = 1
fmt.Println(primes[0]) // 1
fmt.Println(len(primes)) // 5
// Arrays are VALUE types — assigning copies the whole array
a := [3]int{1, 2, 3}
b := a // full copy
b[0] = 99
fmt.Println(a) // [1 2 3] — unchanged!
fmt.Println(b) // [99 2 3]
// Multidimensional arrays
var grid [2][3]int
grid[0][1] = 5
Key points
[N]T— array type. The lengthNis part of the type and cannot change.[...]T{...}— let the compiler infer the length from the literal.- Value semantics — assigning or passing an array copies every element. For large data this is expensive.
len(a)— returns the array length (always a compile-time constant for arrays).
💡 Tip: You will almost always reach for a slice instead of an array. Use arrays only when the size is genuinely fixed and known — like a 16-byte hash or an RGBA pixel
[4]uint8.
A slice is a flexible, growable view into an underlying array. It is the workhorse collection of Go. A slice is a small header holding three things: a pointer to the backing array, a length, and a capacity.
// Create slices
s := []int{1, 2, 3} // slice literal
t := make([]int, 3) // length 3, capacity 3 -> [0 0 0]
u := make([]int, 0, 10) // length 0, capacity 10 (pre-allocated)
fmt.Println(len(s), cap(s)) // 3 3
// append grows the slice, reallocating when capacity runs out
s = append(s, 4, 5) // [1 2 3 4 5]
s = append(s, t...) // append another slice with ...
// Slicing: s[low:high] — includes low, excludes high
nums := []int{0, 1, 2, 3, 4, 5}
fmt.Println(nums[1:4]) // [1 2 3]
fmt.Println(nums[:3]) // [0 1 2]
fmt.Println(nums[3:]) // [3 4 5]
// Sub-slices SHARE the same backing array!
view := nums[1:3]
view[0] = 99
fmt.Println(nums) // [0 99 2 3 4 5] — original changed!
// Safe copy into a new backing array
dst := make([]int, len(nums))
copy(dst, nums)
// Remove element at index i (order not preserved is faster)
i := 2
nums = append(nums[:i], nums[i+1:]...)
Key points
make([]T, len, cap)— pre-allocate capacity to avoid repeated re-allocations in a loop.append— returns a (possibly new) slice; always assign the result back:s = append(s, x).- Shared backing array — sub-slices alias the same memory. Mutating one can affect the other.
copy(dst, src)— the safe way to get an independent copy of the data.
⚠️ Warning: Because slices share a backing array,
b := a[1:3]followed by writes tobcan silently changea. When you need isolation,copyinto a fresh slice.
A map is Go's built-in hash table — an unordered collection of key/value pairs with fast lookup, insert, and delete. Keys must be comparable (numbers, strings, booleans, structs of comparable fields); slices and other maps cannot be keys.
// Create a map
ages := map[string]int{
"Alice": 30,
"Bob": 25,
}
empty := make(map[string]int) // ready to use
// Insert / update / read
empty["Carol"] = 41
fmt.Println(ages["Alice"]) // 30
// Reading a MISSING key returns the value type's zero value
fmt.Println(ages["Zoe"]) // 0 — not an error!
// The comma-ok idiom distinguishes "missing" from "zero"
age, ok := ages["Zoe"]
if !ok {
fmt.Println("Zoe is not in the map")
}
// Delete a key (safe even if absent)
delete(ages, "Bob")
// Length and iteration (order is RANDOM)
fmt.Println(len(ages))
for name, age := range ages {
fmt.Printf("%s: %d\n", name, age)
}
// A nil map can be READ but panics on WRITE
var m map[string]int // nil
fmt.Println(m["x"]) // 0 — ok
// m["x"] = 1 // PANIC: assignment to entry in nil map
Key points
map[K]V—Kmust be comparable;Vcan be any type, including structs or slices.- Comma-ok —
v, ok := m[k]tells you whether the key actually exists. delete(m, k)— removes a key; it is a no-op if the key is absent.- Nil maps — reading is safe and returns the zero value, but writing panics. Use
makeor a literal before writing.
💡 Tip: To iterate a map in a predictable order, collect the keys into a slice,
sort.Strings(keys), then range over the sorted keys.
A Go string is an immutable, read-only sequence of bytes — usually UTF-8 encoded text. Indexing a string gives you a byte (uint8), while ranging over it gives you runes (Unicode code points). Knowing the difference prevents a whole class of bugs with non-ASCII text.
s := "Héllo, 世界"
// len() counts BYTES, not characters
fmt.Println(len(s)) // 13 bytes
// Indexing gives a byte
fmt.Println(s[0]) // 72 (the byte 'H')
// range decodes UTF-8 into runes (code points) + their byte offset
for i, r := range s {
fmt.Printf("%d:%c ", i, r)
}
// 0:H 1:é 3:l 4:l 5:o ... offsets skip multi-byte chars
// Count actual characters with utf8 or []rune
import "unicode/utf8"
fmt.Println(utf8.RuneCountInString(s)) // 9 characters
fmt.Println(len([]rune(s))) // 9 characters
// The strings package — the everyday text toolbox
import "strings"
strings.ToUpper("go") // "GO"
strings.Contains("golang", "go") // true
strings.Split("a,b,c", ",") // ["a" "b" "c"]
strings.TrimSpace(" hi ") // "hi"
strings.ReplaceAll("aaa", "a", "b") // "bbb"
// Build strings efficiently with strings.Builder (no repeated allocs)
var b strings.Builder
for i := 0; i < 3; i++ {
b.WriteString("go")
}
fmt.Println(b.String()) // "gogogo"
Key points
- byte vs rune —
s[i]is abyte;for _, r := range syieldsrune(a Unicode code point). len(s)— counts bytes. Useutf8.RuneCountInStringfor character counts.stringspackage —Contains,Split,Join,TrimSpace,ToUpper,ReplaceAll, and friends.strings.Builder— concatenating with+in a loop allocates each time; a Builder is far more efficient.
⚠️ Warning: Strings are immutable. To modify text, convert to
[]byteor[]rune, change it, then convert back — or build a new string withstrings.Builder.
Error Handling
Go has no exceptions for ordinary failures. Instead, functions return an error value as their last result, and the caller checks it. error is just a built-in interface with a single method:
type error interface {
Error() string
}
import (
"errors"
"fmt"
)
// Return an error as the last value; nil means success
func sqrt(x float64) (float64, error) {
if x < 0 {
return 0, errors.New("cannot sqrt a negative number")
}
return math.Sqrt(x), nil
}
// The canonical call-and-check pattern
result, err := sqrt(-4)
if err != nil {
fmt.Println("error:", err) // error: cannot sqrt a negative number
return
}
fmt.Println(result)
// errors.New for a fixed message
err1 := errors.New("file not found")
// fmt.Errorf for a formatted message
name := "config.yaml"
err2 := fmt.Errorf("could not open %s", name)
Key points
erroris an interface — any type with anError() stringmethod is an error.- Last return value — by strong convention, the error is the final value a function returns.
nilmeans success — always compare withif err != nil.errors.New/fmt.Errorf— the two everyday ways to create errors; useErrorfwhen you need formatting.
💡 Tip: Handle each error where it happens — log it, return it, or recover from it. Silently discarding errors with
_is a common source of hard-to-find bugs.
As an error travels up the call stack, each layer can add context while preserving the original. Wrap with the %w verb in fmt.Errorf, then inspect the chain with errors.Is and errors.As.
import "errors"
// Sentinel error — a known, comparable value to test against
var ErrNotFound = errors.New("not found")
func lookup(id int) error {
// ... wrap the sentinel with extra context using %w
return fmt.Errorf("lookup user %d: %w", id, ErrNotFound)
}
err := lookup(42)
fmt.Println(err) // lookup user 42: not found
// errors.Is — does the chain contain this sentinel?
if errors.Is(err, ErrNotFound) {
fmt.Println("handle the not-found case")
}
// errors.As — extract a specific error TYPE from the chain
var perr *os.PathError
if errors.As(err, &perr) {
fmt.Println("path was:", perr.Path)
}
// errors.Unwrap returns the next error in the chain (or nil)
inner := errors.Unwrap(err)
Key points
%w—fmt.Errorf("context: %w", err)wrapserrso it stays inspectable. Use%vinstead when you deliberately want to hide the original.- Sentinel errors — package-level values like
io.EOForsql.ErrNoRowsyou compare against. errors.Is(err, target)— checks whethertargetappears anywhere in the wrapped chain.errors.As(err, &target)— finds the first error of a matching type and assigns it for field access.
⚠️ Warning: Don't compare error messages with
==on strings — they change. Use sentinel values witherrors.Is, or custom types witherrors.As.
When you need errors that carry structured data — a status code, a field name, the offending value — define your own error type. Any type implementing Error() string satisfies the error interface.
// A custom error type with extra fields
type ValidationError struct {
Field string
Value any
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("invalid value %v for field %q", e.Value, e.Field)
}
func validateAge(age int) error {
if age < 0 || age > 150 {
return &ValidationError{Field: "age", Value: age}
}
return nil
}
err := validateAge(200)
// Recover the rich type with errors.As to read its fields
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Println("bad field:", ve.Field) // bad field: age
}
// You can also wrap an underlying cause inside a custom type
type QueryError struct {
Query string
Err error
}
func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
func (e *QueryError) Unwrap() error { return e.Err } // makes errors.Is/As work through it
Key points
- Implement
Error() string— that one method is all it takes to become anerror. - Pointer receiver — custom error types usually use
*Tso identity anderrors.Aswork cleanly. - Add an
Unwrap() errormethod — letserrors.Isanderrors.Assee through your type to the cause. - Carry data, not just text — fields like
Field,Code, orRetryablelet callers react programmatically.
💡 Tip: Name error types with an
Errorsuffix (ValidationError) and sentinel variables with anErrprefix (ErrNotFound). This is the convention the standard library follows.
panic stops normal execution and starts unwinding the stack, running deferred functions on the way. recover (only useful inside a defer) stops that unwinding and lets the program continue. Panics are for truly exceptional, unrecoverable situations — not ordinary errors.
// A panic crashes the goroutine unless recovered
func mustPositive(n int) {
if n <= 0 {
panic(fmt.Sprintf("expected positive, got %d", n))
}
}
// recover turns a panic into a normal error inside a deferred function
func safeDivide(a, b int) (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil // dividing by zero panics; we catch it
}
res, err := safeDivide(10, 0)
fmt.Println(res, err) // 0 recovered: runtime error: integer divide by zero
// Built-in runtime panics you will eventually hit:
// - index out of range
// - nil map assignment / nil pointer dereference
// - type assertion failure (single-value form)
Key points
panic(v)— unwinds the stack, running deferred calls, then crashes the program if never recovered.recover()— only stops a panic when called directly inside a deferred function; returns the panic value ornil.- Prefer errors — return
errorfor anything a caller might reasonably handle. Reservepanicfor programmer mistakes and impossible states. - Recover at boundaries — e.g. an HTTP server recovers per-request so one bad handler doesn't kill the whole process.
⚠️ Warning: Don't use panic/recover as exception-style control flow. Idiomatic Go returns errors; a panic that escapes a goroutine takes down the entire program.
Packages, Modules & the Standard Library
A package is Go's unit of code organization. Every .go file declares the package it belongs to, and all files in one directory form a single package. Exported identifiers start with an uppercase letter; everything lowercase is private to the package.
// file: mathutil/mathutil.go
package mathutil
// Add is EXPORTED (uppercase) — usable from other packages
func Add(a, b int) int {
return a + b
}
// helper is unexported (lowercase) — private to this package
func helper() {}
// file: main.go
package main
import (
"fmt"
"github.com/you/proj/mathutil" // import path = module path + folder
)
func main() {
fmt.Println(mathutil.Add(2, 3)) // refer to exports as package.Name
}
// init() runs automatically once, before main, after package vars are set
func init() {
fmt.Println("package initialising...")
}
// Import only for side effects (its init runs; no names used)
import _ "github.com/lib/pq"
Key points
- One directory = one package — all files share the same
packagename. - Capitalization is visibility —
Uppercaseis exported (public);lowercaseis package-private. - Qualified access — call exported names as
package.Name(e.g.strings.ToUpper). init()— optional setup function run automatically beforemain; a file may have several.
💡 Tip: Keep packages small and focused around a single responsibility, and name them after what they provide (
http,json,mathutil) — not generic names likeutilsorcommon.
A module is a collection of packages versioned together, defined by a go.mod file at its root. Modules are how Go manages dependencies and versions since Go 1.16.
# Start a new module — the path is its globally unique identity
go mod init github.com/you/myapp
# Add or upgrade a dependency
go get github.com/google/[email protected]
go get -u ./... # upgrade everything to latest minor/patch
# Sync go.mod/go.sum with the code's actual imports
go mod tidy
# Build, run, test
go build ./...
go run .
go test ./...
go.mod
---------------------------------
module github.com/you/myapp
go 1.22
require (
github.com/google/uuid v1.6.0
golang.org/x/text v0.14.0 // indirect
)
Key points
go.mod— declares the module path, the Go version, and direct/indirect dependencies.go.sum— cryptographic checksums that lock dependencies for reproducible, verifiable builds. Never edit by hand.- Semantic versioning — dependencies are pinned like
v1.6.0. Major versionv2+becomes part of the import path. go mod tidy— adds anything you import and removes anything you don't. Run it before committing.
💡 Tip: Commit both
go.modandgo.sum. Together they guarantee that everyone — and your CI — builds with exactly the same dependency versions.
Go ships with a famously rich standard library that covers most everyday needs with zero external dependencies. A few you will use constantly:
import (
"fmt" // formatted I/O
"strings" // string manipulation
"strconv" // string <-> number conversion
"sort" // sorting slices
"time" // dates, durations, timers
"os" // files, args, environment, exit
)
// sort
nums := []int{3, 1, 2}
sort.Ints(nums) // [1 2 3]
sort.Slice(nums, func(i, j int) bool { return nums[i] > nums[j] }) // custom
// time
start := time.Now()
time.Sleep(50 * time.Millisecond)
fmt.Println(time.Since(start)) // ~50ms
deadline := time.Now().Add(24 * time.Hour)
// strconv
n, _ := strconv.Atoi("42") // string -> int
s := strconv.Itoa(42) // int -> string
// os — program arguments and environment
fmt.Println(os.Args) // [program arg1 arg2 ...]
home := os.Getenv("HOME")
if home == "" { os.Exit(1) }
Common packages worth knowing
fmt,strings,strconv— formatting, text, and conversions.sort,slices,maps— sorting and generic slice/map helpers (Go 1.21+).time—Now,Since,Duration, timers, and formatting.os,io,bufio— files, standard streams, buffered I/O.encoding/json,net/http— JSON encoding and HTTP clients/servers, all built in.
💡 Tip: Read the docs with the command line:
go doc stringslists a package's API, andgo doc strings.Splitshows one symbol. Or browse everything at pkg.go.dev.
When the standard library isn't enough, the Go ecosystem on pkg.go.dev has packages for almost everything. Adding one is just an import plus go mod tidy.
# 1. Add the dependency
go get github.com/google/uuid
// 2. Import and use it
package main
import (
"fmt"
"github.com/google/uuid"
)
func main() {
id := uuid.New()
fmt.Println(id.String()) // e.g. 9b2e... a random UUIDv4
}
# 3. Keep things tidy and inspect what you depend on
go mod tidy
go list -m all # full dependency graph
go doc github.com/google/uuid # read its API locally
Key points
- Import path = location —
github.com/google/uuidtells the toolchain exactly where to fetch the code. go get pkg@version— pin a specific version, a branch, or@latest.- Vet your dependencies — check the license, recent activity, star count, and open issues before adding.
- Fewer is better — every dependency is code you now rely on. Prefer the standard library when it suffices.
⚠️ Warning: Each dependency expands your trust and maintenance surface. Audit new packages, keep them updated for security fixes, and avoid pulling in a large library for a one-line helper.
Concurrency in Depth
Channels are typed pipes that let goroutines communicate safely. You met them briefly with goroutines — here we go deeper: closing, ranging, directions, and buffering.
// Closing a channel signals "no more values will be sent"
func producer(ch chan<- int) { // chan<- = send-only
for i := 0; i < 3; i++ {
ch <- i
}
close(ch) // important: the sender closes, never the receiver
}
func main() {
ch := make(chan int)
go producer(ch)
// range over a channel receives until it is closed
for v := range ch {
fmt.Println(v) // 0 1 2
}
// The comma-ok receive detects a closed channel
v, ok := <-ch
fmt.Println(v, ok) // 0 false (zero value, channel closed)
}
// Buffered vs unbuffered
unbuf := make(chan int) // send blocks until a receiver is ready
buf := make(chan int, 2) // send blocks only when 2 items are buffered
buf <- 1
buf <- 2 // still fine; a third send would block
Key points
close(ch)— only the sender closes a channel; sending on a closed channel panics.for v := range ch— receives values until the channel is closed and drained.v, ok := <-ch—ok == falsemeans the channel is closed and empty.- Directional types —
chan<- T(send-only) and<-chan T(receive-only) document and enforce intent in function signatures.
⚠️ Warning: Closing a channel twice, or sending on a closed channel, panics. Receiving from a closed channel is always safe and returns the zero value.
The select statement waits on multiple channel operations at once, proceeding with whichever is ready first. It is the heart of real concurrent Go — multiplexing, timeouts, and cancellation all build on it.
import "time"
func main() {
c1 := make(chan string)
c2 := make(chan string)
go func() { time.Sleep(100 * time.Millisecond); c1 <- "one" }()
go func() { time.Sleep(200 * time.Millisecond); c2 <- "two" }()
for i := 0; i < 2; i++ {
select {
case msg := <-c1:
fmt.Println("received", msg)
case msg := <-c2:
fmt.Println("received", msg)
}
}
}
// Timeout pattern with time.After
select {
case res := <-work:
fmt.Println("got", res)
case <-time.After(2 * time.Second):
fmt.Println("timed out")
}
// Non-blocking send/receive with default
select {
case v := <-ch:
fmt.Println("value:", v)
default:
fmt.Println("nothing ready right now")
}
Key points
- First ready wins —
selectblocks until one case can proceed; if several are ready, one is chosen at random. time.After— returns a channel that fires after a duration — the idiomatic timeout.default— makes aselectnon-blocking: it runs if no other case is ready.- Loop + select — the common shape of servers, workers, and event loops.
💡 Tip: A
selectwith no cases,select {}, blocks forever. Combined with goroutines it is sometimes used to keepmainalive while background work runs.
Channels are the preferred way to coordinate, but sometimes you just need to protect shared state. The sync package provides classic primitives — and the race detector helps you find bugs when you get it wrong.
import "sync"
// Mutex — mutual exclusion around shared data
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // always unlock, even on early return/panic
c.count++
}
// WaitGroup — wait for a set of goroutines to finish
var wg sync.WaitGroup
counter := &Counter{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter.Inc()
}()
}
wg.Wait()
fmt.Println(counter.count) // 1000 — correct, thanks to the mutex
// sync.Once — run something exactly once (e.g. lazy init)
var once sync.Once
once.Do(func() { fmt.Println("initialised") })
// sync.RWMutex — many readers OR one writer (RLock / Lock)
Key points
sync.Mutex—Lock/Unlockaround critical sections; pairUnlockwithdefer.sync.RWMutex— allows concurrent readers but exclusive writers; great for read-heavy data.sync.WaitGroup—Add,Done,Waitto coordinate completion.sync.Once— guarantees a function runs only once across all goroutines.
⚠️ Warning: Run your tests with
go test -race(and apps withgo run -race). The race detector catches unsynchronised concurrent access that is otherwise invisible until it corrupts data in production.
context.Context carries cancellation signals, deadlines, and request-scoped values across API boundaries and goroutines. It is the standard way to stop work that is no longer needed — the first parameter of nearly every Go I/O function.
import (
"context"
"time"
)
// Cancel work after a timeout
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel() // always call cancel to release resources
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done(): // fires on timeout OR explicit cancel()
fmt.Println("stopping:", ctx.Err())
return
default:
// ... do a unit of work
}
}
}
go worker(ctx)
// Worker pool — fan out jobs to N goroutines, collect results
func pool(jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for j := range jobs {
results <- j * j // process each job
}
}
// launch several `pool` goroutines reading the same jobs channel
Key points
context.Background()— the empty root context; derive others from it.WithCancel/WithTimeout/WithDeadline— produce a child context plus acancelfunction. Alwaysdefer cancel().<-ctx.Done()— a channel that closes when the context is cancelled or expires;ctx.Err()says why.- Worker pool — N goroutines reading from one jobs channel is the idiomatic way to bound concurrency.
💡 Tip: Pass
ctx context.Contextas the first argument to functions that do I/O or long work, and respect it. Never store a Context in a struct — pass it explicitly down the call chain.
Testing Your Code
Testing is built into Go — no framework required. Put tests in a file ending in _test.go, write functions named TestXxx taking *testing.T, and run them with go test.
// file: mathutil/mathutil.go
package mathutil
func Add(a, b int) int { return a + b }
// file: mathutil/mathutil_test.go
package mathutil
import "testing"
func TestAdd(t *testing.T) {
got := Add(2, 3)
want := 5
if got != want {
t.Errorf("Add(2, 3) = %d; want %d", got, want)
}
}
go test # run tests in the current package
go test ./... # run every package in the module
go test -v # verbose: show each test as it runs
go test -run TestAdd # run only tests matching a pattern
Key points
_test.go— test files are excluded from normal builds and live beside the code they test.func TestXxx(t *testing.T)— the name must start withTestand take a*testing.T.t.Errorfvst.Fatalf—Errorfreports a failure but continues;Fatalfstops the test immediately.go test— discovers and runs everything automatically; exit code is non-zero on failure (perfect for CI).
💡 Tip: Write the want vs got message so a failure tells you the input, the actual result, and the expected result — you should be able to debug from the failure line alone.
The most idiomatic Go testing style packs many cases into a table (a slice of structs) and loops over them, running each as a named subtest with t.Run. Adding a case is a one-line edit.
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
want int
}{
{"both positive", 2, 3, 5},
{"with zero", 0, 7, 7},
{"negatives", -2, -3, -5},
{"mixed signs", -5, 10, 5},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { // each case is an isolated subtest
got := Add(tc.a, tc.b)
if got != tc.want {
t.Errorf("Add(%d, %d) = %d; want %d",
tc.a, tc.b, got, tc.want)
}
})
}
}
# Run just one subtest by name
go test -run TestAdd/negatives -v
Key points
- Table — a slice of anonymous structs, one row per case, each with a descriptive
name. t.Run(name, fn)— runs a named subtest; failures report the exact case, and you can run one in isolation.- Easy to extend — covering a new edge case is just another row in the table.
- Helpers — call
t.Helper()inside shared assertion functions so failures point at the caller, not the helper.
💡 Tip: Table-driven tests are the Go community standard. They keep tests compact, readable, and exhaustive — favour them over many near-identical
TestXxxfunctions.
The same tooling measures performance and documents usage. Benchmarks (BenchmarkXxx) report nanoseconds and allocations per operation; examples (ExampleXxx) double as runnable, verified documentation.
import "testing"
// Benchmark — the framework chooses b.N to get a stable measurement
func BenchmarkAdd(b *testing.B) {
for i := 0; i < b.N; i++ {
Add(2, 3)
}
}
go test -bench=. # run all benchmarks
go test -bench=. -benchmem # include allocations per op
# BenchmarkAdd-8 1000000000 0.31 ns/op 0 B/op 0 allocs/op
// Example — the // Output: comment is checked by `go test`
func ExampleAdd() {
fmt.Println(Add(2, 3))
// Output: 5
}
Key points
func BenchmarkXxx(b *testing.B)— loop exactlyb.Ntimes; the framework scalesb.Nuntil the timing is reliable.-bench=. -benchmem— run benchmarks and report bytes and allocations per operation.func ExampleXxx()— with an// Output:comment, it is both verified bygo testand shown on pkg.go.dev.b.ResetTimer()— call it after expensive setup so the timer measures only the work you care about.
💡 Tip: Examples are the best documentation there is — they appear in your generated docs and fail the build if they ever go out of date, so they can never lie.
Beyond running tests, the Go toolchain measures coverage, finds suspicious code with go vet, and catches data races with the race detector — all built in.
# Coverage — what fraction of statements your tests exercise
go test -cover
go test -coverprofile=cover.out
go tool cover -html=cover.out # open an annotated, colour-coded report
# Static analysis — flags likely mistakes (bad Printf verbs, etc.)
go vet ./...
# Race detector — finds unsynchronised concurrent access
go test -race ./...
# Formatting — the canonical style, enforced by tooling
gofmt -w .
// Setup/teardown for a whole package via TestMain
func TestMain(m *testing.M) {
// ... global setup (e.g. spin up a test database)
code := m.Run() // runs all tests in the package
// ... global teardown
os.Exit(code)
}
Key points
go test -cover— prints the percentage of code your tests run;-coverprofile+go tool cover -htmlgives a visual report.go vet— static checks that catch real bugs the compiler allows (mismatched format verbs, lost struct tags, unreachable code).go test -race— instruments the binary to detect concurrent read/write races; run it in CI.TestMain— optional entry point for package-wide setup and teardown aroundm.Run().
💡 Tip: Aim for meaningful coverage of important logic, not a 100% number. A handful of focused table-driven tests on tricky code is worth far more than trivial tests that only inflate the percentage.
Pointers & Memory
A pointer holds the memory address of a value rather than the value itself. The & operator takes the address of a variable; the * operator dereferences a pointer to read or write the value it points at.
x := 10
p := &x // p is a *int — the address of x
fmt.Println(p) // 0xc000012345 (some address)
fmt.Println(*p) // 10 — dereference to read the value
*p = 20 // write through the pointer
fmt.Println(x) // 20 — x changed!
// The zero value of any pointer is nil
var q *int
fmt.Println(q == nil) // true
// Pointer to a struct — Go auto-dereferences field access
type Point struct{ X, Y int }
pt := &Point{1, 2}
pt.X = 99 // shorthand for (*pt).X = 99
fmt.Println(pt.X) // 99
Key points
&x— the address-of operator; gives you a pointer tox.*p— the dereference operator; reads or writes the value at the pointer.*int,*Point— pointer types; the*in a type means "pointer to".- Auto-deref on structs —
pt.Xworks whetherptis aPointor a*Point; Go inserts the*for you.
💡 Tip: Unlike C, Go has no pointer arithmetic — you can't do
p++to walk through memory. This removes a whole category of bugs and makes pointers much safer to use.
Go passes everything by value — a function receives a copy of its arguments. To let a function modify the caller's data, pass a pointer. This is exactly why mutating methods use pointer receivers.
// Value parameter — works on a copy, caller is unaffected
func doubleValue(n int) {
n *= 2 // changes the local copy only
}
// Pointer parameter — works on the original
func doublePointer(n *int) {
*n *= 2
}
x := 5
doubleValue(x)
fmt.Println(x) // 5 — unchanged
doublePointer(&x)
fmt.Println(x) // 10 — modified through the pointer
// Same idea with structs
type Counter struct{ n int }
func (c Counter) IncCopy() { c.n++ } // value receiver: no effect outside
func (c *Counter) Inc() { c.n++ } // pointer receiver: mutates original
c := Counter{}
c.IncCopy()
fmt.Println(c.n) // 0
c.Inc()
fmt.Println(c.n) // 1
Key points
- Pass by value — function arguments are copied; modifications stay local unless you pass a pointer.
- Pointer to mutate — use
*Tparameters or pointer receivers when a function must change the caller's data. - Big structs — passing a pointer also avoids copying a large struct on every call.
- Slices, maps, channels — these are reference-like already; a copy shares the same underlying data.
⚠️ Warning: A value receiver method can never change the original struct — it edits a copy. If a method should modify state, it must use a pointer receiver (
func (c *Counter)).
Go has two built-in allocation functions that beginners often confuse. new(T) allocates zeroed storage and returns a *T. make initializes the three built-in reference types — slices, maps, and channels — and returns a ready-to-use value (not a pointer).
// new(T) — allocate zeroed memory, get a pointer
p := new(int) // p is *int pointing at 0
*p = 42
fmt.Println(*p) // 42
s := new(Point) // s is *Point -> &Point{0, 0}
// make — only for slices, maps, channels
sl := make([]int, 0, 8) // slice: len 0, cap 8
m := make(map[string]int) // map ready for writes
ch := make(chan int, 4) // buffered channel
// make returns the value itself, NOT a pointer
fmt.Printf("%T %T %T\n", sl, m, ch) // []int map[string]int chan int
// A nil map/slice from `var` is NOT ready for some uses
var bad map[string]int
// bad["x"] = 1 // PANIC: assignment to nil map
good := make(map[string]int)
good["x"] = 1 // fine
Key points
new(T)— works for any type, returns*Tpointing at a zeroed value. Rarely needed for structs since&T{}is clearer.make— only for slices, maps, and channels; sets up their internal structure so they're usable.makereturns the value — not a pointer, because these types are already reference-like.&T{}overnew— for structs,p := &Point{1, 2}is the idiomatic, readable choice.
💡 Tip: Remember the rule of thumb:
makefor the three built-in collection types,&T{}(ornew) for everything else.
nil is the zero value for pointers, slices, maps, channels, interfaces, and function values. Reading through a nil pointer causes a runtime panic, so handling nil correctly is essential.
// Nil pointer dereference panics
var p *int
// fmt.Println(*p) // panic: runtime error: invalid memory address
// Guard before dereferencing
if p != nil {
fmt.Println(*p)
}
// Functions often return *T that may be nil — check it
func find(id int) *Point {
if id == 0 {
return nil // "not found"
}
return &Point{id, id}
}
if pt := find(0); pt != nil {
fmt.Println(pt.X)
} else {
fmt.Println("not found")
}
// nil slices and maps behave gracefully for reads
var s []int
fmt.Println(len(s)) // 0
s = append(s, 1) // append works on a nil slice!
var m map[string]int
fmt.Println(m["x"]) // 0 — reading a nil map is fine
Key points
- Nil pointer deref panics — always check
if p != nilbefore*pwhen a pointer might be unset. nilas "absent" — returning*Tofnilis a common way to signal "no result".- Nil slices read fine —
len,range, and evenappendwork on anilslice. - Nil maps: read OK, write panics — initialize a map with
make(or a literal) before writing.
⚠️ Warning: "invalid memory address or nil pointer dereference" is one of the most common Go runtime panics. When a function returns a pointer that can be
nil, check it before using it.
Generics
Since Go 1.18, functions and types can take type parameters — placeholders for types supplied by the caller. Generics let you write one implementation that works for many types, without giving up compile-time type safety or resorting to interface{}.
// Before generics: one function per type, or unsafe interface{}
func maxInt(a, b int) int { if a > b { return a }; return b }
func maxFloat(a, b float64) float64 { if a > b { return a }; return b }
// With generics: a single, type-safe function
func Max[T int | float64](a, b T) T {
if a > b {
return a
}
return b
}
fmt.Println(Max(3, 7)) // 7 — T inferred as int
fmt.Println(Max(2.5, 1.5)) // 2.5 — T inferred as float64
// You can name the type explicitly, but inference usually handles it
fmt.Println(Max[int](10, 4)) // 10
Key points
[T ...]— type parameters go in square brackets after the function/type name.T— a stand-in type, usable for parameters, returns, and locals inside the function.int | float64— a constraint listing which typesTis allowed to be.- Type inference — the compiler usually figures out
Tfrom the arguments, so calls look ordinary.
💡 Tip: Reach for generics only when you genuinely have the same logic repeated across types (containers, utility functions). For varied behaviour, interfaces are still the better tool.
A constraint is an interface that limits which types a type parameter accepts and tells the compiler what operations are allowed. Go provides built-in constraints and the handy golang.org/x/exp/constraints package (now partly in the stdlib via cmp).
// `any` — no restriction (alias for interface{})
func First[T any](s []T) T { return s[0] }
// `comparable` — types usable with == and != (map keys, dedup, etc.)
func Contains[T comparable](s []T, target T) bool {
for _, v := range s {
if v == target {
return true
}
}
return false
}
fmt.Println(Contains([]string{"a", "b"}, "b")) // true
// A custom constraint via a union of types
type Number interface {
~int | ~int64 | ~float64 // ~ also allows types DEFINED from these
}
func Sum[T Number](nums []T) T {
var total T // zero value of T
for _, n := range nums {
total += n // + is allowed because the constraint guarantees it
}
return total
}
fmt.Println(Sum([]int{1, 2, 3})) // 6
fmt.Println(Sum([]float64{1.5, 2.5})) // 4
Key points
any— accepts every type; you can only do type-agnostic things (assign, pass around).comparable— allows==/!=; required for map keys and equality checks.- Union constraints —
int | float64lists permitted types and unlocks operators like+and>. ~int— the tilde also admits named types whose underlying type isint(e.g.type Celsius float64).
💡 Tip: The standard library's
cmp.Orderedconstraint (Go 1.21+) covers all types that support<,>, etc. — perfect for genericMax,Min, and sorting helpers.
Generic functions shine for collection utilities — the kind of map, filter, and keys helpers other languages give you for free. Go 1.21 even ships some of these in the slices and maps packages.
// Map: transform each element with a function
func Map[T, U any](s []T, f func(T) U) []U {
out := make([]U, len(s))
for i, v := range s {
out[i] = f(v)
}
return out
}
nums := []int{1, 2, 3}
strs := Map(nums, func(n int) string {
return fmt.Sprintf("#%d", n)
})
fmt.Println(strs) // [#1 #2 #3]
// Filter: keep elements that satisfy a predicate
func Filter[T any](s []T, keep func(T) bool) []T {
var out []T
for _, v := range s {
if keep(v) {
out = append(out, v)
}
}
return out
}
evens := Filter(nums, func(n int) bool { return n%2 == 0 })
fmt.Println(evens) // [2]
// The stdlib already has many helpers (Go 1.21+)
import "slices"
fmt.Println(slices.Contains(nums, 2)) // true
fmt.Println(slices.Max(nums)) // 3
Key points
- Multiple type params —
[T, U any]lets input and output types differ, as inMap. - Functions as parameters — generics compose beautifully with higher-order functions.
slices/mapspackages —slices.Contains,slices.Sort,maps.Keys, and more are built in (Go 1.21+).- Type-safe, no casting — unlike
interface{}, the compiler verifies everything; no runtime type assertions.
💡 Tip: Before writing your own generic helper, check the
slicesandmapspackages — much of what you'd build is already there, tested and optimized.
Structs can be generic too, letting you build reusable, type-safe data structures — a stack, queue, set, or cache that works for any element type.
// A generic stack
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) Push(v T) {
s.items = append(s.items, v)
}
func (s *Stack[T]) Pop() (T, bool) {
var zero T
if len(s.items) == 0 {
return zero, false
}
last := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return last, true
}
func main() {
var s Stack[string] // a stack of strings
s.Push("a")
s.Push("b")
v, ok := s.Pop()
fmt.Println(v, ok) // b true
var nums Stack[int] // a stack of ints — same code!
nums.Push(42)
}
// A generic Set built on a map
type Set[T comparable] map[T]struct{}
func (set Set[T]) Add(v T) { set[v] = struct{}{} }
func (set Set[T]) Has(v T) bool { _, ok := set[v]; return ok }
Key points
type Stack[T any] struct— the type parameter follows the type name and is usable in fields.- Methods reuse the param — write the receiver as
(s *Stack[T]); you don't re-declare the constraint. var zero T— the idiomatic way to get the zero value of an unknown type parameter.struct{}values — a set is amap[T]struct{}; empty structs use zero memory.
⚠️ Warning: Generics add power but also complexity. If a plain slice, map, or interface does the job clearly, prefer it — generics are a tool for genuine reuse, not a default.
Working with JSON & Files
JSON is the lingua franca of APIs and config files. The encoding/json package converts Go values to JSON (Marshal) and back (Unmarshal) using reflection over struct fields.
import "encoding/json"
type User struct {
Name string
Email string
Age int
}
// Marshal: Go value -> JSON bytes
u := User{Name: "Alice", Email: "[email protected]", Age: 30}
data, err := json.Marshal(u)
if err != nil { /* handle */ }
fmt.Println(string(data))
// {"Name":"Alice","Email":"[email protected]","Age":30}
// MarshalIndent for human-readable output
pretty, _ := json.MarshalIndent(u, "", " ")
fmt.Println(string(pretty))
// Unmarshal: JSON bytes -> Go value (pass a POINTER)
input := []byte(`{"Name":"Bob","Email":"[email protected]","Age":25}`)
var parsed User
if err := json.Unmarshal(input, &parsed); err != nil {
fmt.Println("bad json:", err)
}
fmt.Println(parsed.Name) // Bob
// For unknown shapes, decode into a map or any
var generic map[string]any
json.Unmarshal(input, &generic)
Key points
json.Marshal(v)— returns the JSON[]byte; only exported (uppercase) fields are included.json.Unmarshal(data, &v)— decodes intov; you must pass a pointer so it can write the result.MarshalIndent— pretty-prints with indentation for logs and config files.map[string]any— decode here when the JSON structure isn't known ahead of time.
⚠️ Warning: Only exported fields are marshalled. A lowercase field like
password stringis silently skipped — both when encoding and decoding.
Real-world JSON rarely uses Go's capitalized field names. Struct tags — backtick strings after a field — let you control the JSON key, omit empty values, and skip fields entirely.
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Price float64 `json:"price"`
Notes string `json:"notes,omitempty"` // dropped if empty
internal string `json:"-"` // never serialized
SKU string `json:"sku,omitempty"`
}
p := Product{ID: 1, Name: "Pen", Price: 2.5}
data, _ := json.Marshal(p)
fmt.Println(string(data))
// {"id":1,"name":"Pen","price":2.5}
// note: notes and sku omitted (empty), internal never appears
// Decoding respects the same tags
in := []byte(`{"id":2,"name":"Mug","price":9.99}`)
var prod Product
json.Unmarshal(in, &prod)
fmt.Println(prod.Name) // Mug
// Nested structs and slices just work
type Order struct {
OrderID string `json:"order_id"`
Items []Product `json:"items"`
}
Key points
\json:"name"`` — maps the Go field to a custom JSON key.,omitempty— leaves the field out of the output when it holds the zero value.json:"-"— excludes a field from JSON entirely (e.g. passwords, internal state).- Nesting works — structs, slices, and maps marshal recursively with no extra effort.
💡 Tip: Always add explicit
json:tags on types that cross an API boundary. It decouples your Go field names from the wire format, so you can rename fields freely without breaking clients.
The os package handles files. For whole-file work, os.ReadFile and os.WriteFile are one-liners; for large files or streaming, open a handle and read incrementally.
import "os"
// Write an entire file (creates or truncates); 0644 = rw-r--r--
content := []byte("Hello, file!\n")
if err := os.WriteFile("greeting.txt", content, 0644); err != nil {
panic(err)
}
// Read an entire file into memory
data, err := os.ReadFile("greeting.txt")
if err != nil {
fmt.Println("read error:", err)
return
}
fmt.Print(string(data)) // Hello, file!
// Open a handle for more control — always defer Close()
f, err := os.Open("greeting.txt")
if err != nil { return }
defer f.Close()
// Create/append
out, _ := os.OpenFile("log.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
defer out.Close()
out.WriteString("appended line\n")
// Combine with JSON: persist a struct to disk
u := User{Name: "Alice"}
buf, _ := json.MarshalIndent(u, "", " ")
os.WriteFile("user.json", buf, 0644)
Key points
os.ReadFile/os.WriteFile— read or write a whole file in one call; great for small files and config.os.Open/os.Create— return a*os.Filehandle for streaming or fine-grained control.defer f.Close()— close every handle you open; defer guarantees it even on early return.- File permissions — the
0644mode (octal) means owner read/write, others read-only.
💡 Tip:
os.ReadFileloads the entire file into memory. For very large files, read in chunks with abufio.Scannerorio.Copyinstead of slurping it all at once.
The bufio package wraps readers and writers with a buffer, making line-by-line reading and efficient writing easy. bufio.Scanner is the idiomatic way to process input one line (or word) at a time.
import (
"bufio"
"fmt"
"os"
"strings"
)
// Read a file line by line — constant memory, any file size
f, _ := os.Open("big.txt")
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text() // current line, without the newline
fmt.Println(strings.ToUpper(line))
}
if err := scanner.Err(); err != nil {
fmt.Println("scan error:", err)
}
// Read from standard input (interactive program)
reader := bufio.NewReader(os.Stdin)
fmt.Print("Your name: ")
name, _ := reader.ReadString('\n')
name = strings.TrimSpace(name)
fmt.Println("Hello,", name)
// Scan word by word instead of line by line
sc := bufio.NewScanner(strings.NewReader("one two three"))
sc.Split(bufio.ScanWords)
for sc.Scan() {
fmt.Println(sc.Text()) // one / two / three
}
Key points
bufio.NewScanner— iterate input withfor scanner.Scan(); uses constant memory regardless of file size.scanner.Text()— the current token (a line by default), already stripped of the delimiter.scanner.Err()— check it after the loop;Scanreturnsfalseon both EOF and error.bufio.ScanWords— switch the split function to tokenize by words, runes, or a custom rule.
⚠️ Warning:
Scannerhas a default max token size (~64 KB per line). For very long lines, increase it withscanner.Buffer(...)or usebufio.Reader.ReadStringinstead.
Building an HTTP Server
Go's standard library includes a production-grade HTTP server in net/http — no framework needed. A handler responds to requests; you map handlers to URL paths and call ListenAndServe.
package main
import (
"fmt"
"net/http"
)
func main() {
// A handler: takes a ResponseWriter (output) and *Request (input)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Hello, web!")
})
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) // 200
fmt.Fprintln(w, "ok")
})
fmt.Println("listening on :8080")
// Blocks forever; returns only on error
if err := http.ListenAndServe(":8080", nil); err != nil {
panic(err)
}
}
go run main.go
# in another terminal:
curl localhost:8080/ # Hello, web!
curl localhost:8080/health # ok
Key points
http.HandlerFunc— anyfunc(w http.ResponseWriter, r *http.Request)is a handler.w http.ResponseWriter— write the response body and headers here.r *http.Request— read the method, URL, headers, query params, and body from here.http.ListenAndServe(addr, nil)— starts the server;niluses the defaultServeMuxrouter.
💡 Tip:
fmt.Fprintln(w, ...)writes to the response just likePrintlnwrites to the console —ResponseWriteris simply anio.Writer.
A ServeMux maps request patterns to handlers. Since Go 1.22, the built-in mux understands HTTP methods and path wildcards like {id} — enough for most REST APIs without any third-party router.
mux := http.NewServeMux()
// Method + path patterns (Go 1.22+)
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /users/{id}", getUser)
mux.HandleFunc("DELETE /users/{id}", deleteUser)
func getUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // read the {id} wildcard
fmt.Fprintf(w, "user %s\n", id)
}
func listUsers(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("sort") // ?sort=name
fmt.Fprintf(w, "users sorted by %q\n", q)
}
// Serve with the custom mux
http.ListenAndServe(":8080", mux)
curl localhost:8080/users/42 # user 42
curl "localhost:8080/users?sort=name" # users sorted by "name"
curl -X POST localhost:8080/users # createUser
Key points
http.NewServeMux()— create your own router instead of the global default."GET /users/{id}"— method-aware patterns with named wildcards (Go 1.22+).r.PathValue("id")— read a wildcard segment from the matched pattern.r.URL.Query().Get("k")— read query-string parameters like?k=v.
💡 Tip: The standard
ServeMux(Go 1.22+) covers most APIs. Reach for a third-party router (chi, gin) only when you need extras like grouped middleware or richer pattern matching.
Most web APIs speak JSON. Combine net/http with encoding/json to decode request bodies and encode responses — the core of a REST endpoint.
type Todo struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
var todos = []Todo{{1, "Learn Go", false}}
// GET /todos -> JSON array
func listTodos(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos) // stream directly to the response
}
// POST /todos -> decode body, append, return created item
func createTodo(w http.ResponseWriter, r *http.Request) {
var t Todo
if err := json.NewDecoder(r.Body).Decode(&t); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest) // 400
return
}
t.ID = len(todos) + 1
todos = append(todos, t)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated) // 201
json.NewEncoder(w).Encode(t)
}
curl -X POST localhost:8080/todos \
-d '{"title":"Write tests"}'
# {"id":2,"title":"Write tests","done":false}
Key points
json.NewDecoder(r.Body).Decode(&v)— stream-decode the request body without buffering it all first.json.NewEncoder(w).Encode(v)— stream-encode the response straight to the client.w.Header().Set("Content-Type", "application/json")— set headers before writing the body.http.Error(w, msg, code)— send an error with a proper status code (400, 404, 500, ...).
⚠️ Warning: Call
w.WriteHeader(status)and set headers before writing any body. Once you write body bytes, the status defaults to 200 and header changes are ignored.
Middleware wraps a handler to add cross-cutting behaviour — logging, authentication, recovery, CORS — without touching the handler itself. In Go, middleware is just a function that takes a handler and returns a new one.
import (
"log"
"net/http"
"time"
)
// Middleware: func(http.Handler) http.Handler
func logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r) // call the wrapped handler
log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
})
}
func recoverPanic(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "hi")
})
// Wrap the whole mux: recover(logging(mux))
handler := recoverPanic(logging(mux))
http.ListenAndServe(":8080", handler)
}
Key points
- Signature — middleware is
func(http.Handler) http.Handler; it wrapsnextand returns a new handler. next.ServeHTTP(w, r)— call this to pass the request down the chain.- Chaining — wrap repeatedly: the outermost wrapper runs first on the way in, last on the way out.
- Common uses — request logging, auth checks, panic recovery, CORS, rate limiting, timeouts.
💡 Tip: Put
recoverPanicas the outermost middleware so it catches panics from every layer below it — keeping one bad request from crashing your whole server.
Building a CLI Tool
Command-line tools are where Go shines — a single static binary with no runtime to install. The simplest input is os.Args, a slice of the words on the command line.
package main
import (
"fmt"
"os"
)
func main() {
// os.Args[0] is the program name; the rest are arguments
fmt.Println("program:", os.Args[0])
args := os.Args[1:] // just the user-supplied args
if len(args) == 0 {
fmt.Println("usage: greet <name> [name...]")
os.Exit(1) // non-zero exit signals an error to the shell
}
for _, name := range args {
fmt.Printf("Hello, %s!\n", name)
}
}
go build -o greet
./greet Alice Bob
# Hello, Alice!
# Hello, Bob!
./greet
# usage: greet <name> [name...] (exit code 1)
Key points
os.Args— a[]string; index0is the program path,1:are the actual arguments.os.Exit(code)—0means success, non-zero means failure; shells and CI rely on this.- Single binary —
go buildproduces one self-contained executable, easy to ship and run anywhere. - Cross-compile — set
GOOS/GOARCHto build for other platforms from one machine.
💡 Tip:
os.Exitskips deferred functions! Do cleanup (close files, flush logs) before calling it, or return frommainnormally and letos.Exithappen implicitly.
For real tools you want named options like -v or --output file. The standard flag package parses them, applies defaults, and generates a usage message automatically.
package main
import (
"flag"
"fmt"
)
func main() {
// Define flags: name, default, help text -> returns a pointer
verbose := flag.Bool("v", false, "enable verbose output")
count := flag.Int("n", 1, "number of greetings")
name := flag.String("name", "World", "who to greet")
flag.Parse() // populates the pointers from os.Args
if *verbose {
fmt.Println("[verbose] starting up")
}
for i := 0; i < *count; i++ {
fmt.Printf("Hello, %s!\n", *name)
}
// Non-flag leftovers are available via flag.Args()
fmt.Println("extra args:", flag.Args())
}
go run main.go -v -n 2 -name Alice extra1 extra2
# [verbose] starting up
# Hello, Alice!
# Hello, Alice!
# extra args: [extra1 extra2]
go run main.go -h # auto-generated usage with all flags
Key points
flag.Bool/Int/String(name, default, usage)— define a flag; the result is a pointer, so read it with*verbose.flag.Parse()— call once after defining all flags; it readsos.Argsand fills them in.flag.Args()— the positional arguments left over after the flags.- Free
-h/-help— the package prints a usage summary from your flag definitions automatically.
💡 Tip: For complex tools with subcommands (
git commit,git push), useflag.NewFlagSetper subcommand, or a library like cobra. For most small tools, plainflagis plenty.
Some tools prompt the user or read piped data from standard input. bufio over os.Stdin handles both — interactive prompts and cat data | mytool pipelines.
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
func main() {
reader := bufio.NewReader(os.Stdin)
fmt.Print("What's your name? ")
name, _ := reader.ReadString('\n')
name = strings.TrimSpace(name) // drop the trailing newline
fmt.Print("Favourite language? ")
lang, _ := reader.ReadString('\n')
lang = strings.TrimSpace(lang)
fmt.Printf("Hi %s, %s is a great choice!\n", name, lang)
// Or process piped input line by line:
// echo -e "a\nb\nc" | ./mytool
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
fmt.Println("got:", scanner.Text())
}
}
Key points
bufio.NewReader(os.Stdin)— read a full line interactively withReadString('\n').strings.TrimSpace— always trim the newline (and stray spaces) from user input.bufio.NewScanner(os.Stdin)— loop over piped input one line at a time.- Pipes just work — the same
os.Stdinserves both a keyboard and a Unix pipe transparently.
💡 Tip: Detect whether input is piped vs interactive by checking
os.Stdinwith(os.Stdin.Stat()).Mode() & os.ModeCharDevice. It lets a tool prompt humans but stay silent in scripts.
A polished CLI separates normal output from errors, returns meaningful exit codes, and is easy to script. Two small habits make your tools feel professional.
package main
import (
"fmt"
"os"
)
func run() error {
if len(os.Args) < 2 {
return fmt.Errorf("usage: %s <file>", os.Args[0])
}
data, err := os.ReadFile(os.Args[1])
if err != nil {
return fmt.Errorf("reading file: %w", err)
}
fmt.Println(len(data), "bytes") // normal output -> stdout
return nil
}
func main() {
if err := run(); err != nil {
// Errors -> stderr, so they don't pollute piped stdout
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
./wc go.mod # 123 bytes (stdout)
./wc missing.txt # error: reading file: ... (stderr, exit 1)
./wc go.mod | wc # only the byte count flows through the pipe
Key points
- stdout vs stderr — print results to
os.Stdoutand diagnostics toos.Stderrso pipes stay clean. run() errorpattern — keepmaintiny: callrun, print any error, set the exit code.- Exit codes —
0success, non-zero failure; scripts and CI branch on this. %wwrapping — add context to errors as they bubble up tomainfor clear messages.
💡 Tip: Keeping
mainto "callrun(), handle the error, exit" makes your logic testable — you can callrunfrom a test, whilemainitself stays trivial.
Logging, Config & Best Practices
Use logging, not fmt.Println, for anything that runs in production. The classic log package is simple; log/slog (Go 1.21+) adds structured logging with levels and key/value attributes that machines can parse.
import (
"log"
"log/slog"
"os"
)
// Classic log: timestamped lines to stderr
log.Println("server starting")
log.Printf("listening on %s", ":8080")
// log.Fatal prints then calls os.Exit(1)
// log.Panic prints then panics
// Structured logging with slog (Go 1.21+)
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("request handled",
"method", "GET",
"path", "/users",
"status", 200,
"duration_ms", 12,
)
// {"time":"...","level":"INFO","msg":"request handled",
// "method":"GET","path":"/users","status":200,"duration_ms":12}
logger.Warn("slow query", "ms", 850)
logger.Error("db unavailable", "err", err)
// Set a package-wide default
slog.SetDefault(logger)
slog.Info("using the default logger")
Key points
log— quick, timestamped lines;log.Fatalexits,log.Panicpanics.log/slog— structured, leveled logging (Debug/Info/Warn/Error) with key/value attributes.- JSON vs text handlers — JSON for log aggregators in production, the text handler for local dev.
- Levels — emit context-rich events and filter by severity instead of commenting out prints.
💡 Tip: Prefer structured logging (
slog) in services. Key/value fields let log tools filter and search — e.g. "all errors wherestatus >= 500" — which plain text lines make painful.
Don't hard-code ports, URLs, or secrets. The twelve-factor convention is to read configuration from environment variables, with sensible defaults for local development.
import (
"os"
"strconv"
)
type Config struct {
Port int
DBURL string
Debug bool
}
// Helper: read an env var or fall back to a default
func getenv(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return v
}
return fallback
}
func LoadConfig() Config {
port, _ := strconv.Atoi(getenv("PORT", "8080"))
return Config{
Port: port,
DBURL: getenv("DATABASE_URL", "postgres://localhost/dev"),
Debug: getenv("DEBUG", "false") == "true",
}
}
func main() {
cfg := LoadConfig()
// PORT=9090 DEBUG=true go run . -> overrides the defaults
}
Key points
os.Getenv/os.LookupEnv— read env vars;LookupEnvalso reports whether the var was set.- Defaults for dev — fall back to safe local values so the app runs with zero setup.
- Parse and validate early — convert strings to the right types at startup and fail fast on bad config.
- Secrets via env, never in code — keep API keys and passwords out of source control.
⚠️ Warning: Never commit secrets (API keys, DB passwords) to git. Use environment variables, a
.envfile that is git-ignored, or a secrets manager — and add.envto.gitignore.
As a program grows, a clear structure keeps it maintainable. Go has light conventions rather than a rigid framework layout — start simple and add folders only when they earn their keep.
myapp/
├── go.mod
├── go.sum
├── cmd/
│ └── server/
│ └── main.go # thin entry point: wire things, call run()
├── internal/ # private packages — not importable externally
│ ├── config/
│ │ └── config.go
│ ├── handler/
│ │ └── todo.go # HTTP handlers
│ └── store/
│ └── store.go # data access
├── pkg/ # OPTIONAL: code meant to be imported by others
│ └── client/
└── README.md
// cmd/server/main.go stays tiny
func main() {
cfg := config.Load()
s := store.New()
h := handler.New(s)
log.Fatal(http.ListenAndServe(cfg.Addr, h.Routes()))
}
Key points
cmd/<name>/main.go— one folder per executable; keepmainthin and delegate to packages.internal/— the compiler forbids importing these from outside your module: real encapsulation.pkg/— optional home for code you intend others to import; skip it if nothing is meant to be public.- Start flat — a single
main.gois fine early on; split into packages as responsibilities emerge.
💡 Tip: Don't over-engineer the layout on day one. Begin with
main.goand grow intointernal/packages when the file gets unwieldy — premature structure is as harmful as none.
Idiomatic Go is consistent and tool-enforced. Following community conventions makes your code instantly readable to other Gophers — and the tooling does most of the work for you.
# Formatting — non-negotiable, fully automatic
gofmt -w . # or: go fmt ./...
# Static analysis — catches real bugs the compiler allows
go vet ./...
# The popular meta-linter (install separately)
golangci-lint run
// Idiomatic naming and error handling
type userStore struct{} // short, no stutter (not UserStore in pkg user)
func (s *userStore) Get(id int) (*User, error) {
u, err := s.find(id)
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err) // add context
}
return u, nil
}
// Handle errors immediately; keep the happy path un-indented
data, err := os.ReadFile(path)
if err != nil {
return err
}
process(data) // no nesting — the "early return" style
Key points
gofmt— one canonical format, applied automatically; no style debates, ever.go vet+golangci-lint— surface likely bugs and questionable patterns before review.- Early returns — handle errors and exit immediately; keep the main logic at the left margin.
- Naming — short, clear names; avoid stutter (
user.User, notuser.UserStruct); receivers are 1–2 letters.
💡 Tip: Read Effective Go and the Go Code Review Comments wiki once — they capture the community's shared style. Combined with
gofmtand a linter in CI, your code will look like everyone else's (in the best way).
Capstone: A Task API
Time to combine everything. We'll build a small REST API for managing tasks — using structs, methods, interfaces, errors, JSON, concurrency-safe storage, HTTP routing, and tests. This is the shape of a real Go service.
What we're building
A JSON API with four endpoints:
| Method | Path | Action |
|---|---|---|
GET | /tasks | list all tasks |
POST | /tasks | create a task |
GET | /tasks/{id} | fetch one task |
DELETE | /tasks/{id} | delete a task |
taskapi/
├── go.mod
├── main.go # wire store + handlers, start server
├── task.go # the Task model + the Store
└── task_test.go # tests for the Store
// task.go — the domain model
package main
import (
"errors"
"sync"
)
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
}
var ErrNotFound = errors.New("task not found")
Concepts this project exercises
- Structs & methods — the
Taskmodel andStoremethods (Lesson 5). - Errors — a sentinel
ErrNotFound, checked witherrors.Is(Lesson 7). - Concurrency — a
sync.Mutexto make the store safe under HTTP's many goroutines (Lessons 5, 9). - JSON + HTTP — decode requests, encode responses, route with the 1.22 mux (Lessons 13, 14).
💡 Tip: Building a project end-to-end is the single best way to cement what you've learned. Type it out yourself rather than copy-pasting — the friction is where the learning happens.
The store holds tasks in memory. Because an HTTP server handles each request in its own goroutine, the store must be concurrency-safe — so we guard its map with a sync.Mutex.
// task.go (continued)
type Store struct {
mu sync.Mutex
tasks map[int]Task
nextID int
}
func NewStore() *Store {
return &Store{tasks: make(map[int]Task), nextID: 1}
}
func (s *Store) Create(title string) Task {
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: s.nextID, Title: title}
s.tasks[t.ID] = t
s.nextID++
return t
}
func (s *Store) All() []Task {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Task, 0, len(s.tasks))
for _, t := range s.tasks {
out = append(out, t)
}
return out
}
func (s *Store) Get(id int) (Task, error) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.tasks[id]
if !ok {
return Task{}, ErrNotFound
}
return t, nil
}
func (s *Store) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.tasks[id]; !ok {
return ErrNotFound
}
delete(s.tasks, id)
return nil
}
Key points
sync.Mutex+defer Unlock— every method locks first and unlocks on return; safe under concurrent requests.- Map storage —
map[int]Taskgives O(1) lookup by ID;nextIDhands out unique IDs. - Sentinel errors —
Get/DeletereturnErrNotFoundso handlers can map it to a 404. - Return copies — handing back
Taskvalues (not pointers into the map) keeps callers from mutating internal state.
💡 Tip: Swapping this in-memory store for a database later is easy if handlers depend on an interface (e.g.
TaskStorewith these methods) rather than the concrete struct — design at the consumer side, as Lesson 5 noted.
Now the HTTP layer: handlers translate requests into store calls and store results (or errors) into JSON responses with correct status codes.
// main.go
package main
import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
)
type Server struct{ store *Store }
func (srv *Server) handleList(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, srv.store.All())
}
func (srv *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
var body struct{ Title string `json:"title"` }
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Title == "" {
http.Error(w, "title is required", http.StatusBadRequest)
return
}
writeJSON(w, http.StatusCreated, srv.store.Create(body.Title))
}
func (srv *Server) handleGet(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
t, err := srv.store.Get(id)
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, t)
}
func (srv *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.Atoi(r.PathValue("id"))
if errors.Is(srv.store.Delete(id), ErrNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent) // 204
}
// small helper to encode any value as JSON
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func main() {
srv := &Server{store: NewStore()}
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", srv.handleList)
mux.HandleFunc("POST /tasks", srv.handleCreate)
mux.HandleFunc("GET /tasks/{id}", srv.handleGet)
mux.HandleFunc("DELETE /tasks/{id}", srv.handleDelete)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
Key points
- Handlers as methods — putting them on
*Servergives them access to the sharedstore(dependency injection). errors.Is(err, ErrNotFound)— maps a domain error to the right HTTP status (404).- Status codes —
201 Created,204 No Content,400 Bad Request,404 Not Found— speak HTTP correctly. - A
writeJSONhelper — removes repetition and keeps every response consistent.
💡 Tip: Run it and poke it:
curl -X POST localhost:8080/tasks -d '{"title":"ship it"}', thencurl localhost:8080/tasks. Seeing your own API respond is a great moment.
Finally, lock in the behaviour with tests — table-driven, of course — and run everything with the race detector. Then take the project further on your own.
// task_test.go
package main
import (
"errors"
"testing"
)
func TestStore(t *testing.T) {
s := NewStore()
a := s.Create("first")
b := s.Create("second")
if a.ID == b.ID {
t.Fatalf("expected unique IDs, got %d twice", a.ID)
}
if len(s.All()) != 2 {
t.Errorf("want 2 tasks, got %d", len(s.All()))
}
got, err := s.Get(a.ID)
if err != nil || got.Title != "first" {
t.Errorf("Get(%d) = %v, %v", a.ID, got, err)
}
if err := s.Delete(a.ID); err != nil {
t.Errorf("Delete failed: %v", err)
}
if _, err := s.Get(a.ID); !errors.Is(err, ErrNotFound) {
t.Errorf("expected ErrNotFound after delete, got %v", err)
}
}
go test -race -v ./... # tests + data-race detection
go vet ./...
go build -o taskapi # one binary, ready to deploy
Where to take it next
- Persistence — swap the in-memory map for SQLite or Postgres behind a
TaskStoreinterface. - Validation & updates — add
PUT /tasks/{id}and richer input checks. - Middleware — plug in the logging and recovery middleware from Lesson 14.
- Config & logging — read the port from
PORTand switch toslog(Lesson 16). - Deploy — containerize with a tiny
Dockerfileand ship the single binary.
💡 Tip: This API touches every fundamental in the course. Extending it — one feature at a time, with a test for each — is the fastest way to grow from "knows Go" to "builds with Go".
You Did It!
You have completed all seventeen lessons of Go Fundamentals. You went from an empty main.go to understanding goroutines, interfaces, and closures — the core of real-world Go.
What you learnedVariables & types, functions, loops, structs, methods, interfaces, and goroutines.What to do nextBuild a small CLI tool or REST API with net/http
. Real projects are the best teacher.Go furtherRead Effective Go
(go.dev/doc/effective_go) and the Tour of Go
for deeper coverage of the standard library.CommunityJoin the Gopher Slack, follow the Go blog at go.dev/blog, and explore open-source Go projects on GitHub.
💡 💡 The best way to keep improving is to build. Pick a small project that interests you and write it in Go — you now have all the fundamentals to do so.
Frequently Asked Questions
What does the Go (Golang) Fundamentals course cover?
The course covers Go from zero to production skills across 17 lessons: variables, types, functions, closures, structs, interfaces, goroutines, generics, error handling, and testing, plus building a real HTTP server and CLI tool. It ends with a capstone project — a working Task API — and takes about 7 hours to complete.
Do I need prior programming experience to take this course?
No. Go Fundamentals is designed for complete beginners — every concept is introduced from scratch with clear examples and no assumed background. Prior programming experience in any language helps you move faster, but it is not required. The course progresses gradually from "Hello, World!" to goroutines and interfaces.
How long does the Go Fundamentals course take to complete?
The course takes about 7 hours in total, spread across 17 self-paced lessons. Each lesson combines four tabbed reading sections with a short quiz, so most lessons take 20–35 minutes. Progress saves automatically in the browser, so you can stop and resume at any time without losing your place.
How do I unlock the next lesson in the course?
Each of the 17 lessons ends with a 3-question quiz. You unlock the next lesson by scoring at least 2 out of 3 correct — a 67% pass threshold. Every quiz question includes an explanation, so even a failed attempt reinforces the concept before you retry.
Does the course teach goroutines and concurrency in Go?
Yes. Goroutines are introduced in the "Structs and Methods" lesson, then covered in depth in the dedicated "Concurrency in Depth" lesson, which teaches channels, the select statement, sync.Mutex, sync.WaitGroup, sync.Once, and context.Context for cancellation — the core toolkit for writing safe concurrent Go programs.
Does the course include generics and other modern Go features?
Yes. A dedicated "Generics" lesson covers writing type-parameterized functions and data structures, a feature introduced in Go 1.18, alongside modern standard-library additions like the sort, slices, and context packages used elsewhere in the course. The material targets current, idiomatic Go rather than outdated pre-generics patterns.
Is there a hands-on project in the Go Fundamentals course?
Yes. The course ends with a capstone lesson, "Capstone: A Task API," where learners apply structs, interfaces, error handling, JSON encoding, and net/http to build a working task-management REST API — combining everything taught in the previous 16 lessons into one real program.
Do I need to install Go locally to follow this course?
No installation is required to follow along — every code example can be run instantly in the browser at go.dev/play. The "Hello, Go!" lesson does cover local installation, GOPATH/GOROOT, and the go.mod project structure for learners who want a full local development environment.