How to Convert Between Types in Go (Type Casting)

Go uses explicit type conversion syntax T(v) instead of casting, requiring compatible types and failing at compile time for incompatible conversions.

How to Convert Between Types in Go

You write a quick script in Python. You grab a value from a JSON response, get "123", and pass it to int("123"). It works. You switch to Go. You type int("123"). The compiler rejects the program. You try int("123") again, maybe adding a cast syntax you remember from C or Java. Still rejected. Go does not do casting. It does explicit conversion, and it is strict about what it allows. This strictness is not bureaucracy. It is a design choice that forces you to acknowledge data representation at every boundary.

Go uses the syntax T(v) to convert a value v to type T. This is a conversion, not a cast. A cast often implies the underlying bits remain the same while the interpretation changes. A conversion may transform the bits entirely. The compiler permits T(v) only when the transformation is well-defined and unambiguous. It allows int(3.14) because truncating a float to an integer is a standard operation. It forbids int("123") because turning text into a number requires parsing logic that can fail. Strings and integers are different species. You need a translator, not a costume change.

Think of T(v) as a machine with specific slots. There is a slot for int to float64. There is a slot for string to []byte. There is no slot for string to int. If you try to jam a string into the int slot, the machine jams. You must use a different tool, a parsing function, to turn text into a number. The compiler enforces this separation to prevent silent data corruption and runtime panics.

Here is the core distinction: numeric types convert with T(v), but strings need a parsing function.

package main

import (
	"fmt"
	"strconv"
)

func main() {
	// Numeric conversion uses T(v) syntax.
	// The compiler allows this because int and float64 share a numeric representation.
	// Warning: this truncates toward zero. 3.99 becomes 3.
	f := 3.99
	i := int(f)
	fmt.Println(i)

	// String to int is not a direct conversion.
	// Strings are sequences of bytes; ints are numbers.
	// The compiler rejects int("42") with: cannot convert "42" (untyped string constant) to type int.
	// Use strconv.Atoi to parse the text and handle errors.
	s := "42"
	n, err := strconv.Atoi(s)
	if err != nil {
		fmt.Println("parse failed:", err)
		return
	}
	fmt.Println(n)
}

Truncation is silent. Overflow is silent. You are the safety net.

Numeric conversions and the int trap

When you convert between numeric types, Go performs the conversion at compile time if the value is a constant, or at runtime if it is a variable. The operation is cheap. It usually involves a single CPU instruction. However, you must understand what happens to the data.

Converting a float to an integer truncates the decimal part. It does not round. int(3.99) is 3. int(-3.99) is -3. If you need rounding, you must add logic manually. Converting an integer to a float is exact for values within the float's precision range. Large integers may lose precision when converted to float64 because floats store a mantissa and an exponent, not every bit of the integer.

The int type in Go is platform-dependent. On a 64-bit system, int is 64 bits. On a 32-bit system, int is 32 bits. The int64 type is always 64 bits. Converting int64 to int is allowed by the compiler, but it truncates the value on 32-bit systems. If you have a file size stored as int64 and you cast it to int to use as a slice index, your program will crash or produce garbage on a 32-bit build. The compiler does not warn you about this because sometimes you know the value fits. The burden is on you to check bounds or use fixed-size types consistently.

Convention aside: the community accepts verbose error handling because it makes the unhappy path visible. When parsing numbers from external input, always check the error. n, _ := strconv.Atoi(s) is dangerous. It hides bad input and leads to zero values that propagate through your logic. Write if err != nil and handle the failure.

Strings, bytes, and runes

Strings in Go are immutable sequences of bytes. They are encoded in UTF-8 by convention, but the language does not enforce this. A string can contain any byte values. This distinction leads to three common conversion patterns.

Converting a string to []byte copies the string data into a mutable byte slice. You can modify the slice without affecting the original string. Converting []byte to string copies the slice data into an immutable string. This copy is necessary because strings must be safe for concurrent access. If Go allowed a string to share memory with a mutable slice, one goroutine could modify the bytes while another reads the string, causing a data race.

Converting a string to []rune decodes the UTF-8 sequence into a slice of Unicode code points. A rune is an alias for int32. This conversion allocates a new slice and iterates over the string to decode each character. If your string contains emojis or non-ASCII characters, len(s) returns the number of bytes, not the number of characters. len([]rune(s)) returns the number of code points. This is a classic trap for developers coming from languages where strings are sequences of characters. In Go, strings are sequences of bytes. Use []rune when you need to index by character, but be aware of the allocation cost.

Only []byte and []rune can convert directly to string. You cannot convert []int or []float64 to string using T(v). The compiler rejects string([]int{65}) with cannot convert []int literal (value of type []int) to type string. This restriction exists because byte is uint8 and rune is int32. The specification hardcodes these conversions as optimizations for text processing. For other slice types, you must build the string manually using strings.Builder or fmt.Sprintf.

Strings are bytes. Runes are characters. Know the difference.

