Free Handbook · Every example compiled & verified

Errors & Debugging

The 15 Go errors every beginner hits, with real compiler and runtime messages, how to read a panic trace, and the tools that find bugs fast.

0 / 134 lessons🔥 0 day streak
ShareXLinkedIn

Module 11 · what you'll be able to do

  • Tell a compile error from a runtime panic at a glance, and read a goroutine trace from the bottom up to the line that failed
  • Recognise and fix the 15 errors Go beginners hit most, from declared and not used to send on closed channel
  • Debug with fmt verbs, log and structured log/slog output instead of guessing
  • Step through a program with the Delve debugger, and catch bugs before they run with go vet
  • Find data races with go run -race and know when to reach for pprof
01

Compile errors vs runtime panics

Go checks your program twice. First the compiler reads every file in the package and refuses to build a binary if anything is wrong with the syntax, the types, or the names — including things other languages only warn about, such as an unused variable or import. Then the runtime executes the binary, and problems that depend on real data — a nil pointer, an index past the end, a channel closed twice — stop the program with a panic. go run main.go does both steps, so you see either kind of message in the same terminal.

Compile error (go build / go run)

  • Nothing runs at all — not even the first line of main
  • Starts with # command-line-arguments (the package being built)
  • Format: ./main.go:6:2: message — file, line, column
  • Always reproducible: same code, same error

Runtime panic

  • The program started, maybe printed some output, then stopped
  • Starts with panic: … or fatal error: …
  • Followed by a goroutine trace and exit status 2
  • Depends on the input: it may work for one value and crash for another

Anatomy of a compile error

text
# command-line-arguments
./main.go:8:11: invalid operation: count * price (mismatched types int and float64)

The package being built, then file:line:column, then the message. Go editors turn this into a red underline at exactly that column.

  • Fix the first error first. The compiler stops after about ten errors, and one mistake (a missing brace, a misspelled type) often causes several of them.
  • Go errors are short and literal. declared and not used: total means exactly that. Read the message word by word before searching for it.
  • The column matters. 9:21 points at the argument that has the wrong type, not just the line.

Anatomy of a panic: read the goroutine trace bottom-up

This program totals the prices of an order. One item is "abc", strconv.Atoi fails and returns 0 (the error was ignored with _), and 100 / n divides by zero:

gomain.go
package main

import (
	"fmt"
	"strconv"
)

type Order struct {
	Items []string
}

func priceOf(item string) int {
	n, _ := strconv.Atoi(item)
	return 100 / n
}

func total(o Order) int {
	sum := 0
	for _, it := range o.Items {
		sum += priceOf(it)
	}
	return sum
}

func main() {
	o := Order{Items: []string{"4", "5", "abc"}}
	fmt.Println(total(o))
}
text
panic: runtime error: integer divide by zero

goroutine 1 [running]:
main.priceOf(...)
	./main.go:14
main.total({{0x7f543ee96e68?, 0x7f543edb41e0?, 0x7f543ee96e98?}})
	./main.go:20 +0x7c
main.main()
	./main.go:27 +0x50
exit status 2

The real output (temp-dir paths shortened to ./main.go). The newest call is at the top, main.main is at the bottom.

  1. 1
    Read the first line

    panic: runtime error: integer divide by zero is the whole diagnosis. The words after panic: are what went wrong.

  2. 2
    Find the goroutine that crashed

    goroutine 1 [running] is the main goroutine. In a program with many goroutines, the first one listed is the one that panicked; the word in brackets (running, chan send, chan receive) is what it was doing.

  3. 3
    Start at the bottom: main.main

    Each frame is two lines: the function, then file:line. The bottom frame is where your program started — main at line 27 called total.

  4. 4
    Read upward to the crash

    total at line 20 called priceOf, which crashed at line 14. Reading bottom-up tells the story of how the bad value travelled; the top frame in your code is where to put the fix.

  5. 5
    Ignore the noise

    (...) means the function was inlined; the hex numbers are argument words and the +0x7c is an offset in machine code. Frames under /usr/local/go/src/ are the standard library — almost never the bug.

The real bug is not on line 14 — it is on line 13, where the error from strconv.Atoi was thrown away. That is the usual shape: the panic line is where the program noticed, one of the frames below it is where the mistake was made. Module 07 shows how to return the error instead.

