Back to Writing
05 — Engineering
Getting Started with Go · Part 1 of 5

Getting Started with Go, Part 1: Setup & Syntax Basics

Cyrus Kiprop·July 6, 2026·5 min read

I'm not going to teach Go as a list of keywords. Over this five-part series we're going to build one small program — a job processor, the kind of thing that classifies incoming work and hands it off to be handled — and grow it as we go. Part 1 is variables, control flow, and functions. By part 5 it's a worker pool with graceful shutdown, structured logging, and a CPU profile you can actually capture. Every complete program in this series has a Run button next to Copy — it executes on the real Go Playground infrastructure, not a simulation, so what you see is genuinely what Go produces.

Installing Go and starting a module

Grab the toolchain from go.dev/dl — there's no separate package manager to install alongside it, go is a single binary that compiles, tests, formats, and manages dependencies. Confirm it landed:

bash
go version

Then start a module — the unit Go uses to track your code and its dependencies, replacing the old GOPATH convention entirely:

bash
mkdir jobrunner && cd jobrunner
go mod init jobrunner

That gives you a go.mod file with your module's name and the Go version you're targeting. You won't touch it much by hand; go get and go mod tidy manage it for you once there are real dependencies to track. We won't need any for a while — the standard library covers everything in this series.

Always format on save with go fmt

Tooling
Go eliminates code style arguments completely by shipping go fmt directly in the toolchain. There are no configuration files or tabs-versus-spaces debates. Configure your editor to run go fmt (or goimports to auto-manage imports) automatically on save so your code always adheres to official Go conventions.
bash
go fmt ./...   # formats all Go files across every package in your module

Experiment quickly with go run without leaving leftover binaries

Workflow
When testing or prototyping small programs, use go run . or go run main.go. It compiles your code directly into a temporary cache and runs it in one step, leaving no binary cluttering your working directory. When you are ready for a production binary, use go build -o bin/app.
bash
go run .            # compile and run on the fly
go build -o bin/app # produce standalone static binary

Variables, types, and zero values

Go is statically typed, but it infers types constantly so it rarely feels like it:

go
var name string = "urgent-deploy"  // explicit
name2 := "cleanup"                 // inferred — the common form
var retries int                    // no initializer: zero value, 0

That last line matters more than it looks. Go doesn't have "uninitialized" variables — every type has a zero value (0 for numbers, "" for strings, false for bools, nil for pointers/slices/maps/interfaces), and a declared-but-unassigned variable always gets it. There's no null-pointer-style trap waiting from a variable you forgot to set; there's just a defined, sometimes-inconvenient default you have to be aware of.

:= only works inside functions — it declares and infers in one step, and it's what you'll use for nearly everything. var shows up at package level, or when you want the zero value explicitly, or need a type wider than what the literal would infer (var priority int64 = 3).

Design structs so zero values are immediately ready

Idiom
In Go, variables without explicit initializers automatically receive their type's zero value (0, "", false, nil). Idiomatic Go packages design types so that this default zero value is completely functional and ready to use without requiring an explicit constructor (e.g., bytes.Buffer, sync.Mutex, sync.WaitGroup).
go
var buf bytes.Buffer
buf.WriteString("Ready without constructor or new()!")

Go never performs implicit type conversion

Idiom
Even between compatible numeric types like int and int64, or float64 and int, Go strictly forbids automatic implicit coercion. You must always cast explicitly using T(v) syntax, making potential precision loss or overflow visible at the call site.
go
var count int = 5
var total int64 = int64(count) // must convert explicitly

Watch out for accidental variable shadowing with :=

Gotcha
The short variable declaration := always creates a new variable in the local lexical block. If you use := inside an if block, switch case, or loop with the same name as an outer variable, you will create a new shadowed variable instead of updating the outer one. Use = when you intend to reassign.
go
var err error
if val, err := compute(); err != nil {
    // this 'err' is scoped only to the if-block!
}
// the outer 'err' is still nil here

Control flow: if, for, switch

Go strips the ceremony down to three constructs. if needs no parentheses around the condition, but the braces are mandatory even for one-liners:

go
if retries > 3 {
    fmt.Println("giving up")
} else if retries > 0 {
    fmt.Println("retrying")
}

for is the only loop keyword — no while, no do-while. It just omits parts of the classic three-clause form to get there:

go
for i := 0; i < len(jobs); i++ { /* classic */ }
for retries < 3 { /* while-style */ }
for {              /* infinite, break to exit */ }
for i, job := range jobs { /* range: index + value */ }

