Go (Golang) โ Complete Beginner Tutorial
6 modules ยท 17 examples ยท Click โถ Run on any example to see the output
๐ก How to use this page
Read the explanation, study the code, then hit โถ Run to see the output.
Variables & Data Types
Every Go program starts with "package main" and a main() function โ that is where execution begins. Go is statically typed, meaning every variable has a fixed type. You can declare types explicitly with var, or let Go figure it out using the := shorthand (called short variable declaration).
Variables โ var and :=
In Go there are two ways to create variables. The var keyword is explicit โ you can use it anywhere including outside functions. The := shorthand is faster but only works inside functions. Go never lets you declare a variable without using it โ unused variables are a compile error.
package main
import "fmt"
func main() {
// var keyword โ explicit type declaration
var name string = "Alice"
var age int = 25
var pi float64 = 3.14159
// := shorthand โ Go figures out the type
city := "Cape Town"
score := 95
passing := true
// Print them
fmt.Println(name, age, city)
fmt.Println("Score:", score)
fmt.Println("Passing:", passing)
fmt.Println("Pi:", pi)
// var block โ declare many at once
var (
firstName = "Bob"
lastName = "Smith"
yearBorn = 1999
)
fmt.Println(firstName, lastName, yearBorn)
}Go will refuse to compile if you declare a variable and never use it. This forces clean code โ no mystery variables sitting unused. If you genuinely need to ignore a value, use the blank identifier: _ = someValue.
Data Types & fmt Formatting
Go has clear, predictable types. int for whole numbers, float64 for decimals, string for text, bool for true/false. The fmt package handles all printing โ fmt.Println adds a newline, fmt.Printf lets you format with verbs like %s (string), %d (integer), %f (float), %v (any value), %T (type).
package main
import "fmt"
func main() {
var (
playerName string = "Alice"
lives int = 3
speed float64 = 9.8
isActive bool = true
)
// fmt.Println โ simple print with newline
fmt.Println("Player:", playerName)
// fmt.Printf โ formatted print with verbs
fmt.Printf("Name: %s\n", playerName)
fmt.Printf("Lives: %d\n", lives)
fmt.Printf("Speed: %.2f m/s\n", speed) // 2 decimal places
fmt.Printf("Active: %v\n", isActive)
fmt.Printf("Type of lives: %T\n", lives)
// fmt.Sprintf โ format into a string (don't print)
summary := fmt.Sprintf("%s has %d lives", playerName, lives)
fmt.Println(summary)
// Constants โ values that never change
const maxLives = 5
const gravity = 9.81
fmt.Printf("Max lives: %d, Gravity: %.2f\n", maxLives, gravity)
}%v is the "default format" verb โ it works for any type. When in doubt, use %v. Use %+v on a struct to also print field names. Use %#v to print Go syntax representation.
Strings & Basic Operations
Strings in Go are sequences of bytes enclosed in double quotes. Go has a strings package with many helpful functions, and the + operator joins strings. The len() function returns the number of bytes (not always characters for Unicode).
package main
import (
"fmt"
"strings"
)
func main() {
name := "Alice Smith"
lang := "Go"
// Joining strings with +
greeting := "Hello, " + name + "!"
fmt.Println(greeting)
// String length
fmt.Println("Length:", len(name))
// strings package functions
fmt.Println(strings.ToUpper(name))
fmt.Println(strings.ToLower(name))
fmt.Println(strings.Contains(name, "Alice"))
fmt.Println(strings.HasPrefix(name, "Ali"))
fmt.Println(strings.HasSuffix(name, "Smith"))
fmt.Println(strings.Replace(name, "Alice", "Bob", 1))
fmt.Println(strings.TrimSpace(" hello "))
// Multi-line string with backticks
message := `Hello from Go!
This spans multiple lines.
No need for escape characters.`
fmt.Println(message)
}Use backtick strings (raw string literals) when your text spans multiple lines or contains backslashes and quotes. They are like Python's triple-quoted strings โ no escape sequences needed inside.
Control Flow
Go has if/else for decisions, a single for keyword for ALL loops (no while keyword!), and switch for multiple conditions. Go also has a unique defer keyword that delays a function call until the surrounding function returns โ very handy for cleanup.
if / else if / else
Go if statements do not need parentheses around the condition โ that is intentional. The curly braces are always required. A unique Go feature: you can add a short statement before the condition, scoping a variable to just the if block.
package main
import "fmt"
func main() {
score := 73
// Basic if / else if / else
if score >= 80 {
fmt.Println("Grade: A")
} else if score >= 70 {
fmt.Println("Grade: B")
} else if score >= 60 {
fmt.Println("Grade: C")
} else if score >= 50 {
fmt.Println("Grade: D")
} else {
fmt.Println("Grade: F")
}
// if with an init statement โ x only exists inside this if block
if x := score * 2; x > 100 {
fmt.Printf("Double score %d is over 100!\n", x)
} else {
fmt.Printf("Double score is %d\n", x)
}
// Checking errors (the most common Go pattern)
result, err := divide(10, 0)
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("Result:", result)
}
}
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, fmt.Errorf("cannot divide by zero")
}
return a / b, nil
}The "if err != nil" pattern is everywhere in Go. Go does not have exceptions โ errors are just values returned from functions. Always check your errors โ ignoring them is the Go equivalent of catching and swallowing exceptions.
for โ Go's Only Loop
Go has exactly ONE loop keyword: for. But it does the job of for, while, and do-while from other languages. Use it with three parts (classic C-style), with just a condition (like while), or with range to loop over collections.
package main
import "fmt"
func main() {
// Classic for loop (like C)
for i := 1; i <= 5; i++ {
fmt.Printf("Count: %d\n", i)
}
fmt.Println("---")
// "while" style โ just the condition
n := 1
for n < 100 {
n *= 2
}
fmt.Println("First power of 2 over 100:", n)
fmt.Println("---")
// range โ loop over a slice
fruits := []string{"apple", "banana", "cherry"}
for index, fruit := range fruits {
fmt.Printf("%d: %s\n", index, fruit)
}
fmt.Println("---")
// Ignore the index with _
for _, fruit := range fruits {
fmt.Println("I like", fruit)
}
fmt.Println("---")
// break and continue
for i := 0; i < 10; i++ {
if i == 7 {
break
}
if i%2 == 0 {
continue
}
fmt.Print(i, " ")
}
fmt.Println()
}The blank identifier _ discards a value Go would otherwise force you to use. "for _, fruit := range fruits" says "I want the value but not the index". You will see _ constantly in real Go code.
switch & defer
Go switch does not fall through by default (no break needed!). It also supports expressions, multiple values per case, and no condition at all (like a cleaner if/else chain). defer runs a statement when the surrounding function exits โ perfect for cleanup like closing files.
package main
import "fmt"
func main() {
// switch โ no break needed, no fallthrough by default
day := "Wednesday"
switch day {
case "Monday", "Tuesday", "Wednesday", "Thursday", "Friday":
fmt.Println(day, "is a weekday")
case "Saturday", "Sunday":
fmt.Println(day, "is the weekend!")
default:
fmt.Println("Unknown day")
}
// switch with no condition (like if/else chain)
score := 82
switch {
case score >= 90:
fmt.Println("A โ Excellent")
case score >= 80:
fmt.Println("B โ Good")
case score >= 70:
fmt.Println("C โ Average")
default:
fmt.Println("Below average")
}
// defer โ runs LAST, when the function exits
fmt.Println("Start of work")
defer fmt.Println("Cleanup: closing resources") // runs at end
defer fmt.Println("Cleanup: saving state") // runs before above
fmt.Println("Doing work...")
fmt.Println("Finishing work")
// deferred calls run here, in LIFO order
}defer calls stack up in LIFO order (last in, first out) โ like a stack of plates. The most common real-world use: defer file.Close() right after you open a file, so you never forget to close it no matter how the function exits.
Functions
Functions in Go are first-class citizens. Go's most distinctive feature is multiple return values โ a function can return both a result AND an error, which is the foundation of Go's error handling. Functions can also be assigned to variables and passed around.
Defining Functions
The func keyword defines a function. Parameter types come AFTER the name (unlike C/Java). The return type comes after the parameters. If multiple parameters share the same type, you can list them together.
package main
import "fmt"
// Basic function โ no return value
func greet(name string) {
fmt.Printf("Hello, %s!\n", name)
}
// Function with a return value
func square(n int) int {
return n * n
}
// Multiple parameters of the same type โ shorthand
func add(a, b int) int {
return a + b
}
// Multiple return values โ Go's superpower
func minMax(nums []int) (int, int) {
min, max := nums[0], nums[0]
for _, n := range nums {
if n < min { min = n }
if n > max { max = n }
}
return min, max
}
func main() {
greet("Alice")
greet("Bob")
fmt.Println("5ยฒ =", square(5))
fmt.Println("3 + 7 =", add(3, 7))
nums := []int{4, 7, 1, 9, 3, 6}
min, max := minMax(nums)
fmt.Printf("Min: %d, Max: %d\n", min, max)
}Multiple return values are how Go handles errors cleanly. The convention is always: (result, error). The caller must deal with both โ this makes error handling impossible to accidentally skip.
Variadic Functions & First-Class Functions
A variadic function accepts any number of arguments using ... before the type. Functions can be stored in variables, passed as arguments, and returned from other functions โ they are first-class values in Go.
package main
import "fmt"
// Variadic function โ accepts any number of ints
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
// Function as a variable (function type)
func apply(nums []int, fn func(int) int) []int {
result := make([]int, len(nums))
for i, n := range nums {
result[i] = fn(n)
}
return result
}
func main() {
// Variadic calls
fmt.Println(sum(1, 2, 3))
fmt.Println(sum(10, 20, 30, 40))
fmt.Println(sum(100))
// Spread a slice into a variadic function
numbers := []int{1, 2, 3, 4, 5}
fmt.Println(sum(numbers...))
// Anonymous function (lambda)
double := func(n int) int { return n * 2 }
fmt.Println(double(7))
// Pass a function as an argument
doubled := apply(numbers, func(n int) int { return n * 2 })
fmt.Println(doubled)
squared := apply(numbers, func(n int) int { return n * n })
fmt.Println(squared)
}To pass a slice to a variadic function, append ... after the slice name: sum(numbers...). Without the ..., Go would try to pass the whole slice as a single argument, which is a type mismatch.
Structs & Methods
Go does not have classes โ it uses structs to group related data together. Methods are functions attached to a struct. This gives you the benefits of object-oriented programming (organising code around data) without the complexity of inheritance.
Defining Structs & Creating Instances
A struct is a collection of fields, each with a name and type. Create an instance by listing field values, or use field names for clarity (recommended). Access fields with the dot (.) operator.
package main
import "fmt"
// Define a struct
type Student struct {
Name string
Age int
Grade string
Score float64
}
// Another struct
type Address struct {
Street string
City string
Code string
}
// Struct with nested struct
type Teacher struct {
Name string
Subject string
Address Address // embedded struct
}
func main() {
// Create instances
alice := Student{
Name: "Alice",
Age: 20,
Grade: "A",
Score: 95.5,
}
bob := Student{"Bob", 18, "B", 82.0} // positional (avoid!)
fmt.Printf("Name: %s, Score: %.1f, Grade: %s\n",
alice.Name, alice.Score, alice.Grade)
// Modify a field
bob.Score = 85.0
fmt.Printf("Bob updated: %.1f\n", bob.Score)
// Nested struct access
teacher := Teacher{
Name: "Mr Adams",
Subject: "Computer Science",
Address: Address{
Street: "123 Main St",
City: "Cape Town",
Code: "8001",
},
}
fmt.Printf("%s lives in %s\n", teacher.Name, teacher.Address.City)
}Always use named fields when creating structs: Student{Name: "Alice", Age: 20}. Positional struct literals (Student{"Alice", 20}) break if someone adds a field to the struct in the middle โ a painful bug to track down.
Methods on Structs
A method is a function with a "receiver" โ the struct it belongs to. Use a pointer receiver (*Student) when you want to modify the struct. Use a value receiver (Student) when you only need to read. By convention, keep them consistent across a type.
package main
import "fmt"
type Student struct {
Name string
Score float64
}
// Value receiver โ reads only, does not modify
func (s Student) Grade() string {
switch {
case s.Score >= 90:
return "A"
case s.Score >= 80:
return "B"
case s.Score >= 70:
return "C"
case s.Score >= 60:
return "D"
default:
return "F"
}
}
// Value receiver โ formats the struct as a string
func (s Student) String() string {
return fmt.Sprintf("%s (%.0f%% โ %s)", s.Name, s.Score, s.Grade())
}
// Pointer receiver โ modifies the original struct
func (s *Student) AddBonus(points float64) {
s.Score += points
if s.Score > 100 {
s.Score = 100
}
}
func main() {
alice := Student{Name: "Alice", Score: 87.5}
bob := Student{Name: "Bob", Score: 62.0}
fmt.Println(alice.String())
fmt.Println(bob.String())
// Pointer receiver modifies alice directly
alice.AddBonus(5)
fmt.Println("After bonus:", alice.String())
bob.AddBonus(10)
fmt.Println("After bonus:", bob.String())
}Use a pointer receiver (*Student) when the method needs to change the struct, or when the struct is large (avoids copying). Use a value receiver (Student) for small read-only methods. Be consistent โ if one method uses a pointer receiver, all methods on that type should.
Arrays, Slices & Maps
Arrays in Go have a fixed size โ once created, they cannot grow. Slices are the dynamic, flexible alternative you will use almost always. Maps store key-value pairs โ like Python dictionaries or JavaScript objects. These three data structures cover the vast majority of real Go programs.
Slices โ Dynamic Arrays
A slice is a view into an underlying array but with dynamic length. Create them with []Type{values} or make([]Type, length). Use append() to add items โ it returns a new slice. len() gives the current length, cap() the capacity.
package main
import "fmt"
func main() {
// Create a slice with initial values
fruits := []string{"apple", "banana", "cherry"}
fmt.Println(fruits)
fmt.Println("Length:", len(fruits))
// Access by index
fmt.Println("First:", fruits[0])
fmt.Println("Last:", fruits[len(fruits)-1])
// Append โ always save the return value!
fruits = append(fruits, "mango")
fruits = append(fruits, "kiwi", "grape") // append multiple
fmt.Println("After append:", fruits)
// Slicing a slice [start:end] (end is exclusive)
fmt.Println("First 3:", fruits[:3])
fmt.Println("Last 2:", fruits[len(fruits)-2:])
fmt.Println("Middle:", fruits[2:5])
// make โ create a slice with specific length/capacity
scores := make([]int, 5) // 5 zeros
for i := range scores {
scores[i] = (i + 1) * 10
}
fmt.Println("Scores:", scores)
// Iterate with range
for i, fruit := range fruits {
fmt.Printf(" [%d] %s\n", i, fruit)
}
}Never forget to save the result of append: fruits = append(fruits, "mango"). If you write just append(fruits, "mango") without saving, the original slice is unchanged. This is a very common beginner mistake in Go.
Maps โ Key-Value Storage
A map stores values indexed by keys. Create with make(map[KeyType]ValueType) or with a map literal. When you look up a key, Go returns TWO values โ the value AND a boolean saying whether the key existed. Always check that second value!
package main
import "fmt"
func main() {
// Create a map with a literal
grades := map[string]string{
"Alice": "A",
"Bob": "B",
"Charlie": "A",
"Diana": "C",
}
// Access a value
fmt.Println("Alice's grade:", grades["Alice"])
// Safe lookup โ check if key exists
grade, ok := grades["Edward"]
if ok {
fmt.Println("Edward:", grade)
} else {
fmt.Println("Edward not found")
}
// Add a new key
grades["Edward"] = "B"
fmt.Println("After adding Edward:", grades["Edward"])
// Delete a key
delete(grades, "Diana")
fmt.Println("After deleting Diana, length:", len(grades))
// Iterate over a map
fmt.Println("All grades:")
for name, grade := range grades {
fmt.Printf(" %s: %s\n", name, grade)
}
// Map of slices โ a common pattern
courses := map[string][]string{
"Alice": {"Maths", "Science", "Go Programming"},
"Bob": {"History", "English"},
}
fmt.Println("Alice's courses:", courses["Alice"])
}Map iteration order in Go is intentionally random โ every run may print in a different order. This is by design to prevent programmers from depending on map order. If you need sorted output, extract the keys into a slice and sort it first.
Goroutines & Channels
Go's killer feature is built-in concurrency. A goroutine is a lightweight thread โ you can run thousands of them cheaply. Channels are typed pipes that goroutines use to communicate safely. Together they make concurrent programming much simpler than in most languages.
Goroutines โ go keyword
Add the go keyword before a function call to run it as a goroutine โ it starts immediately in the background while your main program continues. The main() function is itself a goroutine. When main() exits, all goroutines stop, so you need to wait for them.
package main
import (
"fmt"
"sync"
"time"
)
func printNumbers(label string) {
for i := 1; i <= 5; i++ {
fmt.Printf("[%s] %d\n", label, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
// WaitGroup waits for goroutines to finish
var wg sync.WaitGroup
// Launch 3 goroutines simultaneously
wg.Add(3)
go func() {
defer wg.Done()
printNumbers("A")
}()
go func() {
defer wg.Done()
printNumbers("B")
}()
go func() {
defer wg.Done()
printNumbers("C")
}()
fmt.Println("All goroutines launched โ waiting...")
wg.Wait() // block until all goroutines call Done()
fmt.Println("All done!")
}sync.WaitGroup is the standard way to wait for a collection of goroutines to finish. Add(n) says "I am launching n goroutines". Each goroutine calls Done() when it finishes. Wait() blocks until the counter reaches zero.
Channels โ Safe Communication
A channel is a typed pipe between goroutines. Send a value with ch <- value. Receive a value with value := <-ch. Sending and receiving block until the other side is ready โ this natural synchronisation prevents race conditions.
package main
import "fmt"
// Worker function sends results down a channel
func double(nums []int, result chan<- int) {
for _, n := range nums {
result <- n * 2 // send to channel
}
close(result) // signal: no more values
}
func main() {
// Buffered channel โ holds up to 5 values without blocking
results := make(chan int, 5)
numbers := []int{1, 2, 3, 4, 5}
// Launch goroutine to do the work
go double(numbers, results)
// Receive all results from the channel
for val := range results {
fmt.Println("Got:", val)
}
fmt.Println("---")
// Simple ping-pong with channels
ping := make(chan string, 1)
pong := make(chan string, 1)
go func() {
msg := <-ping // wait for message
pong <- msg + " pong" // send response
}()
ping <- "ping"
response := <-pong
fmt.Println(response)
}The Go concurrency mantra: "Do not communicate by sharing memory; instead, share memory by communicating." Channels encourage you to pass data between goroutines rather than letting multiple goroutines read/write the same variable โ which would cause race conditions.
You finished the Go tutorial!
You now know variables, control flow, functions, structs, slices, maps, goroutines and channels. These are the core building blocks of real Go programs used in production every day.