panic vs fatal error
A panic: can be stopped by recover() in a deferred function. A fatal error: (deadlock, concurrent map writes, out of memory) cannot — the runtime decided the program is in a state it must not continue from. Set GOTRACEBACK=all to see every goroutine, not just the one that crashed.
02

Compile errors: unused, undefined and misplaced code

Go is strict about names. A local variable or import you never use is a compile error, not a warning — the language designers decided dead code is a bug waiting to happen. A name the compiler cannot see from where you are is undefined. These four are the first errors almost everyone meets.

Error you will hit

1. declared and not used

go
package main

import "fmt"

func main() {
	total := 0
	count := 3
	fmt.Println(count)
}
# command-line-arguments
./main.go:6:2: declared and not used: total
Why the compiler said that

total is assigned but never read. Go rejects unused local variables because they are usually a sign of an unfinished change or a typo (you meant to use total somewhere and used another name). Package-level variables and function parameters are exempt.

The fix

Use the variable, delete it, or — while you are still writing the function — assign it to the blank identifier _ = total as a temporary placeholder.

go
package main

import "fmt"

func main() {
	count := 3
	fmt.Println(count)
}
Error you will hit

2. "strings" imported and not used

go
package main

import (
	"fmt"
	"strings"
)

func main() {
	fmt.Println("hello")
}
# command-line-arguments
./main.go:5:2: "strings" imported and not used
Why the compiler said that

Unused imports slow down builds and hide real dependencies, so Go refuses them. This one appears constantly while you edit: you delete the last strings.ToUpper call and the import is left behind.

The fix

Delete the import. Better: let your editor run goimports on save (the Go extension for VS Code and GoLand do it by default) — it adds and removes imports automatically.

go
package main

import "fmt"

func main() {
	fmt.Println("hello")
}
Error you will hit

3. undefined: sum

go
package main

import "fmt"

func main() {
	for i := 0; i < 3; i++ {
		sum := i * 2
	}
	fmt.Println(sum)
}
# command-line-arguments
./main.go:7:3: declared and not used: sum
./main.go:9:14: undefined: sum
Why the compiler said that

A variable declared with := lives only inside the braces where it was declared. sum belongs to the loop body, so on line 9 it no longer exists. The compiler reports two errors for one mistake: inside the loop sum is never used, and outside it is unknown. Typos (fmt.Prinln) and lower-case names from another package (strings.toUpper) produce the same undefined message.

The fix

Declare the variable in the scope where you need it — before the loop — and assign to it inside with = or +=, not :=.

go
package main

import "fmt"

func main() {
	sum := 0
	for i := 0; i < 3; i++ {
		sum += i * 2
	}
	fmt.Println(sum)
}
Error you will hit

4. syntax error: non-declaration statement outside function body

go
package main

import "fmt"

limit := 10

func main() {
	fmt.Println(limit)
}
# command-line-arguments
./main.go:5:1: syntax error: non-declaration statement outside function body
Why the compiler said that

The short declaration := is a statement, and statements may only appear inside functions. At package level Go allows only declarations that start with a keyword: var, const, type, func, import. The same error appears for a stray fmt.Println or if outside any function.

The fix

Use var (or const) at package level, or move the line inside main.

go
package main

import "fmt"

var limit = 10

func main() {
	fmt.Println(limit)
}

The blank identifier _ is how you tell the compiler "I am deliberately not using this". It is legal on the left of any assignment and as an import name for packages you load only for their side effects:

gomain.go
package main

import (
	"fmt"
	"strconv"
)

func main() {
	// keep the value, ignore the index
	for _, word := range []string{"go", "vet"} {
		fmt.Println(word)
	}

	// keep the error, ignore the value
	_, err := strconv.Atoi("x1")
	fmt.Println("valid number?", err == nil)

	// a variable you have not wired in yet
	retries := 3
	_ = retries
}
Outputcompiled & run with real Go
go
vet
valid number? false
Your turn

Remove the line _ = retries and run it. Which of the errors above do you get, and on which line and column?

03

Compile errors: types and returns

Go never converts between types for you — not even int to float64. That removes a whole class of silent bugs, and it means the type errors below are the price of admission. Each one names both types, so the fix is usually one explicit conversion.

Error you will hit