switch doesn't fall through by default (the opposite of C/Java — you opt in with fallthrough if you actually want it), and it doesn't need a tag value at all, which makes it a clean replacement for an if/else-if chain:

go
switch {
case strings.HasPrefix(name, "urgent-"):
    return "HIGH"
case strings.HasPrefix(name, "batch-"):
    return "LOW"
default:
    return "NORMAL"
}

Range loops evaluate elements by value copy

Gotcha
When iterating over a slice with for _, item := range items, the iteration variable item is a local copy of each element, not a reference. Modifying fields on item does not update the element in the slice. To mutate in place, index into the slice directly with for i := range items.
go
for i := range jobs {
    jobs[i].Status = "processed" // modifies the actual slice item
}

Always reassign the return value of append

Gotcha
A slice in Go is a header containing a pointer to an array, length, and capacity. When append runs out of capacity, it allocates a brand-new backing array and returns a slice header pointing to it. Failing to assign back (jobs = append(...)) can lead to lost data or subtle bugs.
go
jobs = append(jobs, "new-task") // always reassign to 'jobs'

Functions and multiple return values

Functions declare parameter types after the name and the return type after the parameter list:

go
func classify(name string) string {
    // ...
}

The feature that shapes idiomatic Go more than any other single thing: functions can return more than one value, and the convention that grew out of it is (result, error) — no exceptions, no try/catch, just an error value you're expected to check immediately:

go
func parsePriority(s string) (int, error) {
    switch s {
    case "HIGH":
        return 2, nil
    case "NORMAL":
        return 1, nil
    case "LOW":
        return 0, nil
    default:
        return 0, fmt.Errorf("unknown priority %q", s)
    }
}

We'll build on this return-an-error convention properly in Part 2, once we have real types worth failing on.

Use the blank identifier _ to discard unwanted returns

Syntax
Go treats unused local variables and unused package imports as compile-time errors to keep codebases lean. When a function returns multiple values (like a result and an error) and you explicitly choose not to use one, assign it to the blank identifier _ to discard it without triggering a compiler error.
go
val, _ := strconv.Atoi("42") // explicitly discard the error return

Capitalization alone dictates package visibility

Syntax
Go has no public or private keywords. An identifier (function, type, constant, variable, or struct field) starting with an uppercase letter is exported and visible outside its package. Starting with a lowercase letter makes it package-private.
go
func ExportedTask() {} // callable from any package
func internalTask() {} // accessible only inside this package

Pair resource acquisition with defer for reliable cleanup

Idiom
Go does not have finally blocks. Instead, defer schedules a function call to execute immediately when the enclosing function returns, regardless of which return branch was taken. Always put cleanup calls like defer file.Close() immediately after confirming successful error-free acquisition.
go
f, err := os.Open("jobs.txt")
if err != nil {
    return err
}
defer f.Close() // runs when the enclosing function exits

The first version of jobrunner

Here's everything above in one program — classify a list of jobs by name prefix and print them with their priority. This is a complete program, so it's runnable — hit Run and it executes for real:

go
package main

import (
	"fmt"
	"strings"
)

func classify(name string) string {
	switch {
	case strings.HasPrefix(name, "urgent-"):
		return "HIGH"
	case strings.HasPrefix(name, "batch-"):
		return "LOW"
	default:
		return "NORMAL"
	}
}

func main() {
	jobs := []string{"urgent-deploy", "cleanup", "urgent-migrate", "batch-report"}

	for i, name := range jobs {
		priority := classify(name)
		fmt.Printf("%d. [%s] %s\n", i+1, priority, name)
	}
}

The jobs slice is hardcoded here so the example runs standalone in a sandbox with no arguments — in a real CLI you'd read it from os.Args[1:] instead (index 0 is always the program's own path). That's a one-line swap once you're running this on your own machine, and it's the natural next step if you want to try it locally.

That's the whole toolkit this part needed: variables with real zero values, if/for/switch, and functions — enough to write real programs, just not yet ones that model a problem with actual types. That's Part 2.

CLI arguments start at os.Args[1], not index 0

Workflow
os.Args[0] contains the path of the program itself. When reading user-supplied CLI arguments for programs like our jobrunner, always slice starting at index 1: os.Args[1:]. Be sure to verify len(os.Args) > 1 first to avoid index out of range runtime panics.
go
if len(os.Args) > 1 {
    inputs := os.Args[1:] // all user inputs
}

Further Reading