DEV Community

Cover image for Reading stack traces in Go
Michele Caci
Michele Caci

Posted on • Edited on

Reading stack traces in Go

In this guide I will show some useful information and tips on how to read a stack trace. Stack traces in Go are very common when a panic is triggered, they are not easy to read but the information contained in them may be useful to get some context and understand better what was happening in the code when the panic started.

A generic stack trace

We start by generating a panic with the following playground example where we call recursively i times a function call before starting a panic and exiting.

func main() {
    iPanic(5)
}

func iPanic(i int) {
    if i > 0 {
        iPanic(i - 1)
    }
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode

The output, as shown below, is composed of the message passed to the panic built-in function, a line describing which goroutine was running at the moment of the panic and the stack trace containing all the calls from the beginning of the program execution to the line that panicked.

panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x0)
    /tmp/sandbox634668020/prog.go:11 +0x4f
main.iPanic(0x1)
    /tmp/sandbox634668020/prog.go:9 +0x33
main.iPanic(0x2)
    /tmp/sandbox634668020/prog.go:9 +0x33
main.iPanic(0x3)
    /tmp/sandbox634668020/prog.go:9 +0x33
main.iPanic(0x4)
    /tmp/sandbox634668020/prog.go:9 +0x33
main.iPanic(0x5)
    /tmp/sandbox634668020/prog.go:9 +0x33
main.main()
    /tmp/sandbox634668020/prog.go:4 +0x2a
Enter fullscreen mode Exit fullscreen mode

main.main() <br/> /tmp/sandbox634668020/prog.go:4 +0x2a is an example of line that composes the stack trace. Each line contains:

  • the package name and the name and arguments of the caller function: main.main()
  • the file where the function was called : /tmp/sandbox634668020/prog.go
  • the line in the file where the function is located : 4
  • the relative position of the function in the stack frame : +0x2a

This picture/table shows an example of stack frame to illustrate what the relative position of the function in the stack frame means:

Function call Relative position
main.iPanic(0x0) +0x4f
main.iPanic(...) +0x33
main.main() +0x2a
Bottom of the stack 0x0

The code also features a stack traces with a call to a function with an int parameter that is represented for example in the line:

main.iPanic(0x3)
   /tmp/sandbox634668020/prog.go:9 +0x33
Enter fullscreen mode Exit fullscreen mode

In this line we see the value 0x3 which is the hexadecimal encoding of the int value assigned to the parameter i in the call of main.iPanic(0x3).

Note on functions with unused or empty parameter list

Take a look at this example below and its correspondent output

func main() {
    iPanic(5)
}