5. invalid operation: mismatched types int and float64

go
package main

import "fmt"

func main() {
	count := 3
	price := 9.99
	total := count * price
	fmt.Println(total)
}
# command-line-arguments
./main.go:8:11: invalid operation: count * price (mismatched types int and float64)
Why the compiler said that

count := 3 is an int and price := 9.99 is a float64. Arithmetic needs both sides to be the same type, and Go will not guess whether you wanted to round the price or widen the count.

The fix

Convert one side explicitly: float64(count) * price. (Untyped constants are the exception — 3 * price compiles, because the literal 3 takes the type it needs.)

go
package main

import "fmt"

func main() {
	count := 3
	price := 9.99
	total := float64(count) * price
	fmt.Printf("%.2f\n", total)
}
Error you will hit

6. cannot use input (variable of type string) as int value

go
package main

import "fmt"

func double(n int) int { return n * 2 }

func main() {
	input := "21"
	fmt.Println(double(input))
}
# command-line-arguments
./main.go:9:21: cannot use input (variable of type string) as int value in argument to double
Why the compiler said that

The value has one type and the place you put it expects another. The message always has the same shape — cannot use X (… of type A) as B value in … — and the last words say where: an argument, an assignment, a return statement, a struct literal. A string is not a number, and int(input) will not compile either.

The fix

Parse text with strconv.Atoi (or strconv.ParseFloat) and handle its error. For number-to-number use a conversion like int64(n); for number-to-text use strconv.Itoa, never string(n).

go
package main

import (
	"fmt"
	"strconv"
)

func double(n int) int { return n * 2 }

func main() {
	input := "21"
	n, err := strconv.Atoi(input)
	if err != nil {
		fmt.Println("not a number:", input)
		return
	}
	fmt.Println(double(n))
}
Error you will hit

7. missing return

go
package main

import "fmt"

func grade(score int) string {
	if score >= 90 {
		return "A"
	} else if score >= 50 {
		return "pass"
	}
}

func main() {
	fmt.Println(grade(70))
}
# command-line-arguments
./main.go:11:1: missing return
Why the compiler said that

The function promises a string, but if score is below 50 neither branch runs and control reaches the closing brace with nothing to return. The compiler checks every path, and it points at the closing brace (line 11, column 1) because that is where the missing path ends.

The fix

Return something on every path — usually a final return after the if chain, or an else branch. A panic("unreachable") also satisfies the compiler when the path truly cannot happen.

go
package main

import "fmt"

func grade(score int) string {
	if score >= 90 {
		return "A"
	}
	if score >= 50 {
		return "pass"
	}
	return "fail"
}

func main() {
	fmt.Println(grade(70))
}

All three fixes are explicit conversions or explicit paths. This program shows the conversions you will use most, and what happens to the fractional part:

gomain.go
package main

import (
	"fmt"
	"strconv"
)

func main() {
	count := 3
	price := 2.75

	fmt.Println(float64(count) * price) // int -> float64
	fmt.Println(int(price))             // float64 -> int truncates toward zero
	fmt.Println(strconv.Itoa(count) + " items")

	n, err := strconv.Atoi("42")
	fmt.Println(n+1, err)

	f, err := strconv.ParseFloat("1.5", 64)
	fmt.Println(f*2, err)

	fmt.Println(string(rune(65))) // a code point, not "65"
}
Outputcompiled & run with real Go
8.25
2
3 items
43 <nil>
3 <nil>
A
Your turn

Change strconv.Atoi("42") to strconv.Atoi("4 2"). What are n and err now?

04

Runtime panics: nil maps, nil pointers and type assertions

The zero value of a map, a pointer, a slice, a channel, a function and an interface is nil. Reading from a nil map or ranging over a nil slice is perfectly safe — but writing into a nil map, following a nil pointer, or asserting the wrong type out of an interface panics. All three pass the compiler, because the problem depends on the value at run time.

Error you will hit

8. panic: assignment to entry in nil map

go
package main

import "fmt"

func main() {
	var counts map[string]int
	fmt.Println(counts["go"])
	counts["go"]++
}
0
panic: assignment to entry in nil map

goroutine 1 [running]:
main.main()
	./main.go:8 +0x88
exit status 2
Why the compiler said that

