Type Conversions

How to explicitly convert between types in Go, covering numeric and string conversions, and the relationship between conversion and assignability.

Go does not perform automatic type conversions between variables. Every time you move a value from one type to another, you must write the conversion yourself. This rule eliminates an entire category of silent data loss bugs that plague languages with implicit coercion. The syntax is simple, but the details — especially around strings, overflow, and assignability — require careful study.

Explicit Conversion Syntax

The conversion expression looks like a function call: the target type followed by the value in parentheses. T(x) takes the value x and produces a new value of type T. The original x remains unchanged; you get back a converted copy.

var i int = 42
var f float64 = float64(i)   // 42.0
var u uint = uint(f)         // 42

The compiler checks that the conversion is legal at compile time. You can only convert between types that share the same underlying representation, are both numeric types, or are both string-related types (string, []byte, []rune). Attempting to convert a bool to an int, for instance, will not compile.

Conversion vs Type Assertion:

Do not confuse type conversions with type assertions. A conversion int(x) changes the actual value's type. A type assertion x.(int) only works on interface values and tells the compiler "trust me, the concrete type inside this interface is int." They solve entirely different problems.

Numeric Conversions

All numeric types — signed and unsigned integers, floating-point numbers, and complex numbers — can be converted to one another. The conversion applies the mathematical value of the source to the target type, but what happens when the target cannot hold that value depends on the direction of the conversion.

Integer to Integer

Converting between integer types of different sizes preserves the value if it fits. When the value overflows the target type, the behavior is implementation-defined. In practice, most Go compilers truncate the high bits as if you had performed a bitwise mask, but the specification does not guarantee it.

var big int16 = 300
var small int8 = int8(big)
fmt.Println(small) // likely 44, but not guaranteed by spec

The number 300 (binary 100101100) requires 9 bits. An int8 can only hold 8 bits, so the extra high bits are discarded, leaving 00101100 which is 44. That is how the gc compiler handles it, but you should never rely on this behavior across compilers. The only safe overflow is when you know the value fits.

Overflow Is Silent:

Integer overflow during conversion does not cause a runtime panic or a compile-time error (unless the value is a constant — then the compiler catches it). A bug caused by silent overflow can survive in production for a long time. Always validate that the source value lies within the target type's range before converting.

Conversely, converting to a larger integer type always succeeds and preserves the value, including its sign:

var small int8 = -10
var wide int64 = int64(small) // -10

Floating-Point to Integer

Converting a float32 or float64 to any integer type truncates toward zero. The fractional part is dropped, not rounded.

var f float64 = 3.9
var i int = int(f) // 3
var neg float64 = -3.9
var j int = int(neg) // -3

If the floating-point value is outside the range representable by the target integer type, the result is implementation-defined — again, typically a wrap-around pattern, but not guaranteed. An infinity or NaN value yields unspecified behavior and should be avoided.

Integer to Floating-Point

Converting an integer to a floating-point type is safe in principle: the numeric value is preserved as closely as the floating-point format allows. However, large integers beyond the precision limit of float64 (roughly 2^53) lose precision.

var big int64 = 1<<53 + 1
var f float64 = float64(big)
fmt.Println(int64(f) == big) // false

The constant 2^53 + 1 requires more bits of mantissa than float64 provides, so the least significant bits are lost. The conversion itself completes without error, but the round-trip back to integer does not produce the original value.

Complex Numbers

Converting a real number (integer or float) to a complex type sets the imaginary part to zero:

var c complex128 = complex128(5)
fmt.Println(c) // (5+0i)

You can also extract the real and imaginary parts using the built-in real() and imag() functions, which are not conversions but are often used alongside them.

When Conversion Is Safe:

Conversions between numeric types are perfectly safe when the source value is known to fit the target type's range. For example, converting a float64 that represents a small whole number to int is routine, and converting an int to float64 for general mathematical work is standard practice. The safety depends entirely on your knowledge of the data.

String Conversions

String conversions in Go break into two distinct families: conversions involving numbers and conversions involving byte or rune slices. The most common mistake newcomers make is treating string(65) as a way to get the decimal string "65".

Integer to String

When you apply string() to an integer value, Go interprets the integer as a Unicode code point (a rune) and returns a string containing that single character. This is by design, but it surprises almost every beginner.

var s string = string(65)
fmt.Println(s) // "A", not "65"

The integer 65 is the code point for 'A'. If you pass a value that is not a valid Unicode code point, the resulting string will contain the Unicode replacement character \ufffd.

To convert an integer to its decimal string representation, use strconv.Itoa or fmt.Sprintf:

import "strconv"
s := strconv.Itoa(65) // "65"

Do Not Use string(i) for Decimal Output:

Codebases that use string(i) to produce a numeric string are a source of hard-to-find bugs. If i happens to be 50, the output is "2", which might not break anything and go unnoticed until a value outside the ASCII digit range appears. Always use strconv or fmt for formatting numbers.

Float to String

The strconv package provides FormatFloat for converting floating-point numbers to strings. You control the format, precision, and bit size.

var pi float64 = 3.14159
s := strconv.FormatFloat(pi, 'f', 2, 64)
fmt.Println(s) // "3.14"

