Skip to content
gohacks.dev
Basics

Interface Conversion Panic in Go Type Assertions Fixed

Fix the Go panic interface conversion: the four message forms, JSON numbers that are float64, nil interfaces, pointer versus value types and the comma-ok fix.

7 min read
Summarize in AI
Flat illustration of an editor window full of code.

A type assertion says "I know the concrete type inside this interface". If you are wrong, the single-value form x.(T) panics, and the message names both types so you can see the mismatch. It is a panic you meet in code that handles untyped data, dynamic configuration, decoded JSON and plugin-style registries, and it is a sign that the program trusted a value it should have checked.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This page covers the four message forms, the situations that produce them, and the fix. It belongs to the error handling guide and builds on type assertions and type switches. Everything ran on Go 1.25.6.

The four messages

main.goGo
package main

import (
	"fmt"
	"io"
)

type Shape interface{ Area() float64 }
type Circle struct{ R float64 }
type Square struct{ S float64 }

func (c Circle) Area() float64 { return 3 * c.R * c.R }
func (s Square) Area() float64 { return s.S * s.S }

func try(f func()) {
	defer func() { fmt.Println(recover()) }()
	f()
}

func main() {
	var any1 any = "hello"
	var shape Shape = Circle{1}
	var nothing any
	var w any = Circle{1}

	try(func() { _ = any1.(int) })
	try(func() { _ = shape.(Square) })
	try(func() { _ = nothing.(string) })
	try(func() { _ = w.(io.Writer) })
}
Text
interface conversion: interface {} is string, not int
interface conversion: main.Shape is main.Circle, not main.Square
interface conversion: interface {} is nil, not string
interface conversion: main.Circle is not io.Writer: missing method Write

Each line is one form. interface conversion: interface {} is string, not int is the plain mismatch: the interface held a string and the code asked for an int. interface conversion: main.Shape is main.Circle, not main.Square is the same with a non-empty interface as the source. interface conversion: interface {} is nil, not string means the interface held nothing at all. And interface conversion: main.Circle is not io.Writer: missing method Write appears when the target is itself an interface and the dynamic type does not implement it. The wording tells you which of the four situations you are in before you look at the code.

The fix: comma-ok or a type switch

The panicking form is v := x.(T). The safe one asks for a second result:

main.goGo
package main

import "fmt"

func describe(x any) string {
	if n, ok := x.(int); ok {
		return fmt.Sprintf("int %d", n)
	}
	if s, ok := x.(string); ok {
		return "string " + s
	}
	return fmt.Sprintf("something else: %T", x)
}

func describeSwitch(x any) string {
	switch v := x.(type) {
	case int:
		return fmt.Sprintf("int %d", v)
	case string:
		return "string " + v
	case nil:
		return "nil"
	default:
		return fmt.Sprintf("something else: %T", v)
	}
}

func main() {
	for _, x := range []any{42, "go", 3.14, nil} {
		fmt.Println(describe(x), "|", describeSwitch(x))
	}
}
Text
int 42 | int 42
string go | string go
something else: float64 | something else: float64
something else: <nil> | nil

With ok, a mismatch gives the zero value and false, and the program decides what to do. A type switch is better when there are several cases, and it handles the nil interface in a case nil. Reserve the single-value form for places where a mismatch is a programming error and a panic is the right answer, and say so in a comment.

Cause 1: JSON numbers are float64

The most common real-world trigger is decoded JSON. Decoding into any produces float64 for every number, string, bool, nil, []any for arrays and map[string]any for objects:

main.goGo
package main

import (
	"encoding/json"
	"fmt"
)

func main() {
	defer func() { fmt.Println("recovered:", recover()) }()

	var data map[string]any
	json.Unmarshal([]byte(`{"id": 42, "tags": ["a", "b"], "owner": {"name": "ada"}}`), &data)

	fmt.Printf("%T %T %T\n", data["id"], data["tags"], data["owner"])

	id := data["id"].(int) // the number is a float64
	fmt.Println(id)
}
Text
float64 []interface {} map[string]interface {}
recovered: interface conversion: interface {} is float64, not int