var counts map[string]int declares a map variable but creates no map — it is nil. Line 7 works (a read from a nil map returns the zero value, hence the 0), but line 8 tries to store into a map that has no storage. The same bug hides in structs: a map field is nil until something makes it.

The fix

Create the map before writing: counts := make(map[string]int) or a literal map[string]int{}. For a struct field, create it in the constructor.

go
package main

import "fmt"

func main() {
	counts := make(map[string]int)
	counts["go"]++
	fmt.Println(counts["go"])
}
Error you will hit

9. panic: runtime error: invalid memory address or nil pointer dereference

go
package main

import "fmt"

type User struct {
	Name string
}

func find(id int) *User {
	if id == 1 {
		return &User{Name: "Ana"}
	}
	return nil
}

func main() {
	u := find(2)
	fmt.Println(u.Name)
}
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x2 addr=0x0 pc=0x104577550]

goroutine 1 [running]:
main.main()
	./main.go:18 +0x20
exit status 2
Why the compiler said that

find(2) returns nil, and u.Name tries to read a field at address 0 (addr=0x0 is the giveaway). It is the most common Go panic in production: a function returns a pointer and "nothing found" is signalled with nil, but the caller never checks.

The fix

Make "not found" impossible to ignore: return (*User, bool) or (*User, error) and check it, or check u == nil before use. In a struct, a nil pointer field often means a constructor was skipped.

go
package main

import "fmt"

type User struct {
	Name string
}

func find(id int) (*User, bool) {
	if id == 1 {
		return &User{Name: "Ana"}, true
	}
	return nil, false
}

func main() {
	u, ok := find(2)
	if !ok {
		fmt.Println("no user 2")
		return
	}
	fmt.Println(u.Name)
}
Error you will hit

10. panic: interface conversion: interface {} is string, not int

go
package main

import "fmt"

func main() {
	var v any = "42"
	n := v.(int)
	fmt.Println(n + 1)
}
panic: interface conversion: interface {} is string, not int

goroutine 1 [running]:
main.main()
	./main.go:7 +0x34
exit status 2
Why the compiler said that

The single-result type assertion v.(int) says "I am certain this is an int" and panics when it is not. The message tells you what was really inside (string). This shows up with JSON decoded into map[string]any, where every number is a float64, not an int.

The fix

Use the two-result form n, ok := v.(int), which never panics, or a type switch when several types are possible (Module 06).

go
package main

import "fmt"

func main() {
	var v any = "42"
	n, ok := v.(int)
	if !ok {
		fmt.Printf("not an int: %T\n", v)
		return
	}
	fmt.Println(n + 1)
}

Every one of these has a "comma ok" form that turns the panic into a bool you can check. Learn them as a set:

gomain.go
package main

import "fmt"

type Config struct {
	Tags map[string]string
}

func main() {
	var c Config             // Tags is nil
	tag, ok := c.Tags["env"] // reading a nil map is safe
	fmt.Printf("%q %v\n", tag, ok)

	if c.Tags == nil {
		c.Tags = map[string]string{}
	}
	c.Tags["env"] = "prod"
	fmt.Println(c.Tags)

	var p *Config
	fmt.Println(p == nil) // check before p.Tags

	var v any = 3.0 // numbers from JSON arrive as float64
	if n, ok := v.(int); ok {
		fmt.Println("int", n)
	} else if f, ok := v.(float64); ok {
		fmt.Println("float64", f)
	}
}
Outputcompiled & run with real Go
"" false
map[env:prod]
true
float64 3
Your turn

Add a switch x := v.(type) with cases for int, float64 and string to replace the if / else if chain.

05

Runtime panics: index and slice bounds

Go checks every index and every slice expression at run time. Instead of reading whatever memory happens to be next (the classic C bug), it panics and tells you both the index you asked for and the length that was there. Those two numbers are usually enough to see the off-by-one.

Error you will hit

11. panic: runtime error: index out of range [3] with length 3

go
package main

import "fmt"

func main() {
	nums := []int{10, 20, 30}
	for i := 0; i <= len(nums); i++ {
		fmt.Println(nums[i])
	}
}
10
20
30
panic: runtime error: index out of range [3] with length 3

goroutine 1 [running]:
main.main()
	./main.go:8 +0x9c
exit status 2
Why the compiler said that

