Go From Zero, Part 2: Variables, Types, Control Flow, and Functions
This is Part 2 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 1: Why Go, Setting It Up, and Your First Program is the place to catch up.
Last time we installed Go and wrote our first program. Today we learn the pieces that every Go file is built from: variables, types, constants, if, for, switch and functions.
If you’ve used another language before, most of this will feel familiar. Pay attention to the small differences, though. They’re where Go’s personality shows up.
Variables: three ways to say the same thing
Go gives you a few ways to declare a variable. They all do the same job, just with different amounts of typing.
package main
import "fmt"
func main() {
var city string = "Pune" // full form: name, type, value
var year = 2026 // type inferred from the value
temp := 31.5 // short form, the one you'll use most
var count int // no value given, so it gets the "zero value": 0
fmt.Println(city, year, temp, count)
}
The := short form is what you’ll write 90% of the time inside functions. It declares and assigns in one step, and Go infers the type. You can only use it inside functions, though. At the package level you need var.
Zero values: no “undefined” in Go
Every variable in Go always has a value. If you don’t give it one, it gets the zero value for its type:
- numbers:
0 - strings:
""(empty string) - booleans:
false - pointers, slices, maps, channels, functions, interfaces:
nil
This sounds small but it’s a big deal. There’s no “uninitialised variable” bug in Go. A good Go habit is to design your types so their zero value is useful, and we’ll see that pattern again in Part 3 and Part 8.
Basic types

