Back to Go Examples

Basic Go

A quick-reference and hands-on guide to the fundamentals of Go (Golang) programming.

How to use this page

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:

  1. Read the section once.
  2. Explain it out loud in plain words, as if teaching a friend — no jargon. If you stall, that's the gap.
  3. Do the exercise from a blank editor, without peeking. Where you get stuck is exactly what to reread.
  4. Reveal the solution, compare, and explain the idea again in your own words.

Watch for the 💡 Explain it simply prompts and ✎ Exercise boxes in each section. Hover any code block and click Copy to grab it.

Contents

  1. What is Go?
  2. Installing Go
  3. Program Structure
  4. Variables & Data Types
  5. Operators
  6. Strings
  7. Arrays, Slices & Maps
  8. Control Structures
  9. Loops
  10. Functions
  11. Structs & Methods
  12. Interfaces
  13. Goroutines & Channels

1. What is Go?

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.

Explain it simply

In one sentence, tell a friend what "compiled" and "statically typed" mean for a Go program — without using either of those words.

Reveal a plain-language answer

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.

2. Installing Go

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.

Linux

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

macOS

Easiest is Homebrew, or download the .pkg installer from go.dev/dl and double-click it:

brew install go

Windows

Download and run the .msi installer from go.dev/dl — it sets your PATH automatically. Or use winget:

winget install GoLang.Go

Verify the install

go version    # e.g. go version go1.22.5 linux/amd64

Your first program with modules

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
Exercise — your turn

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!
}
Show solution
package main

import "fmt"

func main() {
    fmt.Println("Hello, Go!")
}
Output
Hello, Go!
Two commands you'll lean on constantly: 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.

3. Program Structure

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
Common packages: fmt (I/O), math, strings, strconv, os, errors, net/http.
Exercise — your turn

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.
}
Show solution
package main

import "fmt"

func main() {
    name := "Ada"
    fmt.Printf("Hello, %s! Welcome to Go.\n", name)
}
Output
Hello, Ada! Welcome to Go.
Explain it simply

Why does every runnable Go program need both package main and func main()?

Reveal a plain-language answer

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.

4. Variables & Data Types

// 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
TypeDescriptionZero value
int, int8/16/32/64Signed integers0
uint, uint8/16/32/64Unsigned integers0
float32, float64Floating-point numbers0
stringImmutable UTF-8 text""
boolBooleanfalse
byteAlias for uint80
runeAlias for int32 — Unicode code point0
Exercise — your turn

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
}
Show solution
package main

import "fmt"

func main() {
    var age int = 30
    city := "NYC"
    fmt.Printf("age %d in %s\n", age, city)
}
Output
age 30 in NYC
Explain it simply

Go gives every new variable a "zero value" (0, "", false). Explain why that's safer than leaving it undefined.

Reveal a plain-language answer

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.

5. Operators

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)
In Go, ++ and -- are statements, not expressions — you cannot write x = a++.
Exercise — your turn

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
}
Show solution
package main

import "fmt"

func main() {
    a, b := 17, 5
    fmt.Println(a/b, a%b)
}
Output
3 2
Explain it simply

In plain words, why is 7 / 2 equal to 3 in Go, and not 3.5?

Reveal a plain-language answer

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.

6. Strings

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
Exercise — your turn

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
}
Show solution
package main

import (
    "fmt"
    "strings"
)

func main() {
    first, last := "ada", "lovelace"
    full := first + " " + last
    fmt.Println(strings.ToUpper(full))
}
Output
ADA LOVELACE
Explain it simply

Strings in Go are immutable. Does strings.ToUpper(full) change full itself? Explain.

Reveal a plain-language answer

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.

7. Arrays, Slices & Maps

Arrays — fixed size

var a [3]int = [3]int{10, 20, 30}
a[0]          // 10
len(a)        // 3

Slices — dynamic, use these in practice

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]

Maps

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")
}
Exercise — your turn

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)
}
Show solution
package main

import "fmt"

func main() {
    nums := []int{1, 2, 3}
    nums = append(nums, 4, 5)
    fmt.Println(nums, len(nums))
}
Output
[1 2 3 4 5] 5
Explain it simply

Why must you write nums = append(nums, 4) and not just call append(nums, 4) and ignore the result?

Reveal a plain-language answer

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.

8. Control Structures

if / else if / else

score := 75

if score >= 90 {
    fmt.Println("A")
} else if score >= 75 {
    fmt.Println("B")
} else {
    fmt.Println("C or below")
}

switch

// 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")
}
Exercise — your turn

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"
}
Show solution
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")
    }
}
Output
B
Explain it simply

What does a switch give you that a long if / else if chain doesn't?

Reveal a plain-language answer

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.

9. Loops

Go has only one loop keyword: for. It covers all looping patterns.

Traditional for

for i := 0; i < 5; i++ {
    fmt.Println(i)
}

while-style

i := 0
for i < 5 {
    fmt.Println(i)
    i++
}

range — iterate over slices, maps, strings

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)
}
Use break to exit a loop and continue to skip to the next iteration.
Exercise — your turn

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
}
Show solution
package main

import "fmt"

func main() {
    sum := 0
    for i := 1; i <= 10; i++ {
        sum += i
    }
    fmt.Println(sum)
}
Output
55
Explain it simply

Go has only for — no while keyword. Explain how one keyword still covers a while loop.

Reveal a plain-language answer

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.

10. Functions

// 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
Returning an error as the last return value is the standard Go pattern for error handling — there are no exceptions.
Exercise — your turn

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)
    }
}
Show solution
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)
    }
}
Output
Result: 5
Explain it simply

Go has no exceptions. Explain how returning an error value replaces try / catch.

Reveal a plain-language answer

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.

11. Structs & Methods

// 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++
}
Exercise — your turn

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())
}
Show solution
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())
}
Output
Ada is 30 years old.
Explain it simply

Explain the difference between a value receiver (p Person) and a pointer receiver (p *Person).

Reveal a plain-language answer

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.

12. Interfaces

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())
}
Exercise — your turn

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())
}
Show solution
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())
}
Output
12.57
Explain it simply

Go interfaces are "implicit." Explain what that means compared with languages where you write implements Shape.

Reveal a plain-language answer

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.

13. Goroutines & Channels

Go has built-in concurrency via goroutines (lightweight threads) and channels (typed message-passing pipes).

Goroutines

go say("world")   // launches a goroutine — runs alongside main
say("hello")      // runs in the main goroutine

Channels

// 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)
Go's concurrency motto: "Do not communicate by sharing memory; instead, share memory by communicating." Prefer channels over shared variables.
Exercise — your turn

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
}
Show solution
package main

import "fmt"

func main() {
    ch := make(chan int)
    go func() {
        ch <- 42
    }()
    val := <-ch
    fmt.Println(val)
}
Output
42
Explain it simply

Explain why receiving from an unbuffered channel "blocks," and why that's actually useful.

Reveal a plain-language answer

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.