Valid indexes are 0 to len-1. The loop condition i <= len(nums) lets i reach 3. The three values printed before the crash are the clue that the loop ran exactly one step too far. The same panic comes from nums[0] on an empty slice, or os.Args[1] when no argument was passed.

The fix

Use i < len(nums), or better, for i, n := range nums, which cannot go out of range. Check len(x) > 0 before reading x[0].

go
package main

import "fmt"

func main() {
	nums := []int{10, 20, 30}
	for _, n := range nums {
		fmt.Println(n)
	}
}
Error you will hit

12. panic: runtime error: slice bounds out of range [:3] with length 2

go
package main

import "fmt"

func firstN(s string, n int) string {
	return s[:n]
}

func main() {
	fmt.Println(firstN("gopher", 3))
	fmt.Println(firstN("go", 3))
}
gop
panic: runtime error: slice bounds out of range [:3] with length 2

goroutine 1 [running]:
main.firstN(...)
	./main.go:6
main.main()
	./main.go:11 +0x60
exit status 2
Why the compiler said that

A slice expression s[low:high] needs 0 <= low <= high <= len(s) (for a slice, up to cap(s)). "go"[:3] asks for three bytes of a two-byte string. Read the trace bottom-up: main line 11 called firstN, which failed on line 6 — the first call on line 10 was fine.

The fix

Clamp the bound: s[:min(n, len(s))] (the built-in min exists since Go 1.21). Remember that strings are sliced in bytes, so cutting text with multi-byte characters needs []rune.

go
package main

import "fmt"

func firstN(s string, n int) string {
	return s[:min(n, len(s))]
}

func main() {
	fmt.Println(firstN("gopher", 3))
	fmt.Println(firstN("go", 3))
}
gomain.go
package main

import "fmt"

// safeAt returns the element at i, or false when i is out of range.
func safeAt(xs []int, i int) (int, bool) {
	if i < 0 || i >= len(xs) {
		return 0, false
	}
	return xs[i], true
}

func main() {
	xs := []int{4, 8, 15}
	for _, i := range []int{0, 2, 3, -1} {
		v, ok := safeAt(xs, i)
		fmt.Println(i, v, ok)
	}
	fmt.Println(xs[1:], xs[:0], len(xs[3:]))
}
Outputcompiled & run with real Go
0 4 true
2 15 true
3 0 false
-1 0 false
[8 15] [] 0

xs[3:] on a length-3 slice is legal and empty: the upper bound may equal the length. Only going past it panics.

Your turn

Print xs[1:2:2] and its cap. The third number limits the capacity — see Module 04.

06

Channel and goroutine errors

Concurrency bugs have their own set of messages. The good news: the Go runtime detects a program where every goroutine is blocked, and it panics on misuse of a closed channel instead of silently losing data. The rules behind all three are in Module 08: only the sender closes, close exactly once, and every blocking operation needs a partner.

Error you will hit

13. fatal error: all goroutines are asleep - deadlock!

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		fmt.Println("worker done")
	}()
	wg.Wait()
}
worker done
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [sync.WaitGroup.Wait]:
sync.runtime_SemacquireWaitGroup(0x10431c200?, 0x20?)
	/usr/local/go/src/runtime/sema.go:114 +0x38
sync.(*WaitGroup).Wait(0x23d63d196020)
	/usr/local/go/src/sync/waitgroup.go:206 +0xa4
main.main()
	./main.go:15 +0x88
exit status 2
Why the compiler said that

Add(2) promises two goroutines, but only one is started. After it calls Done the counter is stuck at 1, Wait blocks forever, and no other goroutine exists that could ever unblock it. Read the trace bottom-up: main line 15 is the Wait, and the bracket [sync.WaitGroup.Wait] says what goroutine 1 was stuck on. The other classic cause is an unbuffered send with no receiver ([chan send]).

The fix

Keep Add and the goroutine count in step: Add(1) right before each go statement, or use wg.Go(f) (Go 1.25+), which does both. For channel deadlocks, make sure some other goroutine receives, or that the sender closes the channel.

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	wg.Go(func() {
		fmt.Println("worker done")
	})
	wg.Wait()
}
The detector only sees total deadlock
The runtime reports a deadlock only when no goroutine can run. In a web server, other goroutines (the listener, timers) are always alive, so a stuck handler just hangs silently — a goroutine leak. That is why servers use context timeouts, and why a goroutine dump (/debug/pprof/goroutine?debug=2, below) is the tool for "the service stopped responding".
Error you will hit

