Numeric Conversions
Understand how to convert between integer, float, and complex numeric types in Go — including truncation, rounding, sign extension, and overflow behaviour.
Go refuses to guess what you mean when you mix numeric types. Every conversion must be explicit, and that decision forces you to think about what could go wrong — loss of precision, sign surprises, or silent truncation. The rules are mechanical and consistent, which means once you learn them you can predict exactly what any conversion will produce.
Why Go Requires Explicit Numeric Conversions
In many languages, the compiler quietly converts an int to a float64 when you add them. That convenience hides a real cost: the conversion might lose information, and you never saw it happen. Go’s designers chose explicitness so that every potential data loss is visible in the source code.
When you write float64(i), you are stating plainly that you accept the consequences of turning an integer into a floating-point value. The compiler will not second-guess you, but it will also not rescue you from a mistake.
No implicit conversions:
Even if two types have the same underlying representation on your machine — int and int32 on a 32-bit system — Go treats them as unrelated. You must still convert explicitly.
This requirement makes code easier to audit. Any expression that mixes types stands out, and a reviewer can immediately ask whether the conversion is safe.
The Conversion Syntax for Numeric Types
The expression T(x) converts the value x to type T, where both T and x are numeric types (or x is an untyped constant). The syntax looks like a function call, but it is a built-in language operation, not a library function.
var i int = 42
f := float64(i)
u := uint(f)
The conversion creates a new value of the target type. The original variable is unchanged. This matters because the new value may not represent the same number — it might be truncated, rounded, or, in the case of floating-point overflow, implementation-dependent.
Conversion never panics for non-constant values:
A conversion like int8(1000) compiles and runs, but it silently truncates the value. The program continues with corrupted data. There is no overflow error at runtime.
Integer to Integer Conversions
When you convert between integer types, two things happen in order: the value is extended to an infinitely precise representation (using sign-extension for signed integers, zero-extension for unsigned integers), then it is truncated to fit the destination type’s size.
Sign-extension means the most significant bit of the source is copied into all the higher bits before truncation. This preserves negative values when you widen a signed type. If you later convert back to a narrower signed type, the truncation can turn a negative value positive.
a := uint16(0x10fe) // 4350
b := int8(a) // -2 — only the low 8 bits (0xfe) are kept
c := uint16(b) // 65534 — the signed int8 was sign-extended to 16 bits
The chain uint16 → int8 → uint16 does not return to the original value. The middle step saw 0xfe as a signed 8-bit integer (−2), and converting that back to uint16 sign-extended it to 0xfffe (65534). This is not a bug in Go; it is the defined behaviour for integer conversions.
If the destination type is unsigned, the value is zero-extended before truncation. So converting a negative signed integer to an unsigned type will produce a large positive number, as the sign bit becomes part of the magnitude.
Sign-extension can surprise you:
A common mistake is assuming that int8(someUint16) and then uint16() will round-trip. They will not if the original value’s high bit is set.
Narrowing and Widening in Practice
Moving from a wider type to a narrower type drops high-order bits. Moving from a narrower type to a wider type preserves the numeric value if the source type is unsigned, or if it’s signed and the value is non-negative. If you widen a negative signed value, the extra bits are filled with 1 s to keep the same negative number.
var small int8 = -100
var wide int64 = int64(small) // still -100
No data is lost when widening, provided the destination can represent all values of the source. Going from int32 to int64 always works.
Widening a signed integer preserves the sign:
If your code relies on negative values staying negative when moving from int8 to int16, Go’s sign-extension guarantees that. No explicit mask is needed.
Float to Integer Conversions
Converting a floating-point number to an integer discards the fractional part — truncation towards zero. There is no rounding, no ceiling, no floor. The integer part is kept, and the sign is preserved.
var x float64 = 3.9
n := int(x) // 3
y := -3.9
m := int(y) // -3
The operation is mathematically equivalent to rounding toward zero. For positive numbers it is the floor; for negative numbers it is the ceiling.
Truncation, not rounding:
int(3.9) gives 3, not 4. If you need the nearest integer, use math.Round before converting. Many bugs come from assuming the conversion will round.
When the floating-point value is larger than the maximum integer of the target type, the result is the maximum integer value (or the minimum for negative overflow). The Go specification defines this behaviour for signed integer types. For unsigned integer types, the result is the value modulo 2ⁿ where n is the type’s bit size. In other words, overflow wraps around, but without a panic.
var huge float64 = 1e20
var smallInt int8 = int8(huge) // wraps around; depends on platform
This is dangerous because the wrapping is not an error; the code just runs with a garbage number. Always check the range before converting if the float comes from an untrusted source.
Integer and Float to Float Conversions
Converting an integer to a floating-point type rounds the value to the precision of the target type. Converting a floating-point value to a floating-point type of different size also rounds — it does not truncate. The result is the nearest representable value, with ties broken according to IEEE 754.
var a float64 = 1.0000003
var b float32 = float32(a) // 1.0000004 on typical hardware
The example shows that the absolute value may increase slightly because of rounding. This is correct per the spec and happens because float32 cannot represent 1.0000003 exactly.
When converting a float64 that is too large for float32 to represent, the result is an infinity (positive or negative). This does not cause a panic; it just produces an infinity value that you can test with math.IsInf.
Precision loss is visible:
Even a widening conversion, like float32 to float64, changes the value. The new float64 will be the exact representation of the float32, which may differ slightly from the original if the number was not exactly representable in float32.
Complex Number Conversions
Complex types follow the same rounding rules as floats, applied independently to the real and imaginary parts. Converting from complex128 to complex64 rounds each component to 32-bit precision.
var c complex128 = complex(1.0000003, 2.0000003)
var d complex64 = complex64(c) // each part rounded
You can also convert a float to a complex type. The imaginary part becomes zero:
f := 3.14
c := complex128(f) // (3.14 + 0i)
Constant Numeric Conversions
Untyped constants behave differently from variables. An untyped constant is an ideal number that has arbitrary precision. When you assign or use an untyped constant where a numeric type is required, Go will convert it implicitly — but only if the constant is representable in that type.
const c = 1000
var b byte = c // works; 1000 overflows byte -> compile-time error
The assignment byte = 256 would be caught: “constant 256 overflows byte”. This compile-time safety is a sharp contrast to variable conversions, where the same overflow would be silently truncated.
An explicit constant conversion, like int8(1000), is also a compile-time error if the constant is out of range. The compiler evaluates the constant expression and refuses to build if the conversion cannot hold the value.
Constant overflow is caught at build time:
This is one of Go’s strongest safety nets. If a constant cannot fit in the target type, your code won’t compile. You are forced to fix the logic before the program ever runs.
Overflow and Truncation Behaviour Summary
| Source Type | Destination Type | Behaviour |
|---|---|---|
| Integer | Narrower integer | Truncation of high bits. Signed: sign bit may change. No error. |
| Integer | Wider integer | Sign-extension (signed) or zero-extension (unsigned). Value preserved. |
| Float | Integer | Truncation towards zero. Overflow wraps or saturates; no panic. |
| Float/Int | Float (different size) | Rounding to destination precision. Overflow yields infinity. |
| Complex | Complex (different size) | Rounding of real and imaginary parts independently. |
| Constant | Any numeric type | Compile-time check: must be representable; overflow is an error. |
The most surprising consequence for newcomers is that conversion of a non-constant value never fails. Even when the source value is completely out of range, the conversion succeeds and produces a deterministic but likely incorrect result. This is by design — it avoids hidden runtime panics in arithmetic-heavy code — but it means you must actively guard against overflow.
Silent data corruption is the default:
Relying on the compiler to catch overflow only works with constants. For everything else, you need to validate ranges yourself before converting.
Common Pitfalls and How to Avoid Them
Assuming int and int64 are the same type.
int is architecture-dependent (32 or 64 bits). On a 64-bit system it happens to be the same size as int64, but the type system still sees them as different. Always convert explicitly.
Forgetting that truncation throws away the fractional part.
int(2.999999) is 2. If you want rounding, call math.Round first.
Converting a signed integer to an unsigned type of the same width.
uint8(-1) is 255. If the negative value is a sentinel (like -1 for “not found”), this can turn into a valid-looking positive number.
Believing a conversion will panic on overflow.
It will not. You must check bounds yourself if the input is not a constant.
Sign-extension when converting from a smaller unsigned type to a signed type.
int8(uint16(0xff)) gives -1. The unsigned value 0xff is 255, but the conversion to int8 interprets the high bit as a sign.
Each of these has bitten experienced Go developers. The language gives you full control, but you are responsible for the correctness of the resulting value.
Summary
Numeric conversions in Go are a precise, predictable machine. The compiler enforces explicitness, which makes potential data loss visible at the call site. The rules — sign-extension for signed integers, truncation for narrowing, rounding for floating-point destinations — are mechanical and will never surprise you if you stop and think about them before converting.
The biggest lesson is that non-constant conversions succeed silently, even when the result is garbage. That shifts the burden of safety to you: check ranges, understand the direction of the conversion, and never assume a number will stay the same just because the syntax is tidy.