The assertion to int panics with interface {} is float64, not int. Assert to float64 and convert, or better, avoid the problem: decode into a struct with real types, which makes the JSON layer do the checking and return an error that you can report (JSON unmarshal errors). Use any only when the shape is genuinely dynamic, and then use comma-ok at every level. Nested access chains such as data["owner"].(map[string]any)["name"].(string) have three places to panic, and one ok-checked helper function is worth writing once.

Cause 2: a nil interface

The message interface {} is nil, not T means the interface value is entirely empty: no type, no value. It appears when a lookup missed (a map of any with an absent key returns a nil interface), when a function returned nil for an interface result, or when a struct field of interface type was never set. The comma-ok form returns false for it, as in the case nil above. As with nil pointers, the cause is usually upstream: find where the value should have been set (nil pointer dereference covers the same family).

Cause 3: value versus pointer

A value type and a pointer to it are different types, and assertions are exact:

main.goGo
package main

import "fmt"

type Config struct{ Port int }

func main() {
	var x any = &Config{Port: 8080}

	if _, ok := x.(Config); !ok {
		fmt.Println("not a Config value")
	}
	if c, ok := x.(*Config); ok {
		fmt.Println("a *Config with port", c.Port)
	}

	var y any = Config{Port: 9090}
	if _, ok := y.(*Config); !ok {
		fmt.Println("not a *Config either")
	}
}
Text
not a Config value
a *Config with port 8080
not a *Config either

The interface holds a *Config, so asking for Config fails and asking for *Config succeeds. Mixing the two is a frequent source of this panic when values pass through an any field or a registry: whoever stored it and whoever reads it must agree on which form it is. The message names the types exactly (*main.Config), so read the star. The consistency rule from pointer or value receivers applies here too: pick one form for each type and use it everywhere.

Cause 4: asserting to an interface the type does not implement

Asserting to another interface checks the method set, and a type that lacks a method fails with the "missing method" form. This is a design signal as much as a bug: if the code asserts that a value also supports io.Closer, it should use the optional-interface pattern with ok:

main.goGo
package main

import (
	"fmt"
	"io"
	"strings"
)

func closeIfPossible(r io.Reader) {
	if c, ok := r.(io.Closer); ok {
		c.Close()
		fmt.Println("closed")
		return
	}
	fmt.Println("nothing to close")
}

func main() {
	closeIfPossible(strings.NewReader("data"))
	closeIfPossible(io.NopCloser(strings.NewReader("data")))
}
Text
nothing to close
closed

The first reader has no Close method, so the assertion fails quietly, and the second does. The standard library uses this pattern widely (http.Flusher, io.WriterTo), and it is the right way to ask for an optional capability.

Reading it in production

The message in a log is enough to find the bug if you read it as a sentence: the interface held X, the code wanted Y. The first frame of your own code in the trace shows the assertion. The question to ask is where X came from, since the producer and the consumer disagree. If X is float64 and Y is int, the producer is a JSON decode. If X is *main.Config and Y is main.Config, someone stored a pointer and someone read a value. If X is nil, a value was never set. Fixing the assertion with comma-ok stops the crash, and the real fix is to make the disagreement impossible by giving the producer and the consumer a shared concrete type. Keep the comma-ok in places where the input is truly untyped, and return a descriptive error there (fmt.Errorf("field %q: want number, got %T", key, v)), because %T prints the actual type and turns a crash into a message a person can act on.

Avoiding the problem

Prefer designs where the type is known statically: concrete types, interfaces with the methods you need, and generics instead of any plus assertions. When you do need dynamic types, centralise the assertions in one function with ok checks and clear errors, and test it with the awkward inputs: nil, a pointer where a value was expected, and a number from JSON. Generics replace many assertion-heavy helpers: a func Get[T any](m map[string]any, key string) (T, bool) does the lookup and the checked assertion in one place, and its callers never see the mismatch. The specification describes the semantics in its section on type assertions, and the Tour of Go has a short lesson on the topic.

Taggederror fix
Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.