14. panic: close of closed channel

go
package main

import "fmt"

func main() {
	done := make(chan bool, 1)
	done <- true
	close(done)
	fmt.Println(<-done)
	close(done)
}
true
panic: close of closed channel

goroutine 1 [running]:
main.main()
	./main.go:10 +0x94
exit status 2
Why the compiler said that

A channel can be closed exactly once. In real code the two close calls are rarely three lines apart — typically two goroutines each think they are the one that should close, or a cleanup path and a normal path both close.

The fix

Give the channel a single owner that closes it (the sending goroutine, often with defer close(ch)). If several goroutines genuinely might close it, wrap the close in a sync.Once.

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	done := make(chan bool, 1)
	var once sync.Once
	stop := func() { once.Do(func() { close(done) }) }

	done <- true
	stop()
	fmt.Println(<-done)
	stop() // safe: the second call does nothing
}
Error you will hit

15. panic: send on closed channel

go
package main

import "fmt"

func main() {
	jobs := make(chan int, 2)
	jobs <- 1
	close(jobs)
	fmt.Println(<-jobs)
	jobs <- 2
}
1
panic: send on closed channel

goroutine 1 [running]:
main.main()
	./main.go:10 +0x9c
exit status 2
Why the compiler said that

Receiving from a closed channel is fine (you get the remaining values, then zero values). Sending to one panics, because the value would be lost. It happens when a receiver or a coordinator closes a channel that producers are still writing to.

The fix

Only the sender closes, and only after its last send. With several senders, close after all of them have finished: wg.Wait() in a separate goroutine, then close(ch).

go
package main

import "fmt"

func main() {
	jobs := make(chan int, 2)
	jobs <- 1
	jobs <- 2
	close(jobs) // after the last send
	for j := range jobs {
		fmt.Println(j)
	}
}
gomain.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	results := make(chan int)
	var wg sync.WaitGroup

	for id := 1; id <= 3; id++ {
		wg.Go(func() {
			results <- id * 10 // three senders
		})
	}

	// one closer, after every sender has finished
	go func() {
		wg.Wait()
		close(results)
	}()

	sum := 0
	for r := range results {
		sum += r
	}
	fmt.Println("sum:", sum)
}
Outputcompiled & run with real Go
sum: 60

The safe many-senders shape: senders never close; a separate goroutine closes once Wait returns. The sum is printed, not the individual values, because the arrival order varies from run to run.

Your turn

Move close(results) into each sender goroutine instead. Run it a few times — which of the errors above do you get?

07

Debugging tools: print, log, slog, Delve, vet, race, pprof

Debugging is a loop: reproduce the bug with the smallest input, observe what the program really does, form one hypothesis, change one thing, repeat. Go gives you a tool for each kind of observation. Start with the cheapest.

fmt, log and slog

The fmt verbs are the fastest look inside a value: %v the value, %+v with field names, %#v as Go syntax (shows "" vs missing and exact types), %T the type. log adds a prefix and, with log.Lshortfile, the file:line of the call. log/slog writes structured key=value (or JSON) lines with levels, which is what production services use because log systems can search them by field.

gomain.go
package main

import (
	"fmt"
	"log"
	"log/slog"
	"os"
)

func main() {
	// 1. fmt verbs: the quickest look inside a value
	type Item struct {
		Name string
		Qty  int
	}
	it := Item{"pen", 3}
	fmt.Printf("%v | %+v | %#v | %T\n", it, it, it, it)

	// 2. log: prefix and file:line, no timestamp so the output is stable
	log.SetOutput(os.Stdout)
	log.SetFlags(log.Lshortfile)
	log.Printf("qty=%d", it.Qty)

	// 3. slog: structured key=value lines, time removed for the example
	h := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelDebug,
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.TimeKey {
				return slog.Attr{}
			}
			return a
		},
	})
	logger := slog.New(h)
	logger.Debug("loaded item", "name", it.Name, "qty", it.Qty)
	logger.Warn("low stock", "item", it.Name, "left", 1)
}
Outputcompiled & run with real Go
{pen 3} | {Name:pen Qty:3} | main.Item{Name:"pen", Qty:3} | main.Item
main.go:22: qty=3
level=DEBUG msg="loaded item" name=pen qty=3
level=WARN msg="low stock" item=pen left=1