Here are the ones you’ll use daily:
int,int64,int32,uint8… whole numbers. Use plainintunless you have a reason not to.float64for decimals. Prefer it overfloat32.stringfor text. Strings in Go are immutable and stored as UTF-8 bytes.boolistrueorfalse.byteis an alias foruint8, andruneis an alias forint32, used for a single Unicode character.
Go never converts types behind your back
This trips up almost everyone on day one:
package main
import "fmt"
func main() {
apples := 3
price := 12.5
// total := apples * price // compile error: mismatched types int and float64
total := float64(apples) * price
fmt.Println(total) // 37.5
}
In Go you convert explicitly with Type(value). It feels fussy at first, but it means you’ll never get a surprise rounding bug because the language “helpfully” changed a type.
Strings, bytes and runes
len on a string gives you the number of bytes, not characters. For plain English that’s the same thing. For anything else, it isn’t.
package main
import (
"fmt"
"unicode/utf8"
)
func main() {
s := "héllo"
fmt.Println(len(s)) // 6 bytes, because é takes 2
fmt.Println(utf8.RuneCountInString(s)) // 5 characters
for i, r := range s {
fmt.Printf("%d:%c ", i, r)
}
fmt.Println()
// 0:h 1:é 3:l 4:l 5:o
}
Looping with range over a string walks through runes (characters), and the index jumps by however many bytes each one takes. Remember this if you ever handle names, emoji or any non-English text.
Constants and iota
Constants are values fixed at compile time. Go has a neat helper called iota for numbered constants, which is how you’d build something like an enum.
package main
import "fmt"
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1
Tuesday // 2
Wednesday // 3
)
const Pi = 3.14159
func main() {
fmt.Println(Monday, Wednesday, Pi) // 1 3 3.14159
}
iota starts at 0 and goes up by one for each line in the const block. You’ll see this pattern all over Go code.
if: no parentheses, braces always
package main
import (
"fmt"
"strconv"
)
func main() {
score := 72
if score >= 90 {
fmt.Println("A")
} else if score >= 70 {
fmt.Println("B")
} else {
fmt.Println("Keep going")
}
// if with a short statement: n and err only exist inside this if/else
if n, err := strconv.Atoi("42"); err == nil {
fmt.Println("parsed:", n)
}
}
The second form, with a short statement before the condition, is very idiomatic. It keeps temporary variables scoped tightly to where they’re used. You’ll see if err := doThing(); err != nil { constantly.
for: the only loop you need
Go has one loop keyword. It covers every case other languages split into for, while and do-while.
package main
import "fmt"
func main() {
// classic three-part loop
for i := 0; i < 3; i++ {
fmt.Print(i, " ")
}
fmt.Println()
// "while" style
n := 1
for n < 100 {
n *= 2
}
fmt.Println(n) // 128
// range over an integer (Go 1.22+)
for i := range 3 {
fmt.Print(i, " ")
}
fmt.Println()
// range over a slice: index and value
langs := []string{"Go", "Rust", "Python"}
for i, lang := range langs {
fmt.Println(i, lang)
}
// infinite loop with break and continue
count := 0
for {
count++
if count%2 == 0 {
continue
}
if count > 7 {
break
}
fmt.Print(count, " ")
}
fmt.Println()
}
If you don’t need the index from range, use the blank identifier _: for _, lang := range langs. The underscore means “I know there’s a value here and I’m choosing to ignore it.”
switch: cleaner than a pile of ifs
Go’s switch doesn’t fall through to the next case by default, so you don’t need break everywhere.
package main
import (
"fmt"
"time"
)
func main() {
day := time.Now().Weekday()
switch day {
case time.Saturday, time.Sunday:
fmt.Println("Weekend")
default:
fmt.Println("Weekday")
}
// switch with no value works like a tidy if/else chain
temp := 31
switch {
case temp > 35:
fmt.Println("Too hot")
case temp > 25:
fmt.Println("Warm")
default:
fmt.Println("Pleasant")
}
}
Functions
Functions are declared with func. Types come after names, which feels backwards for about a day and then reads naturally: “a is an int, b is an int, returns an int”.
package main
import (
"errors"
"fmt"
)
func add(a, b int) int {
return a + b
}
// Functions can return more than one value.
// The Go convention: the last return value is an error.
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("cannot divide by zero")
}
return a / b, nil
}
// Variadic: accepts any number of ints.
func sum(nums ...int) int {
total := 0
for _, n := range nums {
total += n
}
return total
}
func main() {
fmt.Println(add(2, 3))
result, err := divide(10, 0)
if err != nil {
fmt.Println("error:", err)
} else {
fmt.Println(result)
}
fmt.Println(sum(1, 2, 3, 4))
values := []int{5, 6, 7}
fmt.Println(sum(values...)) // spread a slice into variadic args
}
Multiple return values are one of Go’s defining features. Instead of throwing exceptions, functions return an error alongside the result, and you check it right away. Part 5 is dedicated to doing this well.
Functions are values
You can store a function in a variable, pass it around, and return it. A function that captures variables from around it is called a closure.
package main
import "fmt"
func counter() func() int {
count := 0
return func() int {
count++
return count
}
}
func main() {
next := counter()
fmt.Println(next(), next(), next()) // 1 2 3
apply := func(nums []int, f func(int) int) []int {
out := make([]int, 0, len(nums))
for _, n := range nums {
out = append(out, f(n))
}
return out
}
double := func(n int) int { return n * 2 }
fmt.Println(apply([]int{1, 2, 3}, double)) // [2 4 6]
}
defer: clean up on the way out
defer schedules a call to run when the surrounding function returns. It’s how Go handles cleanup, like closing files.
package main
import "fmt"
func main() {
fmt.Println("start")
defer fmt.Println("deferred 1")
defer fmt.Println("deferred 2")
fmt.Println("end")
}
// start
// end
// deferred 2
// deferred 1
Deferred calls run in reverse order, like a stack of plates. The classic use is right after opening something: f, err := os.Open(path), check the error, then defer f.Close(). Opening and closing sit next to each other, so you can’t forget.
Pointers, briefly
A pointer holds the memory address of a value. &x gets the address, *p reads or writes the value it points to.
package main
import "fmt"
func resetByValue(n int) { n = 0 }
func resetByPointer(n *int) { *n = 0 }
func main() {
score := 50
resetByValue(score)
fmt.Println(score) // 50, the function only changed its own copy
resetByPointer(&score)
fmt.Println(score) // 0
}
Go passes everything by value, meaning functions get a copy. If you want a function to modify the caller’s variable, pass a pointer. There’s no pointer arithmetic, so pointers in Go are much safer than in C. We’ll use them a lot with structs in Parts 3 and 4.
Best practices from this part
- Prefer
:=inside functions, andvarwhen you want the zero value or are at package level. - Keep variable names short when the scope is short.
i,n,errare fine in a three-line loop. Longer-lived things deserve clearer names likeretryCount. - Use the
if x := ...; condform to keep temporary variables out of the wider scope. - Return errors, don’t hide them. If something can fail, return an
erroras the last value. - Put
deferright after acquiring a resource, so cleanup is never forgotten. - Don’t use pointers just to “be efficient”. Pass small values by value. Use pointers when you need to modify something or when a struct is large.
Recap
- Every variable has a zero value, so nothing is ever undefined.
- Go never converts types implicitly. You write
float64(x). foris the only loop, andswitchdoesn’t fall through.- Functions can return multiple values, with the error last by convention.
deferruns cleanup when a function returns, in reverse order.
Try this: write a function minMax(nums ...int) (int, int) that returns the smallest and largest number. What should it do when called with no numbers? (Thinking about that edge case is very Go.)
Next up, in Part 3, we’ll organise data with arrays, slices, maps and structs, and look at how slices really work under the hood. That one picture will save you from a whole category of bugs.
Where to go from here
⬅️ Previous: Part 1: Why Go, Setting It Up, and Your First Program
➡️ Next: Part 3: Arrays, Slices, Maps, and Structs
The full Go From Zero series
- Part 1: Why Go, Setting It Up, and Your First Program
- Part 2: Variables, Types, Control Flow, and Functions (you are here)
- Part 3: Arrays, Slices, Maps, and Structs
- Part 4: Methods, Interfaces, and Composition
- Part 5: Error Handling the Go Way
- Part 6: Packages, Modules, and Project Layout
- Part 7: Goroutines, Channels, and select
- Part 8: sync, context, and Concurrency Patterns
- Part 9: Generics Without the Headache
- Part 10: Testing, Benchmarks, and Fuzzing
- Part 11: Best Practices for Production-Ready Go
- Part 12: Building a REST API with the Standard Library
If this helped you, share it with a friend who is learning Go, or subscribe to get the next posts by email. Questions are welcome in the comments.
Releted Posts
Go From Zero, Part 1: Why Go, Setting It Up, and Your First Program
This is Part 1 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. You are at the very beginning, which is a great place to be.
Read moreGo From Zero, Part 12: Building a REST API with the Standard Library
This is Part 12 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 11: Best Practices for Production-Ready Go is the place to catch up.
Read moreGo From Zero, Part 11: Best Practices for Production-Ready Go
This is Part 11 of 12 in Go From Zero, a beginner-to-production series on the Go programming language. If you missed it, Part 10: Testing, Benchmarks, and Fuzzing is the place to catch up.
Read more