func iPanic(i int) {
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.iPanic(...)
    /tmp/sandbox137813596/prog.go:8
main.main()
    /tmp/sandbox137813596/prog.go:4 +0x39
Enter fullscreen mode Exit fullscreen mode

Even though the iPanic function is called with a valid parameter the stack traces shows a (...), in the line main.iPanic(...) instead.

This empty parameter list symbol is returned either when the parameters are not used in a meaningful computation, e.g. fmt.Print(i) would not change the stack trace content, or when the function has no parameters at all.

Bool and numeric types

Let's now take this playground example with several parameters of type bool and numeric.

import "fmt"

func main() {
    iPanic(true, 6, 'c', 6.7, complex(2, 1))
}

func iPanic(b bool, by byte, r rune, f float32, c complex64) {
    fmt.Println(by + 1)
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode

Here's the output (excluding the Println line)

panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x6300000601, 0x4000000040d66666, 0xc03f800000)
    /tmp/sandbox564764099/prog.go:11 +0xad
main.main()
    /tmp/sandbox564764099/prog.go:6 +0x5a
Enter fullscreen mode Exit fullscreen mode

This output is a bit messy to read as it appears to show 3 out 5 parameters but this is not the case. In fact this example is a way to show that parameters may be encoded to fit together into several words, sequences of 32 or 64 bits.

If iPanic is rerun with one parameter at a time we can see each value separately as follows:

  • bool: is located in main.iPanic(0xc00005e0*01*)
    • or in main.iPanic(0x63000006*01*, 0x4000000040d66666, 0xc03f800000) in the original call
  • byte: is located in main.iPanic(0xc00005e0*06*)
    • or in main.iPanic(0x630000*06*01, 0x4000000040d66666, 0xc03f800000) in the original call
  • rune: is located in main.iPanic(0xc0000000*63*)
    • or in main.iPanic(0x*63*00000601, 0x4000000040d66666, 0xc03f800000) in the original call
  • float32: is located in main.iPanic(0xc0*40d66666*)
    • or in main.iPanic(0x6300000601, 0x40000000*40d66666*, 0xc03f800000) in the original call
  • complex64: is located in main.iPanic(0x*3f80000040000000*)
    • or in main.iPanic(0x6300000601, 0x*4000000040d66666, 0xc03f800000*) in the original call

All of this values are hexadecimal encoding of the value that has been passed to the parameters in the example. For instance 0x01 stands for true for the bool parameter (as well as 0x00 stands for false), 0x06 is the encoding of the byte parameter with decimal value 6 and so on.

The encoding of the values is done in this way for bool and all numeric types, in which rune is included.

Pointers and nil

Regarding pointers and nil values, we generate a panic with the following playground example where we call recursively i and times a function call before starting a panic and exiting. In addition of this we add a *int parameter to read the value printed in the stack trace.

func main() {
    a := 5
    iPanic(a, &a)
}

func iPanic(i int, j *int) {
    if i > 0 {
        iPanic(i-1, j)
    }
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode

The output, as shown below, is composed of the message passed to the panic built-in function, a line describing which goroutine was running at the moment of the panic and the stack trace containing all the calls from the beginning of the program execution to the line that panicked.

panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x0, 0xc000032770)
    /tmp/sandbox341218196/prog.go:12 +0x59
main.iPanic(0x1, 0xc000032770)
    /tmp/sandbox341218196/prog.go:10 +0x3d
main.iPanic(0x2, 0xc000032770)
    /tmp/sandbox341218196/prog.go:10 +0x3d
main.iPanic(0x3, 0xc000032770)
    /tmp/sandbox341218196/prog.go:10 +0x3d
main.iPanic(0x4, 0xc000032770)
    /tmp/sandbox341218196/prog.go:10 +0x3d
main.iPanic(0x5, 0xc000032770)
    /tmp/sandbox341218196/prog.go:10 +0x3d
main.main()
    /tmp/sandbox341218196/prog.go:5 +0x3d
Enter fullscreen mode Exit fullscreen mode

If we replace &a with nil we will see in the stack trace that the address changes to 0x0 as shown in the output of the appropriate run.

panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x0, 0x0)
    /tmp/sandbox325035618/prog.go:12 +0x59
main.iPanic(0x1, 0x0)
    /tmp/sandbox325035618/prog.go:10 +0x3d
main.iPanic(0x2, 0x0)
    /tmp/sandbox325035618/prog.go:10 +0x3d
main.iPanic(0x3, 0x0)
    /tmp/sandbox325035618/prog.go:10 +0x3d
main.iPanic(0x4, 0x0)
    /tmp/sandbox325035618/prog.go:10 +0x3d
main.iPanic(0x5, 0x0)
    /tmp/sandbox325035618/prog.go:10 +0x3d
main.main()
    /tmp/sandbox325035618/prog.go:5 +0x33
Enter fullscreen mode Exit fullscreen mode

Strings and slices

Let's now take a look at this playground example featuring strings and slices and its correspondent output.

import "fmt"

func main() {
    iPanic("hello", make([]string, 3, 5))
}

func iPanic(s string, v []string) {
    fmt.Println(s + "world")
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x4bcedc, 0x5, 0xc000068f28, 0x3, 0x5)
    /tmp/sandbox564764099/prog.go:11 +0xad
main.main()
    /tmp/sandbox564764099/prog.go:6 +0x5a
Enter fullscreen mode Exit fullscreen mode

Now in the iPanic line we have more information than number of parameters. This is related to how strings and slices are represented in memory.

For the string hello parameter the stack trace line shows its address 0x4bcedc and its size 0x5

  • main.iPanic(0x4bcedc, 0x5, 0xc000068f28, 0x3, 0x5) -> this is the hello string

For the []string parameter the stack trace line shows again its address 0xc000068f28, its size 0x3 and its capacity 0x5

  • main.iPanic(0x4bcedc, 0x5, 0xc000068f28, 0x3, 0x5) -> this is the make([]string, 3, 5) slice

Note that strings and slices are referenced by their address in the stack trace and not to the values they hold.

Structs

Structs are collections of fields and/or embeded structs and interfaces. As we can see in this playground example and its stack trace the struct fields are put in the paramters in the order in which they are defined in the struct when referenced by value.

import "fmt"

type A struct {
    i int
    s string
}

func main() {
    iPanic(A{i:50})
}