Type assertions and interfaces

Interfaces in Go hold a concrete value and its type. When you have an interface{} value, you do not use T(v) to extract the concrete type. You use a type assertion: x.(T). This syntax checks the dynamic type of x at runtime. If x holds a value of type T, the assertion succeeds and returns the value. If x holds a different type, the assertion panics.

package main

import "fmt"

func main() {
	// Interface holds a string value.
	var i interface{} = "hello"

	// Type assertion extracts the string.
	// This panics if i does not hold a string.
	s := i.(string)
	fmt.Println(s)

	// Safe assertion uses the comma-ok idiom.
	// Returns the value and a boolean indicating success.
	v, ok := i.(int)
	if !ok {
		fmt.Println("i is not an int")
	}
	fmt.Println(v, ok)
}

Type assertions panic. Use the comma-ok idiom unless you want your server to crash.

The comma-ok idiom is the standard pattern for type assertions. It returns the value and a boolean. If the boolean is false, the value is the zero value of type T. This prevents panics when you are unsure of the interface's content. You often see this in type switches, which handle multiple types in a single block. Type switches are syntactic sugar for a series of type assertions. They are efficient and readable.

Convention aside: public names start with a capital letter. Private names start lowercase. When defining interfaces, keep them small. One method is often enough. The mantra "accept interfaces, return structs" guides Go design. Functions should accept interfaces to allow flexibility, but return concrete structs to avoid leaking implementation details. Type assertions are the bridge between these two worlds. Use them carefully.

Realistic example: parsing HTTP query parameters

You are building an API endpoint. The client sends a query parameter ?page=5. Query parameters always arrive as strings. You need an integer to calculate pagination offsets. You also need to validate the input. Negative page numbers or non-numeric strings must be rejected.

Here is a handler that parses the parameter safely. It uses strconv.ParseInt instead of Atoi. ParseInt allows you to specify the base and the bit size. It returns an int64, which you then convert to int for slice indexing. The conversion is safe here because page numbers are small, but the code includes a bounds check to prevent overflow on malicious input.

package main

import (
	"fmt"
	"net/http"
	"strconv"
)

// HandleList processes requests for a paginated list.
// It parses the page parameter and validates bounds.
func HandleList(w http.ResponseWriter, r *http.Request) {
	// Query params always come as strings.
	// Get returns an empty string if the key is missing.
	pageStr := r.URL.Query().Get("page")

	// Parse with base 10 and bit size 64.
	// Returns int64, not int.
	// Handles negative numbers and overflow automatically.
	page, err := strconv.ParseInt(pageStr, 10, 64)
	if err != nil {
		http.Error(w, "invalid page parameter", http.StatusBadRequest)
		return
	}

	// Validate business logic constraints.
	// Page numbers should be positive.
	if page < 1 {
		http.Error(w, "page must be >= 1", http.StatusBadRequest)
		return
	}

	// Convert to int for slice indexing.
	// Safe here because page numbers are small.
	// On 32-bit systems, this truncates, but page fits in 32 bits.
	index := int(page)

	// Use index to fetch data.
	fmt.Fprintf(w, "Fetching page %d", index)
}

Go trusts you to check the error. Don't lie to the compiler.

Pitfalls and compiler errors

The compiler catches many conversion mistakes at build time. If you try to convert incompatible types, you get a clear error message. Attempting int("123") produces cannot convert "123" (untyped string constant) to type int. Attempting to pass a string where an int is expected produces cannot use "123" (untyped string constant) as int value in argument. These errors stop you from shipping broken code.

Runtime panics are harder to catch. Type assertions panic when the dynamic type does not match. If you have var i interface{} = "hello" and you write n := i.(int), the program crashes with interface conversion: interface {} is string, not int. Always use the comma-ok idiom when the type is uncertain.

Nil interfaces are a subtle trap. An interface value is nil only if both the type and the value are nil. If you assign a nil pointer to an interface, the interface is not nil. var i interface{} = (*int)(nil) creates a non-nil interface holding a nil pointer. A type assertion i.(*int) succeeds and returns the nil pointer. A type assertion i.(int) fails because the dynamic type is *int, not int. This distinction causes bugs in error handling and nil checks. Read about nil interfaces to avoid this pitfall.

Slices of different types are incompatible. You cannot convert []int to []int64 using T(v). The compiler rejects this with cannot convert ints (variable of type []int) to type []int64. The memory layouts differ, and the compiler cannot guarantee safety. You must copy the elements in a loop. This prevents silent aliasing bugs where modifying one slice affects the other.

Decision matrix

Use T(v) when converting between numeric types like int, float64, byte, or rune.

Use T(v) when converting between string, []byte, and []rune.

Use strconv functions when converting between strings and numbers.

Use type assertion x.(T) when extracting a concrete type from an interface.

Use json.Unmarshal or encoding/json when converting JSON text to structs.

Use a loop or copy when converting between slice types like []int and []int64.

Where to go next