log writes to stderr with a timestamp by default; this example sends it to stdout without one so the output is repeatable. Real services keep the time.

Your turn

Swap slog.NewTextHandler for slog.NewJSONHandler and compare the output. Then raise Level to slog.LevelInfo — which line disappears?

Delve: a real debugger

Print statements answer questions you already thought of. A debugger lets you stop the program and look at everything. Delve (dlv) is the Go debugger; VS Code and GoLand use it under the hood when you click in the gutter to set a breakpoint and press F5. From a terminal:

bash
go install github.com/go-delve/delve/cmd/dlv@latest

dlv debug main.go          # build with debug info and start paused
(dlv) break main.go:14     # stop at line 14 (or: break main.priceOf)
(dlv) continue             # run until a breakpoint
(dlv) print n              # show a variable; also: locals, args
(dlv) next                 # run the current line, step over calls
(dlv) step                 # step into the call on this line
(dlv) stack                # the same goroutine trace a panic prints
(dlv) goroutines           # list every goroutine and where it is blocked
(dlv) exit

dlv test ./...             # the same, for a failing test

Delve understands goroutines, which is why gdb is not recommended for Go.

go vet: bugs the compiler allows

go vet runs a set of analyzers over code that compiles but is almost certainly wrong: Printf verbs that do not match their arguments, copying a sync.Mutex, unreachable code, struct tags with typos, a context cancel function that is never called. go test runs a subset of vet automatically. Here is a program that compiles and runs — with garbage output:

gomain.go
package main

import "fmt"

func main() {
	name := "Ana"
	age := 31
	fmt.Printf("%s is %d years old\n", age, name)
	fmt.Println("done\n")
}
text
$ go run main.go
%!s(int=31) is %!d(string=Ana) years old
done

$ go vet main.go
main.go:8:14: fmt.Printf format %s has arg age of wrong type int
main.go:9:2: fmt.Println arg list ends with redundant newline

Real output. %!s(int=31) is how fmt reports a verb/argument mismatch at run time; vet catches it before you ship. staticcheck and golangci-lint add many more checks and run in most CI pipelines.

The race detector: go run -race

A data race is two goroutines touching the same variable at the same time with at least one writing. It is the one bug Go cannot stop at compile time, and it often "works" in testing. Build with -race and the runtime watches every memory access:

gomain.go
package main

import (
	"fmt"
	"sync"
)

func main() {
	counter := 0
	var wg sync.WaitGroup
	for range 2 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++
		}()
	}
	wg.Wait()
	fmt.Println(counter)
}
text
$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000114038 by goroutine 7:
  main.main.func1()
      ./main.go:15 +0x68

Previous write at 0x00c000114038 by goroutine 8:
  main.main.func1()
      ./main.go:15 +0x78

Goroutine 7 (running) created at:
  main.main()
      ./main.go:13 +0x6c

Goroutine 8 (finished) created at:
  main.main()
      ./main.go:13 +0x6c
==================
2
Found 1 data race(s)
exit status 66

Real output. It printed the "right" answer, 2, and still reported the race: counter++ on line 15 is a read and a write, and two goroutines did it unsynchronised. The fix is a sync.Mutex or atomic.Int64 (Module 08).

In real jobs
Run tests with go test -race ./... in CI. It makes programs several times slower, so it is not used in production builds, but it only reports races that really happened — no false positives. Maps are special: two goroutines writing one map at once can crash the program even without -race, with fatal error: concurrent map writes, which recover cannot catch.

pprof: when it is slow, not wrong

For "it is slow" or "it uses too much memory", measure before guessing. The standard library ships a profiler. In a server, a blank import adds HTTP endpoints; for a test or benchmark, flags write a profile file. go tool pprof then shows which functions used the time or the memory.

go
import (
	"log"
	"net/http"
	_ "net/http/pprof" // registers /debug/pprof/ handlers
)

func main() {
	go func() {
		// keep this on localhost: profiles expose internals
		log.Println(http.ListenAndServe("localhost:6060", nil))
	}()
	// ... the rest of the service
}