The 'f' argument is the format verb (decimal). 2 is the number of digits after the decimal point. 64 indicates the original value was a float64.

String to Integer and Float

Parsing a string to a numeric type also lives in strconv. Atoi is the shorthand for base-10 integer parsing, while ParseInt and ParseFloat give you more control.

s := "42"
i, err := strconv.Atoi(s)
if err != nil {
    // s was not a valid integer
}
fmt.Println(i) // 42

Atoi returns two values: the parsed integer and an error. You must check the error. Ignoring it (with _) will lead to zero values being used when parsing fails, which silently corrupts downstream logic.

f, err := strconv.ParseFloat("3.14", 64)

The second argument to ParseFloat is the bit size of the result (32 or 64). It determines both the precision and the type of the returned value.

String to Byte Slice and Rune Slice

Converting a string to []byte gives you a mutable copy of the string's underlying bytes. Converting back to a string creates a new immutable string from those bytes.

s := "hello"
b := []byte(s)   // []byte{'h', 'e', 'l', 'l', 'o'}
b[0] = 'H'
s2 := string(b)  // "Hello"

The conversion allocates new memory; the string and the byte slice do not share the same backing array. The same applies to []rune conversions, which decode the string into Unicode code points.

s := "日本語"
runes := []rune(s) // []rune{26085, 26412, 35486}
fmt.Println(len(s), len(runes)) // 9, 3

The length of the string in bytes is 9 (each CJK character is 3 bytes in UTF-8), but the rune slice has 3 elements. This is a common point of confusion.

String to String (No Conversion Needed)

A string cannot be converted to another string type with a different underlying type. If you define type MyString string, a value of type MyString is assignable to string without conversion because they share the same underlying type. You can also convert string to MyString explicitly.

Performance Note:

Frequent conversions between strings and byte slices can generate significant garbage. When working with large text transformations, consider using strings.Builder or working directly with []byte throughout the hot path, converting to string only at the boundary.

Conversion vs Assignability

A variable assignment x = y is legal only if y is assignable to the type of x. Assignability is a broader concept than just identical types. A value of one type is assignable to a variable of another type if:

  • The two types are identical.
  • Both types have the same underlying type, and at least one of them is an unnamed type.
  • The source is an untyped constant representable by the target type.
  • The source is an interface value that implements the target interface.
  • The source is a bidirectional channel and the target is a channel with identical element type.

The last three are useful but the first two govern most everyday conversions. The distinction matters because many developers confuse assignability (implicit) with conversion (explicit).

Same Underlying Type

Consider two named types based on int:

type Celsius int
type Fahrenheit int
var c Celsius = 100
var f Fahrenheit
f = c // compile error: cannot use c (type Celsius) as type Fahrenheit

Both Celsius and Fahrenheit have the same underlying type int, and they are both named types. Assignability requires at least one of them to be unnamed. Since both are named, assignment fails. You need an explicit conversion:

f = Fahrenheit(c) // legal

This design prevents logical errors — a temperature in Celsius should not silently flow into a variable meant for Fahrenheit. The compiler forces you to acknowledge the semantic shift.

Unnamed Types

When one of the types is unnamed, assignment works without explicit conversion:

type Timestamp int64
var now Timestamp = 1700000000
var raw int64
raw = now // assignable: Timestamp and int64 share underlying type, int64 is unnamed

However, the reverse direction also works:

var t Timestamp
t = raw // also assignable

If you want to block that behavior, keep both types as named types and require explicit conversion everywhere. Many codebases adopt this pattern for safety-critical type distinctions (e.g., user IDs vs order IDs).

Untyped Constants

Untyped constants have no fixed type; they adopt the type required by context. This makes them assignable to any compatible numeric type without an explicit conversion:

const value = 100
var a int8 = value
var b float64 = value

The constant 100 has kind integer but no concrete type. It fits into int8 because its value is representable. A typed constant, on the other hand, cannot cross type boundaries without conversion:

const typedValue int = 100
var a int8 = typedValue // compile error: cannot use typedValue (type int) as int8

This is the only form of implicit conversion Go allows, and it applies exclusively to untyped constants at compile time.

Untyped Constants Are Flexible:

Use untyped constants for mathematical and configuration values that will be consumed by multiple integer or float types. You get the safety of exact representation without the friction of explicit conversions at every use site.

When to Use Conversion vs Rely on Assignability

Even when an assignment is legal, an explicit conversion can serve as documentation. If a value's meaning changes — say, converting a raw byte count to a Duration — writing the conversion makes the intent visible during code review. For routine operations where the types are merely aliases of convenience, assignability reduces noise. There is no universal rule; the correct choice depends on whether the type change carries semantic weight.


Summary

Type conversions in Go are syntactically uniform — T(x) for every type — but their behavior splits into two worlds. Numeric conversions silently truncate, overflow, or lose precision; string conversions on integers produce runes, not decimal text; and assignability rules determine when you can skip the conversion entirely. The critical habit is to verify range and format before converting, especially when crossing from a wider type to a narrower one or from a number to a string. Untyped constants offer a rare exception where the compiler does the conversion for you, but only because they have no fixed type to defend.