A quick-reference and hands-on guide to the fundamentals of Go (Golang) programming.
The fastest way to keep what you learn is to teach it. The physicist Richard Feynman's trick was simple: if you can't explain something in plain words, you don't really understand it yet. For each section, work these four steps:
Watch for the 💡 Explain it simply prompts and ✎ Exercise boxes in each section. Hover any code block and click Copy to grab it.
Go (also called Golang) is a statically typed, compiled language created at Google. It is designed for simplicity, fast compilation, and built-in concurrency support.
.go extensionpackage; executable programs use package maingo build; run directly with go run main.goIn one sentence, tell a friend what "compiled" and "statically typed" mean for a Go program — without using either of those words.
Before it runs, Go turns your whole program into a single machine-code file (compiled), and while doing that it checks that every value is used as the right kind of thing — a number where a number belongs, text where text belongs (statically typed). So many mistakes get caught up front, before the program ever starts, and it runs fast because the translating is already done.
Go ships as one toolchain: the compiler, code formatter, test runner, and package manager, all in a single install. Get the latest version from go.dev/dl, then confirm it with go version.
Download the tarball and unpack it into /usr/local (remove any old install first rather than layering on top):
# check go.dev/dl for the current version number
wget https://go.dev/dl/go1.22.5.linux-amd64.tar.gz
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.22.5.linux-amd64.tar.gz
Add Go to your PATH by appending this to ~/.profile (or ~/.bashrc), then reload it with source ~/.profile:
export PATH=$PATH:/usr/local/go/bin
Easiest is Homebrew, or download the .pkg installer from go.dev/dl and double-click it:
brew install go
Download and run the .msi installer from go.dev/dl — it sets your PATH automatically. Or use winget:
winget install GoLang.Go
go version # e.g. go version go1.22.5 linux/amd64
Modern Go organizes code into modules. Create one, add a file, and run it:
mkdir hello && cd hello
go mod init example/hello # creates go.mod
# put your program in main.go, then:
go run . # compile & run
go build # produce a ./hello binary
Create the module above, then write main.go so that go run . prints exactly Hello, Go! Fill in the one missing line.
package main
import "fmt"
func main() {
// TODO: print exactly: Hello, Go!
}
package main
import "fmt"
func main() {
fmt.Println("Hello, Go!")
}
Hello, Go!
go run . compiles and runs in one step (nothing left behind), while go build produces a standalone binary you can ship. go fmt auto-formats your code to the one true Go style.package main
import "fmt"
func main() {
fmt.Println("Hello, World!")
}
Compile and run:
go run main.go
go build -o myapp main.go // produces a binary
./myapp
fmt (I/O), math, strings, strconv, os, errors, net/http.Complete main so it greets the name in name. Use fmt.Printf with a %s placeholder and a \n newline to print: Hello, Ada! Welcome to Go.
package main
import "fmt"
func main() {
name := "Ada"
// TODO: print: Hello, Ada! Welcome to Go.
}
package main
import "fmt"
func main() {
name := "Ada"
fmt.Printf("Hello, %s! Welcome to Go.\n", name)
}
Hello, Ada! Welcome to Go.
Why does every runnable Go program need both package main and func main()?
package main tells Go "this is a program you can run, not a library other code borrows from." func main() is the one function Go calls first when it starts. Together they say: this is runnable, and here is where to begin.
// var declaration — explicit type
var name string = "Alice"
var age int = 30
var price float64 = 9.99
var active bool = true
// Short variable declaration — type inferred (inside functions only)
city := "NYC"
count := 10
// Multiple assignment
x, y := 1, 2
// Zero values — Go initialises all variables
var i int // 0
var s string // ""
var b bool // false
// Constants
const Pi = 3.14159
const Max = 100
| Type | Description | Zero value |
|---|---|---|
int, int8/16/32/64 | Signed integers | 0 |
uint, uint8/16/32/64 | Unsigned integers | 0 |
float32, float64 | Floating-point numbers | 0 |
string | Immutable UTF-8 text | "" |
bool | Boolean | false |
byte | Alias for uint8 | 0 |
rune | Alias for int32 — Unicode code point | 0 |
Declare age as an int using var, and city as a string using :=. Then print: age 30 in NYC.
package main
import "fmt"
func main() {
// TODO: var age int = 30
// TODO: city := "NYC"
// TODO: print → age 30 in NYC
}
package main
import "fmt"
func main() {
var age int = 30
city := "NYC"
fmt.Printf("age %d in %s\n", age, city)
}
age 30 in NYC
Go gives every new variable a "zero value" (0, "", false). Explain why that's safer than leaving it undefined.
There's never leftover garbage in a fresh variable. A new number starts at 0, a new string is empty, a new bool is false. So reading a variable before you've set it is always safe and predictable — a whole family of "used an uninitialised value" bugs simply can't happen.
a, b := 10, 3
a + b // 13
a - b // 7
a * b // 30
a / b // 3 — integer division
a % b // 1 — modulus
// Comparison
a == b // false
a != b // true
a < b // false
a > b // true
// Logical
a > 5 && b < 5 // true — AND
a > 5 || b > 5 // true — OR
!(a == b) // true — NOT
// Assignment shortcuts
a += 5
a++ // increment (statement only, not expression)
++ and -- are statements, not expressions — you cannot write x = a++.Given a = 17 and b = 5, print the quotient and the remainder on one line, space-separated: 3 2.
package main
import "fmt"
func main() {
a, b := 17, 5
// TODO: print the quotient then the remainder → 3 2
}
package main
import "fmt"
func main() {
a, b := 17, 5
fmt.Println(a/b, a%b)
}
3 2
In plain words, why is 7 / 2 equal to 3 in Go, and not 3.5?
Both 7 and 2 are whole numbers (integers), so Go does whole-number division and throws away the fractional part — you get 3, remainder 1. To keep the .5 you need a decimal on at least one side, like 7.0 / 2, which gives 3.5.
import (
"fmt"
"strings"
"strconv"
)
first := "Alice"
last := "Smith"
// Concatenation
first + " " + last // "Alice Smith"
// Sprintf — formatted string (like printf but returns a string)
fmt.Sprintf("Hello, %s! Age: %d", first, 30)
// strings package
strings.ToUpper("hello") // "HELLO"
strings.ToLower("HELLO") // "hello"
strings.TrimSpace(" hi ") // "hi"
strings.Contains("hello", "ell") // true
strings.Split("a,b,c", ",") // ["a","b","c"]
// Convert between strings and numbers
strconv.Itoa(42) // "42"
strconv.Atoi("42") // 42, nil
Join first and last with a space, then print the result in UPPERCASE using the strings package: ADA LOVELACE.
package main
import (
"fmt"
"strings"
)
func main() {
first, last := "ada", "lovelace"
// TODO: build "ada lovelace", then print it uppercased → ADA LOVELACE
}
package main
import (
"fmt"
"strings"
)
func main() {
first, last := "ada", "lovelace"
full := first + " " + last
fmt.Println(strings.ToUpper(full))
}
ADA LOVELACE
Strings in Go are immutable. Does strings.ToUpper(full) change full itself? Explain.
No. ToUpper can't edit the original — strings can't be changed in place. It builds and hands back a brand-new string. full stays exactly as it was unless you assign the new string back onto it.
var a [3]int = [3]int{10, 20, 30}
a[0] // 10
len(a) // 3
fruits := []string{"apple", "banana", "cherry"}
fruits[0] // "apple"
fruits[1:3] // ["banana","cherry"] — slice of slice
len(fruits) // 3
fruits = append(fruits, "date") // append — may allocate new array
// Make a slice with length and capacity
s := make([]int, 5) // [0,0,0,0,0]
ages := map[string]int{
"Alice": 30,
"Bob": 25,
}
ages["Alice"] // 30
ages["Carol"] = 28 // add / update
delete(ages, "Bob") // remove key
// Safe lookup — ok is false if key missing
val, ok := ages["Dave"]
if !ok {
fmt.Println("not found")
}
Append 4 and 5 to the slice, then print the slice and its length: [1 2 3 4 5] 5.
package main
import "fmt"
func main() {
nums := []int{1, 2, 3}
// TODO: append 4 and 5 to nums, then print nums and len(nums)
}
package main
import "fmt"
func main() {
nums := []int{1, 2, 3}
nums = append(nums, 4, 5)
fmt.Println(nums, len(nums))
}
[1 2 3 4 5] 5
Why must you write nums = append(nums, 4) and not just call append(nums, 4) and ignore the result?
When a slice runs out of room, append makes a bigger array behind the scenes and returns a slice pointing at the new one. If you throw that return value away, your variable still points at the old, smaller array — and never sees the item you added. So you must catch what append hands back.
score := 75
if score >= 90 {
fmt.Println("A")
} else if score >= 75 {
fmt.Println("B")
} else {
fmt.Println("C or below")
}
// No fallthrough by default — no break needed
switch day {
case "Mon":
fmt.Println("Monday")
case "Fri", "Sat":
fmt.Println("End of week")
default:
fmt.Println("Other")
}
Print the letter grade using if / else if / else: A for score ≥ 90, B for ≥ 80, otherwise C. Here score is 82, so it should print B.
package main
import "fmt"
func main() {
score := 82
// TODO: print "A", "B", or "C"
}
package main
import "fmt"
func main() {
score := 82
if score >= 90 {
fmt.Println("A")
} else if score >= 80 {
fmt.Println("B")
} else {
fmt.Println("C")
}
}
B
What does a switch give you that a long if / else if chain doesn't?
It reads cleaner when you're checking one thing against many possibilities, and Go's switch doesn't "fall through" into the next case, so you don't sprinkle break everywhere. A condition-less switch { case x > 90: ... } can even stand in for an if/else chain while staying tidy.
for. It covers all looping patterns.for i := 0; i < 5; i++ {
fmt.Println(i)
}
i := 0
for i < 5 {
fmt.Println(i)
i++
}
fruits := []string{"apple", "banana", "cherry"}
for i, fruit := range fruits {
fmt.Printf("%d: %s\n", i, fruit)
}
// Ignore index with _
for _, fruit := range fruits {
fmt.Println(fruit)
}
break to exit a loop and continue to skip to the next iteration.Use a for loop to add up the numbers 1 through 10, then print the total: 55.
package main
import "fmt"
func main() {
sum := 0
// TODO: loop i from 1 to 10, add each to sum, then print sum → 55
}
package main
import "fmt"
func main() {
sum := 0
for i := 1; i <= 10; i++ {
sum += i
}
fmt.Println(sum)
}
55
Go has only for — no while keyword. Explain how one keyword still covers a while loop.
for has three optional parts: a start, a condition, and a step. Keep only the condition — for sum < 100 { } — and it behaves exactly like a while. Drop all three — for { } — and it loops forever until you break. Same keyword, you just leave out the pieces you don't need.
// Basic function
func greet(name string) string {
return "Hello, " + name + "!"
}
// Multiple return values — idiomatic Go
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}
result, err := divide(10, 3)
if err != nil {
fmt.Println("Error:", err)
}
// defer — run at end of the surrounding function
defer fmt.Println("cleanup") // runs when function returns
error as the last return value is the standard Go pattern for error handling — there are no exceptions.Finish divide: return an error when b is 0, otherwise return a/b and nil. Called with (10, 2), the program should print Result: 5.
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
// TODO: if b == 0, return 0 and errors.New("divide by zero")
// TODO: otherwise return a/b and nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
package main
import (
"errors"
"fmt"
)
func divide(a, b int) (int, error) {
if b == 0 {
return 0, errors.New("divide by zero")
}
return a / b, nil
}
func main() {
result, err := divide(10, 2)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
Result: 5
Go has no exceptions. Explain how returning an error value replaces try / catch.
A Go function hands back an error as its last return value. Right after you call it, you check if err != nil and decide what to do. Errors are just ordinary values moving through your normal code — nothing is secretly thrown and caught somewhere far away.
// Define a struct
type Person struct {
Name string
Age int
}
// Create instances
p1 := Person{Name: "Alice", Age: 30}
p1.Name // "Alice"
p1.Age = 31 // update field
// Method — function with a receiver
func (p Person) Describe() string {
return fmt.Sprintf("%s is %d years old.", p.Name, p.Age)
}
// Pointer receiver — can modify the struct
func (p *Person) Birthday() {
p.Age++
}
Give Person a Describe() method (use fmt.Sprintf) so the program prints Ada is 30 years old.
package main
import "fmt"
type Person struct {
Name string
Age int
}
// TODO: add a Describe() string method on Person
func main() {
p := Person{Name: "Ada", Age: 30}
fmt.Println(p.Describe())
}
package main
import "fmt"
type Person struct {
Name string
Age int
}
func (p Person) Describe() string {
return fmt.Sprintf("%s is %d years old.", p.Name, p.Age)
}
func main() {
p := Person{Name: "Ada", Age: 30}
fmt.Println(p.Describe())
}
Ada is 30 years old.
Explain the difference between a value receiver (p Person) and a pointer receiver (p *Person).
A value receiver works on a copy of the struct, so any changes it makes vanish when the method returns. A pointer receiver works on the original, so it can actually change the struct's fields (and it avoids copying a big struct). Reach for a pointer receiver whenever the method needs to modify the thing.
An interface defines a set of method signatures. Any type that implements all the methods satisfies the interface — no explicit declaration needed.
type Shape interface {
Area() float64
}
type Circle struct { Radius float64 }
func (c Circle) Area() float64 { return math.Pi * c.Radius * c.Radius }
// Circle satisfies Shape automatically
func printArea(s Shape) {
fmt.Printf("Area: %.2f\n", s.Area())
}
Make Circle satisfy Shape by giving it an Area() method (π·r·r). For a radius-2 circle, the program should print 12.57.
package main
import (
"fmt"
"math"
)
type Shape interface {
Area() float64
}
type Circle struct {
Radius float64
}
// TODO: implement Area() float64 for Circle
func main() {
var s Shape = Circle{Radius: 2}
fmt.Printf("%.2f\n", s.Area())
}
package main
import (
"fmt"
"math"
)
type Shape interface {
Area() float64
}
type Circle struct {
Radius float64
}
func (c Circle) Area() float64 {
return math.Pi * c.Radius * c.Radius
}
func main() {
var s Shape = Circle{Radius: 2}
fmt.Printf("%.2f\n", s.Area())
}
12.57
Go interfaces are "implicit." Explain what that means compared with languages where you write implements Shape.
You never announce that Circle implements Shape. The instant Circle has the method the interface asks for — Area() float64 — it automatically counts as a Shape. Fitting an interface is about having the right methods, not declaring your intention to fit it.
Go has built-in concurrency via goroutines (lightweight threads) and channels (typed message-passing pipes).
go say("world") // launches a goroutine — runs alongside main
say("hello") // runs in the main goroutine
// Create a channel of ints
ch := make(chan int)
// Send a value in a goroutine
go func() {
ch <- 42 // send
}()
val := <-ch // receive (blocks until value arrives)
Start a goroutine that sends 42 into the channel, then receive it in main and print it: 42.
package main
import "fmt"
func main() {
ch := make(chan int)
// TODO: start a goroutine that sends 42 into ch
// TODO: receive from ch into a variable named val
// TODO: print val
}
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42
}()
val := <-ch
fmt.Println(val)
}
42
Explain why receiving from an unbuffered channel "blocks," and why that's actually useful.
An unbuffered channel passes a value hand-to-hand: the receiver waits until some goroutine sends, and the sender waits until someone is there to receive. That built-in waiting lines the two goroutines up at the same moment — it synchronizes them for you, so you don't need locks to coordinate.