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

Go basic types and their zero values

Here are the ones you’ll use daily:

  • int, int64, int32, uint8… whole numbers. Use plain int unless you have a reason not to.
  • float64 for decimals. Prefer it over float32.
  • string for text. Strings in Go are immutable and stored as UTF-8 bytes.
  • bool is true or false.
  • byte is an alias for uint8, and rune is an alias for int32, 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, and var when you want the zero value or are at package level.
  • Keep variable names short when the scope is short. i, n, err are fine in a three-line loop. Longer-lived things deserve clearer names like retryCount.
  • Use the if x := ...; cond form to keep temporary variables out of the wider scope.
  • Return errors, don’t hide them. If something can fail, return an error as the last value.
  • Put defer right 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).
  • for is the only loop, and switch doesn’t fall through.
  • Functions can return multiple values, with the error last by convention.
  • defer runs 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

  1. Part 1: Why Go, Setting It Up, and Your First Program
  2. Part 2: Variables, Types, Control Flow, and Functions (you are here)
  3. Part 3: Arrays, Slices, Maps, and Structs
  4. Part 4: Methods, Interfaces, and Composition
  5. Part 5: Error Handling the Go Way
  6. Part 6: Packages, Modules, and Project Layout
  7. Part 7: Goroutines, Channels, and select
  8. Part 8: sync, context, and Concurrency Patterns
  9. Part 9: Generics Without the Headache
  10. Part 10: Testing, Benchmarks, and Fuzzing
  11. Part 11: Best Practices for Production-Ready Go
  12. 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.

comments powered by Disqus

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 more

Go 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 more

Go 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