func iPanic(a A) {
    fmt.Println(a.i + 1)
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x32, 0x0, 0x0)
    /tmp/sandbox211242967/prog.go:16 +0xa5
main.main()
    /tmp/sandbox211242967/prog.go:11 +0x32
Enter fullscreen mode Exit fullscreen mode

Changing the value parameter to a reference using a *A will change the stack trace which will show the reference to the parameter instead of its content.

Methods

Changing the struct parameter to be a receiver in the iPanic function is shown in this Playground example. Whether the struct a parameter of the function or a receiver for the method, there is no actual difference in display the parameters list. However what was main.iPanic(...) before, becomes main.A.iPanic(...) if the method has a value receiver and main.(*A).iPanic(...) if the method has a pointer receiver.

Below is the code and the stack trace.

import "fmt"

type A struct {
    i int
    s string
}

func main() {
    A{i: 50}.iPanic(true)
}

func (a A) iPanic(b bool) {
    fmt.Println(a.i + 1)
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.A.iPanic(0x32, 0x0, 0x0, 0xc00005e001)
    /tmp/sandbox467574958/prog.go:16 +0xa5
main.main()
    /tmp/sandbox467574958/prog.go:11 +0x37
Enter fullscreen mode Exit fullscreen mode

Note that even if a is a receiver outside the parameters list, it is actually referenced in the parameter list as in "main.A.iPanic(0x32, 0x0, 0x0, 0xc00005e001)" with its content. When we change the receiver to a pointer, a is referenced by it's address in the parameter list as in "main.(*A).iPanic(0xc000068f60, 0x1)", as shown in the example below.

import "fmt"

type A struct {
    i int
    s string
}

func main() {
    (&A{i: 50}).iPanic(true)
}

func (a *A) iPanic(b bool) {
    fmt.Println(a.i + 1)
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.(*A).iPanic(0xc000068f60, 0x1)
    /tmp/sandbox772882731/prog.go:16 +0xa7
main.main()
    /tmp/sandbox772882731/prog.go:11 +0x4f
Enter fullscreen mode Exit fullscreen mode

Interface

Interfaces, whether they are held by a struct or a pointer to a struct are always represented in the parameter list as two words holding a pointer to the information about the type stored and one to the object that they hold.

The two examples below can be explored with the basis of this playground example.

Struct implementing interface example

import "fmt"

type oper interface {
    op() string
}

type A struct {
    s string
}

func (a A) op() string { return a.s }

func main() {
    iPanic(A{"op"})
}

func iPanic(o oper) {
    fmt.Println(o.op() + "!")
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x4dc000, 0xc000010200)
    /tmp/sandbox220169591/prog.go:21 +0xe5
main.main()
    /tmp/sandbox220169591/prog.go:16 +0x50
Enter fullscreen mode Exit fullscreen mode

Pointer to struct implementing interface example

import "fmt"

type oper interface {
    op() string
}

type A struct {
    s string
}

func (a *A) op() string { return a.s }

func main() {
    iPanic(&A{"op"})
}

func iPanic(o oper) {
    fmt.Println(o.op() + "!")
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0x4dbfa0, 0xc0001021e0)
    /tmp/sandbox587239589/prog.go:21 +0xe5
main.main()
    /tmp/sandbox587239589/prog.go:16 +0x59
Enter fullscreen mode Exit fullscreen mode

Maps, channels and function types

For maps, channels and function types as parameters, the stack trace will always show the reference to the values passed to the function. Here is the correspondent playground example for these types.

func main() {
    iPanic(make(map[int]bool), make(chan int), func() {})
}

func iPanic(m map[int]bool, c chan int, f func()) {
    go func() { f() }()
    panic("I'm outta here")
}
Enter fullscreen mode Exit fullscreen mode
panic: I'm outta here

goroutine 1 [running]:
main.iPanic(0xc000032748, 0xc000094060, 0x479288)
    /tmp/sandbox760033282/prog.go:9 +0x5b
main.main()
    /tmp/sandbox760033282/prog.go:4 +0xc9
Enter fullscreen mode Exit fullscreen mode

Parting thoughts

I hope this article helps you unlock the secrets of the Go stack and, even though they prove to difficult to read, you may gain a better understanding of the meaning and the information that the stack is providing before heading to potentially long debugging sessions.

You can find me up on twitter @nikiforos_frees if you have any questions or comments and follow me on dev.to @mcaci

This was Michele and thanks for reading!

References

Here is a list of references that helped me in doing my research on the go stack:

Top comments (0)