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.
A programming language is a precise, written notation for telling a computer what to do. Go was designed at Google in 2007 by Robert Griesemer, Rob Pike, and Ken Thompson, and released publicly in 2009. Its authors were frustrated by large codebases that took many minutes to compile and were hard for new engineers to read, so they set out to build a language that is small enough to learn in a few days, compiles in seconds, and makes it easy to use every core of a modern processor.
Go is compiled: before a program runs, a tool called the compiler translates the entire source code into machine code, producing a single executable file. It is also statically typed: every value has a type (number, text, list, and so on) that is known and checked at compile time, so many mistakes are caught before the program ever runs. Today Go is widely used for web servers, command-line tools, and cloud infrastructure such as Docker and Kubernetes.
.go..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.
Every Go program follows the same basic skeleton. Understanding that skeleton first makes every later example easier to read, because the only thing that changes is what goes inside it.
A Go source file begins with a package clause, which states which package the file belongs to. A package is a named group of related code; the special name main tells the compiler that this package builds into a runnable program rather than a library. Next come the import declarations, which list the other packages this file uses. For example, fmt (short for “format”) provides functions for printing text. Finally comes the code itself. Execution always begins at the function named main in package main. When main returns, the program ends.
Go uses curly braces { } to group statements into blocks, and the opening brace must sit on the same line as the statement that owns it. Comments, which are notes for human readers that the compiler ignores, start with // for a single line or are wrapped in /* … */ for several lines.
main() in package main.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.
A variable is a named storage location that holds a value while a program runs. You can think of it as a labelled box: the label is the variable's name, and the contents are its value. The contents can change, but the kind of thing the box holds cannot.
That “kind of thing” is the variable's data type. The type determines which values are allowed and which operations make sense. You can add two numbers, but you cannot meaningfully add a number to true. Go's basic types are integers (int, whole numbers), floating-point numbers (float64, numbers with a fractional part), strings (string, text), and booleans (bool, either true or false).
Go gives you two ways to declare a variable. The long form, var name type = value, states everything explicitly. The short form, name := value, lets the compiler infer the type from the value, and it can only be used inside functions. A variable that is declared without a value is not left undefined: Go assigns its zero value, which is 0 for numbers, "" for strings, and false for booleans. A constant, declared with const, is a value fixed at compile time that can never change.
// 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.
An operator is a symbol that tells the computer to perform a specific operation on one or more values, called operands. In a + b, the + is the operator and a and b are its operands. A combination of values and operators that produces a result is called an expression.
Go's operators fall into a few families. Arithmetic operators (+ - * / %) compute numbers; note that dividing two integers discards the remainder, so 10 / 3 is 3, while % (modulus) gives that remainder. Comparison operators (== != < > <= >=) compare two values and produce a boolean. Logical operators (&& for AND, || for OR, ! for NOT) combine booleans. Assignment operators (= += -= and so on) store a result in a variable.
Go is strict about types in expressions: both operands must be the same type, so adding an int to a float64 requires an explicit conversion such as float64(a). Also note that ++ and -- are statements in Go, not expressions, so you can write i++ on its own line but not x = i++.
&& and || stop evaluating as soon as the result is known.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.
A string is a sequence of characters used to represent text: a name, a sentence, a line from a file. In Go a string is written between double quotes, as in "hello", or between backticks for a raw string that may span several lines and treats backslashes literally.
Under the hood, a Go string is a read-only sequence of bytes encoded in UTF-8, the standard encoding that can represent every alphabet and symbol. Plain English letters take one byte each, but characters such as é or emoji take several. This is why len(s) counts bytes, not characters. When you need to work with individual characters, Go calls them runes, and a for … range loop over a string steps through it one rune at a time.
Strings in Go are immutable: once created, a string's contents cannot be changed. Operations that seem to modify a string, such as joining two with + or converting to upper case, actually build and return a new string. The standard library's strings package provides common tools such as searching, splitting, and replacing, and strconv converts between strings and numbers.
len() counts these.rune type.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.
Programs rarely deal with just one value at a time. A collection is a data structure that holds many values under a single name. Go provides three built-in collections, and choosing the right one is one of the most common design decisions you will make.
An array holds a fixed number of elements of the same type, stored side by side in memory. Its length is part of its type, so [3]int and [4]int are different types, and the length can never change. Each element is reached by its index, its position counting from zero. Because they are rigid, arrays are used directly less often than the next structure.
A slice is a flexible, resizable view onto an underlying array, and it is Go's everyday list. You can grow a slice with append, and take a portion of it with s[low:high]. A map is an unordered collection of key–value pairs: instead of looking a value up by numeric position, you look it up by a key, such as a person's name to find their age. Maps give very fast lookups, but their iteration order is deliberately unpredictable.
append must allocate a larger array.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.
By default a program runs its statements one after another, top to bottom. Control structures let a program make decisions, running some statements only when certain conditions hold. This is what lets software react differently to different inputs.
The most basic decision is the if statement. It evaluates a condition, an expression that is either true or false, and runs its block only when the condition is true. An optional else if tests further conditions in turn, and a final else handles every remaining case. In Go the condition is never wrapped in parentheses, but the braces around each block are always required. An if may also start with a short statement, such as if v, err := f(); err != nil, which is the idiomatic way to check for errors.
When one value must be compared against many possibilities, a switch statement is clearer than a long if/else if chain. Go's switch checks each case from top to bottom and runs only the first one that matches. Unlike C or Java, Go does not “fall through” into the next case, so no break is needed.
case; Go does this only if you write fallthrough.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.
A loop repeats a block of code, either a fixed number of times or until some condition changes. Repetition is fundamental to programming: processing every line of a file, every item in a list, or every request to a server all rely on loops. Each pass through the loop body is called an iteration.
Most languages offer several loop keywords (for, while, do), but Go has exactly one, for, which takes several forms. The three-part form, for init; condition; post, counts through a range of numbers. The condition-only form, for condition, behaves like a while loop in other languages. The bare form, for { }, loops forever until something inside stops it. The range form steps through each element of a slice, map, string, or channel.
Two keywords adjust a loop from the inside: break exits the loop immediately, and continue skips the rest of the current iteration and moves on to the next. A loop whose condition never becomes false is an infinite loop. That is sometimes intended, as in a server waiting for connections, but often it is a bug.
i.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.
A function is a named, reusable block of code that performs one specific task. Functions are the primary way programmers break a large problem into small, understandable pieces: instead of writing the same steps in many places, you write them once, give them a name, and call that name wherever the task is needed.
A function can receive input through parameters, variables listed in parentheses after its name, each with a type. The actual values passed in when the function is called are its arguments. A function can hand results back to its caller with a return statement, and its signature declares the types of those results. A distinctive feature of Go is that a function may return multiple values. By convention, a function that can fail returns its result together with an error value, and the caller checks whether that error is nil.
Go passes arguments by value, meaning the function receives a copy, so changing a parameter inside the function does not affect the caller's variable. To let a function modify the original, you pass a pointer, the memory address of the variable. Functions in Go are also first-class values: they can be stored in variables, passed to other functions, and written inline as anonymous functions, or closures, that remember the variables around them.
// 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.
Real-world things have several properties at once. A person has a name and an age, and a bank account has an owner and a balance. A struct (short for “structure”) is a user-defined type that groups several related values, called fields, into a single unit. Where a slice holds many values of the same type, a struct holds a fixed set of named values that may each have a different type.
You define a struct with type Name struct { … }, create a value of it with a struct literal such as Person{Name: "Alice", Age: 30}, and read or change its fields with a dot, as in p.Name. A field whose name starts with a capital letter is exported, meaning visible to other packages; a lower-case name keeps it private to its own package.
A method is a function attached to a particular type. It is declared with a receiver written before the method name, as in func (p Person) Greet(). A value receiver works on a copy of the struct, while a pointer receiver (func (p *Person)) works on the original and can therefore modify it. Go has no classes or inheritance. Structs and methods, combined with interfaces in the next section, fill that role.
// 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 is a type that describes behaviour rather than data. It lists a set of method signatures, which are the names, parameters, and return types of methods, without saying how any of them work. Any type that has all of those methods is said to satisfy (or implement) the interface, and a value of that type can be used wherever the interface is expected.
Interfaces make polymorphism possible: writing one piece of code that works with many different types. A function that accepts a Shape interface can compute the area of a circle, a rectangle, or any shape invented later, without knowing which concrete type it has been given. This keeps code flexible and makes it easy to substitute one implementation for another, for example a fake database in tests.
Go's interfaces are implicit. A type never declares “I implement Shape”; if it has the right methods, it simply does. This is sometimes called structural typing or “duck typing checked at compile time”: if it walks like a duck and quacks like a duck, the compiler accepts it as a duck. Good Go interfaces tend to be small, often a single method, like the standard library's io.Reader and fmt.Stringer. The empty interface, any, has no methods and is therefore satisfied by every type.
v.(T): recovering the concrete type stored in an interface value.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.
So far every program has done one thing at a time. Concurrency is the ability to have several tasks in progress at once, such as serving many web visitors, downloading several files, or keeping a user interface responsive during a long calculation. On a machine with multiple processor cores, concurrent tasks can also run truly in parallel, at the same instant.
A goroutine is Go's unit of concurrency: a function running independently alongside the rest of the program. You start one simply by writing go before a function call. Goroutines are far lighter than operating-system threads, since each starts with only a few kilobytes of memory, so a program can comfortably run thousands of them. The Go runtime schedules them across the available CPU cores automatically. When main returns, the program exits, even if other goroutines are still running, so you must wait for work to finish, for example with a sync.WaitGroup.
A channel is a typed pipe that lets goroutines communicate safely. One goroutine sends a value with ch <- v, and another receives it with v := <-ch. On an unbuffered channel, the sender waits until a receiver is ready, so the handoff also synchronizes the two goroutines. Go's guiding motto is: “Do not communicate by sharing memory; instead, share memory by communicating.” Passing data through channels, rather than having many goroutines modify the same variables, avoids a whole class of bugs called race conditions.
make(chan T, n), whose sends do not block until it is full.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.