// $ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30   (CPU)
// $ go tool pprof http://localhost:6060/debug/pprof/heap                 (memory)
// $ curl 'http://localhost:6060/debug/pprof/goroutine?debug=2'           (every goroutine's stack)
// $ go test -bench . -cpuprofile cpu.out && go tool pprof -http=:8080 cpu.out

The goroutine dump is the fastest way to diagnose a hung service: it prints the same trace format as a panic, for every goroutine.

Compile error
A problem the Go compiler finds before any code runs; reported as ./main.go:line:col: message.
Panic
A run-time failure that unwinds the goroutine, runs deferred calls, and (unless recovered) prints a goroutine trace and exits with status 2.
Fatal error
A run-time failure that cannot be recovered, such as a deadlock or concurrent map writes.
Goroutine trace
The list of stack frames printed on a panic; newest call on top, main.main at the bottom.
Blank identifier
_ — a write-only name used to discard a value you deliberately do not use.
Delve (dlv)
The Go debugger: breakpoints, stepping, variables and goroutines.
go vet
Static analyzers for code that compiles but is probably wrong, such as mismatched Printf verbs.
Data race
Two goroutines accessing the same memory concurrently, at least one writing, without synchronisation. Found with -race.
pprof
Go's built-in CPU, memory and goroutine profiler, read with go tool pprof.
08

Index of the 15 errors

Paste the first line of your error into your browser's find-in-page on this table. Compile errors stop the build; panics and fatal errors stop the running program.

Honourable mentions you will meet next: integer divide by zero (lesson 1), fatal error: concurrent map writes (use a mutex), too many return values (Module 07), and negative WaitGroup counter (Module 08).
#ErrorKindUsual causeUsual fix
1declared and not used: xCompileLocal variable assigned but never readUse it, delete it, or _ = x while drafting
2"pkg" imported and not usedCompileLeftover import after an editDelete it; run goimports on save
3undefined: xCompileOut of scope (declared inside a block), typo, unexported nameDeclare in the outer scope; fix the name or case
4non-declaration statement outside function bodyCompile:= or a call at package levelUse var/const, or move it into a function
5mismatched types int and float64CompileArithmetic mixing numeric typesExplicit conversion: float64(n)
6cannot use x (… type A) as B valueCompileWrong type for an argument, assignment or returnstrconv.Atoi/Itoa or a conversion
7missing returnCompileA path through the function returns nothingFinal return after the if chain
8assignment to entry in nil mapPanicMap declared with var, never mademake(map[K]V) or a literal first
9nil pointer dereferencePanicUsing a pointer a function returned as nilReturn (T, bool) / (T, error); check for nil
10interface conversion: … is A, not BPanicSingle-result type assertion on the wrong typev, ok := x.(T) or a type switch
11index out of range [i] with length nPanic<= instead of <; empty slice; missing os.Argsfor range; check len first
12slice bounds out of rangePanics[:n] with n > len(s)Clamp: s[:min(n, len(s))]
13all goroutines are asleep - deadlock!FatalWaitGroup count off; send/receive with no partnerOne Add per goroutine or wg.Go; a receiver for every send
14close of closed channelPanicTwo places both close the channelOne owner closes; sync.Once if unavoidable
15send on closed channelPanicChannel closed while senders are still runningClose after wg.Wait(), from one place
Quick check

A panic trace lists main.parse at the top, then main.load, then main.main at the bottom. Where did the panic happen?

Quick check

Which of these compiles but panics at run time?

Frequently asked questions

Why does Go refuse to compile unused variables and imports?
An unused local variable or import is almost always an unfinished edit or a typo, so Go treats it as an error rather than a warning that gets ignored. While drafting you can silence it with the blank identifier, and goimports removes unused imports automatically.
How do I read a Go panic stack trace?
Read the first line for what went wrong, then find the goroutine that panicked. Its frames list the newest call first and main.main last, each with file:line. Start at the bottom to see how the program got there and the first frame in your own code nearest the top is usually where to fix it.
What is the best debugger for Go?
Delve (dlv). It understands goroutines and Go types, and it is what VS Code and GoLand use when you set a breakpoint. For concurrency bugs add go run -race or go test -race, and for performance problems use pprof.

Finish the Go handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.