Getting Started with Go, Part 1: Setup & Syntax Basics
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:
go version
Then start a module — the unit Go uses to track your code and its dependencies, replacing the old GOPATH convention entirely:
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
Toolinggo 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.go fmt ./... # formats all Go files across every package in your moduleExperiment quickly with go run without leaving leftover binaries
Workflowgo 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.go run . # compile and run on the fly
go build -o bin/app # produce standalone static binaryVariables, types, and zero values
Go is statically typed, but it infers types constantly so it rarely feels like it:
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
Idiom0, "", 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).var buf bytes.Buffer
buf.WriteString("Ready without constructor or new()!")Go never performs implicit type conversion
Idiomint 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.var count int = 5
var total int64 = int64(count) // must convert explicitlyWatch out for accidental variable shadowing with :=
Gotcha:= 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.var err error
if val, err := compute(); err != nil {
// this 'err' is scoped only to the if-block!
}
// the outer 'err' is still nil hereControl 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:
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:
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:
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
Gotchafor _, 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.for i := range jobs {
jobs[i].Status = "processed" // modifies the actual slice item
}Always reassign the return value of append
Gotchaappend 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.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:
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:
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_ to discard it without triggering a compiler error.val, _ := strconv.Atoi("42") // explicitly discard the error returnCapitalization alone dictates package visibility
Syntaxpublic 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.func ExportedTask() {} // callable from any package
func internalTask() {} // accessible only inside this packagePair resource acquisition with defer for reliable cleanup
Idiomfinally 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.f, err := os.Open("jobs.txt")
if err != nil {
return err
}
defer f.Close() // runs when the enclosing function exitsThe 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:
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
Workflowos.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.if len(os.Args) > 1 {
inputs := os.Args[1:] // all user inputs
}Further Reading
- A Tour of Go — the official interactive intro, good for drilling syntax further
- Effective Go — idioms and conventions, worth reading once you're past the basics
- The Go Programming Language Specification — the actual source of truth when a rule seems surprising