Go Basics
Irbisa · cheatsheetSeptember 19, 2026

Go Basics

Syntax, zero values, constants and iota, control flow, defer and closures

Junior Developer16 itemscompressed for a skim
  1. 01

    You split a working main.go into a store package and the build now fails with undefined: store.parseRow, even though the function is right there in the file you moved. What rule are you hitting?

    Easy

    Go's only access control is the case of the identifier's first letterparseRow starts lowercase, so it is visible everywhere inside package store and nowhere outside it.

    // store/row.go — one directory, one package; every file in it sees the others.
    package store
    
    type Row struct {
    	ID   int    // exported: other packages and encoding/json can see it
    	name string // unexported: invisible outside package store, and to json
    …
  2. 02

    A reviewer asks why your Server literal sets two of its six fields and the code still runs. What did Go put in the other four, and which of those are usable as they stand?

    Easy

    Go initialises every declaration it compiles — there is no uninitialised variable in the language — so the other four hold their zero value; the real question is which zero values are ready to use and which are merely nil.

    type Server struct {
    	Addr    string          // ""
    	Timeout time.Duration   // 0
    	mu      sync.Mutex      // usable with no constructor
    	buf     bytes.Buffer    // ditto
    	tags    []string        // nil
    …
  3. 03

    version := "1.0" at the top of a file is a syntax error while the identical line inside main compiles. Where is each declaration form legal, and when must you still reach for var?

    Easy

    The short variable declaration := is legal only inside a function body; at package scope every declaration has to begin with a keyword — var, const, func or type.

    // ✅ package scope: every declaration starts with a keyword.
    // `build := "dev"` out here is a syntax error.
    var build = "dev"
    
    const maxRetries = 3
    …
  4. 04

    Go has exactly one loop keyword. Name the shapes it takes, and say which one refuses to compile in a module whose go.mod still says go 1.21.

    Easy

    for covers all of them — three-clause, condition-only, infinite and range — and the shape that needs a newer module is for i := range 10, the integer range added in Go 1.22.

    // 1. three-clause — init, condition and post are each optional
    for i := 0; i < len(rows); i++ {
    	use(rows[i])
    }
    
    // 2. condition only — this is Go's while
    …
  5. 05

    go run . is fine on your laptop, but the binary your Dockerfile builds will not start at all in a scratch image. What do go run, go build and go install each actually do, and what is missing from that build?

    Medium

    The three commands compile identically and differ only in where the result lands — a temp directory go run then deletes, the current directory for go build, $GOBIN for go install — and the scratch failure is cgo: the default CGO_ENABLED=1 links against the host's libc, which a scratch image does not contain.

    // cmd/api/main.go — only a package named main produces an executable.
    package main
    
    // Stamped by the linker, never by hand:
    //   go build -trimpath -ldflags="-s -w -X main.version=$(git rev-parse --short HEAD)" \
    //       -o bin/api ./cmd/api
    …
  6. 06

    A production log line reads user=%!s(int=42) ok=%!v(MISSING). Reconstruct the Printf call from those two markers, and say which tool should have caught it.

    Medium

    fmt never panics on a bad format — it writes the error into the output — so %!s(int=42) is a %s handed an int and %!v(MISSING) is a verb with no argument left: the call was fmt.Printf("user=%s ok=%v", u.ID).

    u := User{ID: 42, Name: "ada"}
    
    // ❌ every one of these is a go vet finding, and every one of them still runs
    fmt.Printf("user=%s ok=%v\n", u.ID) // user=%!s(int=42) ok=%!v(MISSING)
    fmt.Printf("%d\n", u.ID, u.Name)    // 42, then %!(EXTRA string=ada)
    fmt.Printf("100%")                  // 100%!(NOVERB)
    …
  7. 07

    const timeout = 30 followed by time.Sleep(timeout * time.Second) compiles. Change the first line to var timeout = 30 and the build breaks. What is different about the two?

    Medium

    const timeout = 30 is an untyped constant: it has a default type but no actual type until it is used, so at the multiplication it becomes a time.Duration.

    type State uint8
    
    const (
    	StateUnknown State = iota // 0 — the zero value; make it the honest default
    	StatePending
    	StateRunning
    …
  8. 08

    pct := done / total * 100 logs 0 when 3 of 4 items are done, and float64(done/total) * 100 logs 0 as well. What is Go doing, and what is the rule behind it?

    Medium

    Both operands are int, so / is integer division and truncates toward zero before anything else runs — 3/4 is already 0 by the time the conversion sees it.

    done, total := 3, 4
    
    pct := done / total * 100        // ❌ 0 — integer division truncates first
    bad := float64(done/total) * 100 // ❌ 0 — the conversion is one step too late
    good := float64(done) / float64(total) * 100 // ✅ 75
    …
  9. 09

    Inside for _, job := range jobs { switch job.Kind { case "stop": break ... } } the break never ends the loop, and everything after the stop marker is processed anyway. Why, and what are the ways out?

    Medium

    break binds to the innermost for, switch or select — inside a case it only leaves the switch, which was about to end anyway.

    // ❌ break leaves the switch, which was ending anyway; the loop keeps going
    for _, job := range jobs {
    	switch job.Kind {
    	case "stop":
    		break
    	default:
    …
  10. 10

    A loop that opens 4 000 files with defer f.Close() in its body dies with too many open files, and the defer fmt.Println("took", time.Since(start)) on line two of the same function always reports a few microseconds. What are the two rules at work?

    Medium

    A deferred call's arguments are evaluated at the defer statement while the call itself runs when the enclosing function returns — so time.Since(start) was computed immediately, and all 4 000 Close calls queued up until the function returned.

    func timed() {
    	start := time.Now()
    	defer fmt.Println("took", time.Since(start))          // ❌ evaluated NOW: ~19µs
    	defer func() { fmt.Println("took", time.Since(start)) }() // ✅ read at return
    	work()
    }
    …
  11. 11

    func scale(factor int, xs ...int) []int mutates the caller's slice when it is called as scale(2, a...) but not as scale(2, 1, 2, 3). What is different about the two call sites?

    Medium

    Passing a slice with s... does not copy it — the variadic parameter is that slice, sharing its backing array — whereas the value-by-value form makes the compiler build a fresh slice the callee owns.

    func scale(factor int, xs ...int) []int {
    	for i := range xs {
    		xs[i] *= factor // writes straight into whatever slice the caller passed
    	}
    	return xs
    }
    …
  12. 12

    applyDefaults(cfg Config) sets three fields and the caller sees none of them. What is Go's argument-passing rule, and why does the fix use &Config{} rather than new(Config)?

    Medium

    Go passes everything by value, so the function mutated a copy; it has to take *Config.

    type Config struct {
    	Addr    string
    	Timeout time.Duration
    }
    
    // ❌ cfg is a copy; the caller sees none of this
    …
  13. 13

    A retry helper logs the error from every attempt and still hands the caller nil. The only odd-looking line is if resp, err := http.Get(u); err != nil {. What did that := do, and why is the build clean?

    Hard

    The := in the if header declared new resp and err variables scoped to that if statement, so the outer err the function returns was never assigned.

    // ❌ returns nil for every url, and logs a real error for every one of them
    func fetch(urls []string) (err error) {
    	for _, u := range urls {
    		// := declares a NEW resp and a NEW err, scoped to this if statement
    		if resp, err := http.Get(u); err != nil {
    			log.Printf("attempt %s: %v", u, err) // the inner err — used, so no warning
    …
  14. 14

    A reviewer says func read(p string) (data []byte, err error) is swallowing the error from its deferred f.Close() and that the bare return at the bottom will bite you. What single mechanism explains both remarks?

    Hard

    Named results are ordinary variables, zero-initialised at function entry and assigned by return before the deferred calls run — which is how a deferred closure can rescue Close's error, and equally how a bare return ships something you never meant to send.

    // ✅ the name exists so the deferred closure has somewhere to put Close's error
    func write(path string, data []byte) (err error) {
    	f, err := os.Create(path)
    	if err != nil {
    		return err
    	}
    …
  15. 15

    for _, u := range users { go audit(&u) } audited the same user three times on Go 1.21 and audits three different ones on Go 1.22. A teammate on the same toolchain still sees the old behaviour. What changed, and what is gating it?

    Hard

    Before Go 1.22 the range variable was one variable reused by every iteration, so every closure and every &u pointed at the same storage; 1.22 gives each iteration its own copy, and the switch is gated on the go line in that module's go.mod, not on the toolchain you build with.

    users := []User{{ID: 1}, {ID: 2}, {ID: 3}}
    
    var jobs []func()
    for _, u := range users {
    	jobs = append(jobs, func() { fmt.Print(u.ID, " ") })
    }
    …
  16. 16

    A package-level var cfg = mustLoad() reads an environment variable that another package's init() sets. It works on your machine and comes back empty in the container. What is the real initialisation order, and why is this broken either way?

    Hard

    Within a package, variables are initialised in dependency order, not source order, then every init() in filename order, and an imported package is fully initialised before the package that imports it.

    // ❌ package-level work: runs on import, before flags are parsed, inside every
    //    test binary that reaches this package, with no error path but panic.
    var cfg = mustLoad(os.Getenv("CONFIG_PATH"))
    
    // dependency order, not source order: b is initialised first, so a is 3
    var a = b + 1
    …