Type Conversion in Go — Professional Level¶
Focus: What Happens Under the Hood¶
1. How It Works Internally¶
1.1 The Compilation Pipeline for Conversions¶
When you write float64(i), the Go compiler processes it through several phases:
- Parsing (syntax): The parser sees
T(expr)and creates an AST nodeCallExprwith the type as the function - Type-checking (typecheck pass): The compiler verifies the conversion is valid per the spec
- IR generation (SSA): The conversion becomes an SSA instruction (
CONVNOP,CONV,CVTSL, etc.) - Code generation: The SSA instruction maps to target machine instructions
1.2 Conversion Node in the AST¶
In the Go compiler source (cmd/compile/internal/typecheck), conversions generate a OCONV node:
OCONV
├── Left: expression to convert
├── Type: target type
└── Op: OCONV, OCONVNOP, ODOTTYPE (for interfaces)
For float64(i) where i is int: - The compiler generates OCONV(i, float64) - In SSA: v = ConvertSL <float64> i (or CVTSL = convert signed long)
1.3 Zero-Cost Conversions¶
The compiler recognizes conversions that are "no-ops" at the machine level: - Named type to/from underlying type with same representation - type MyInt int — MyInt(x) is a OCONVNOP — generates zero instructions
type MyInt int
var n int = 42
m := MyInt(n) // OCONVNOP — zero cost, compiler ensures same memory representation
Verify with: go build -gcflags='-e -m' .
2. Runtime Deep Dive¶
2.1 runtime.slicebytetostring¶
When you write string(byteSlice), the compiler generates a call to:
// Simplified version of runtime source
func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
l := len(b)
if l == 0 {
return ""
}
if l == 1 {
stringStructOf(&str).str = unsafe.Pointer(&staticuint64s[b[0]])
stringStructOf(&str).len = 1
return
}
var p unsafe.Pointer
if buf != nil && len(b) <= len(buf) {
p = unsafe.Pointer(buf)
} else {
p = mallocgc(uintptr(l), nil, false) // heap allocation
}
stringStructOf(&str).str = p
stringStructOf(&str).len = l
memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(l))
return
}
Key observations: - Single bytes use a static lookup table (staticuint64s) — no allocation - Small strings (<32 bytes on stack) can use the tmpBuf — no heap allocation - All other cases: mallocgc + memmove
2.2 runtime.stringtoslicebyte¶
func stringtoslicebyte(buf *tmpBuf, s string) []byte {
var b []byte
if buf != nil && len(s) <= len(buf) {
*buf = tmpBuf{}
b = buf[:len(s)]
} else {
b = rawbyteslice(len(s)) // mallocgc
}
copy(b, s)
return b
}
2.3 The tmpBuf Optimization¶
The compiler passes a stack-allocated tmpBuf (32 bytes) when it can prove the result doesn't escape to the heap. This avoids malloc for small strings/slices in many common cases.
// This conversion may use tmpBuf (no heap alloc if s is short):
func example(s string) int {
b := []byte(s) // compiler may pass tmpBuf here
return len(b)
}
2.4 Compiler-Elided Conversions¶
The compiler DOES NOT call slicebytetostring in these cases (optimization):
// Case 1: string used directly in map lookup
m := map[string]int{"a": 1}
key := []byte("a")
_ = m[string(key)] // no allocation — compiler special-cases this
// Case 2: for-range over converted string
b := []byte("hello")
for i, c := range string(b) { // no allocation
_, _ = i, c
}
// Case 3: string comparison
if string(b) == "hello" { // no allocation
}
// Case 4: string concatenation with converted
_ = "prefix:" + string(b) // actually DOES allocate — not optimized
3. Compiler Perspective¶
3.1 SSA Form for Numeric Conversions¶
Using GOSSAFUNC=main go build:
SSA output (simplified):
b1:
v1 = InitMem <mem>
v2 = SP <uintptr>
v3 = Arg <int> {i} ; load argument
v4 = CVTSL <float64> v3 ; convert int64 → float64
v5 = Store <mem> {ret} v4 v1
Ret v5
The CVTSL instruction maps to CVTSI2SDQ on x86-64.
3.2 String Conversion SSA¶
SSA output (simplified):
b1:
v1 = InitMem
v2 = Arg <[]byte>
v3 = StaticCall <(string,mem)> {runtime.slicebytetostring}
[buf=nil, b=v2]
v4 = SelectN <string> [0] v3
v5 = SelectN <mem> [1] v3
Ret v4 v5
3.3 Type Assertion Compilation¶
A type assertion x.(T) compiles to a call to runtime.assertI2T or runtime.assertI2I:
; Pseudo-assembly for i.(string)
MOVQ i.type, AX ; load interface type pointer
CMPQ AX, typeptr_string ; compare with string type pointer
JNE panic_branch ; if not equal, panic
MOVQ i.data, AX ; load data pointer
For the two-value form s, ok := i.(string):
; No conditional jump — just compare and set ok flag
MOVQ i.type, AX
CMPQ AX, typeptr_string
SETE ok ; set ok = (types are equal)
4. Memory Layout¶
4.1 Integer Representations¶
All integer types use two's complement representation:
int8 range: -128 to 127
Binary: 1000 0000 = -128 (most negative)
0111 1111 = 127 (most positive)
1111 1111 = -1
int8(200):
200 = 1100 1000 in binary
Interpreted as int8 (signed): -56
Because: 1100 1000 = -(256 - 200) = -56
uint8(200):
200 = 1100 1000 in binary
Interpreted as uint8 (unsigned): 200 ✓
4.2 IEEE 754 Float Representation¶
float64 (64-bit):
Bit 63: sign (1 bit)
Bits 62-52: exponent (11 bits, biased by 1023)
Bits 51-0: mantissa (52 bits + implicit 1 bit)
float64(42):
Sign: 0 (positive)
Exponent: 1028 (1028 - 1023 = 5, so 2^5 = 32)
Mantissa: 1.3125 (32 × 1.3125 = 42)
Binary: 0 10000000100 0101000000000000000000000000000000000000000000000000
Precision limit:
2^53 = 9007199254740992
float64 can exactly represent integers up to 2^53
Beyond that, consecutive integers cannot all be represented
4.3 String Header vs Slice Header¶
// From runtime/string.go
type stringStruct struct {
str unsafe.Pointer // 8 bytes (pointer to data)
len int // 8 bytes (length in bytes)
} // Total: 16 bytes
// From reflect/value.go
type SliceHeader struct {
Data uintptr // 8 bytes (pointer to data)
Len int // 8 bytes (length)
Cap int // 8 bytes (capacity)
} // Total: 24 bytes
// When converting string → []byte:
// New SliceHeader is created (24 bytes on stack)
// New data array is allocated on heap (len(s) bytes)
// memmove copies the data
4.4 Interface Internal Layout¶
// iface — interface with methods (non-empty interface)
type iface struct {
tab *itab // pointer to interface table
data unsafe.Pointer // pointer to concrete data
}
// eface — empty interface{}
type eface struct {
_type *_type // pointer to type descriptor
data unsafe.Pointer
}
// Type assertion: i.(string)
// 1. Check i._type == &string_type
// 2. If yes, return *(*string)(i.data)
// 3. If no, panic (or return ok=false)
5. OS / Syscall Level¶
5.1 Conversions at OS Boundaries¶
When Go interacts with the OS, type conversions are critical:
// syscall.Read takes a []byte buffer
// The OS sees a pointer and length
// Go's []byte → C void* conversion happens via unsafe
func Read(fd int, p []byte) (n int, err error) {
// Internally:
// _p0 := unsafe.Pointer(&p[0]) // extract data pointer
// n = syscall(SYS_READ, fd, _p0, len(p))
}
5.2 cgo and Type Conversion¶
/*
#include <stdint.h>
int32_t add(int32_t a, int32_t b) { return a + b; }
*/
import "C"
func callC() {
var a, b int32 = 10, 20
// Must convert Go int32 to C.int32_t
result := C.add(C.int32_t(a), C.int32_t(b))
// Convert back
goResult := int32(result)
_ = goResult
}
The cgo tool generates C bridge code that handles type conversions between Go's type system and C's type system.
6. Source Code References¶
6.1 Relevant Go Compiler Files¶
cmd/compile/internal/typecheck/typecheck.go— conversion type checkingcmd/compile/internal/walk/convert.go— conversion code generationruntime/string.go— string conversion runtime functionsruntime/iface.go— interface type assertion runtime
6.2 Key Functions in the Runtime¶
// runtime/string.go
func slicebytetostring(buf *tmpBuf, b []byte) (str string)
func stringtoslicebyte(buf *tmpBuf, s string) []byte
func stringtoslicerune(buf *[tmpStringBufSize]rune, s string) []rune
func slicerunetostring(buf *tmpBuf, a []rune) string
// runtime/iface.go
func assertI2I(inter *interfacetype, i iface) (r iface)
func assertI2I2(inter *interfacetype, i iface) (r iface, b bool)
func assertE2I(inter *interfacetype, e eface) (r iface)
func panicdottypeE(have, want, iface *_type) // panic for failed assertions
7. Assembly Output¶
7.1 Numeric Conversion¶
x86-64 assembly (from go tool compile -S):
"".intToFloat STEXT nosplit size=7 args=0x10 locals=0x0
MOVQ "".n+8(SP), AX ; load n from stack
CVTSI2SDQ AX, X0 ; convert int64 to float64
MOVSD X0, "".~r1+16(SP) ; store result
RET
7.2 String to []byte Assembly¶
x86-64 (simplified):
"".convert STEXT size=64
MOVQ $0, "".buf+0(SP) ; buf = nil (no tmpBuf)
MOVQ "".s_data+8(SP), AX ; load string.Data
MOVQ "".s_len+16(SP), BX ; load string.Len
MOVQ AX, ""..autotmp+24(SP)
MOVQ BX, ""..autotmp+32(SP)
MOVQ BX, ""..autotmp+40(SP) ; set slice header
CALL runtime.stringtoslicebyte(SB)
; ...
RET
7.3 Type Assertion Assembly¶
x86-64:
"".assertString STEXT size=72
MOVQ "".i.type+8(SP), AX ; load interface type pointer
MOVQ "".i.data+16(SP), BX ; load interface data pointer
CMPQ AX, type·string(SB) ; compare with string type
JNE paniccode ; panic if not string
MOVQ (BX), CX ; dereference data pointer
MOVQ 8(BX), DX ; get string length
MOVQ CX, "".~r1+24(SP) ; store result
MOVQ DX, "".~r1+32(SP)
RET
paniccode:
CALL runtime.panicdottypeE(SB)
8. Performance Internals¶
8.1 The tmpBuf Mechanism in Detail¶
The compiler performs escape analysis to determine if a converted value escapes to the heap:
// Compiler generates a tmpBuf when:
// 1. The result does not escape to the heap
// 2. The string/slice is small enough (≤32 bytes by default)
// tmpBuf size: typically 32 bytes
const tmpStringBufSize = 32
type tmpBuf [tmpStringBufSize]byte
// Example where tmpBuf IS used (no heap alloc):
func f(b []byte) bool {
return string(b) == "hello" // result doesn't escape
}
// Example where tmpBuf is NOT used (heap alloc):
func g(b []byte) string {
return string(b) // result may be stored/returned, must heap allocate
}
8.2 GC Impact¶
Heap allocated during conversions:
[]byte(s) → 1 allocation = GC work proportional to len(s)
string(b) → 1 allocation = GC work proportional to len(b)
[]rune(s) → 1 allocation = 4 × len(s) bytes for Unicode
GC scan cost:
- Each allocation adds to GC scan work
- 1000 conversions/request × 1000 RPS = 1M allocations/second
- This can cause GC to run >100 times/second with short STW pauses
8.3 SIMD Optimization Opportunity¶
For bulk conversions (e.g., ASCII-only strings to []byte), SIMD instructions can process 16-32 bytes at a time. The Go runtime uses this for memmove but not directly in user-visible conversion paths. High-performance string processing libraries implement SIMD explicitly:
// Conceptual SIMD copy (actual SIMD requires assembly)
// Process 16 bytes at a time using SSE2 instructions
// Reduces effective cost from O(n) sequential to O(n/16)
9. Internal Type System¶
9.1 How Type Compatibility Is Checked¶
From cmd/compile/internal/types/type.go, the Identical function determines if two types are identical:
func Identical(t1, t2 *Type) bool {
if t1 == t2 {
return true
}
if t1.kind != t2.kind {
return false
}
// ... recursively check fields, elements, etc.
}
The ConvertibleTo check (from the spec): 1. Both types have the same underlying type 2. Both are unnamed pointer types to same underlying type 3. Both are integer or floating point types 4. Both are complex number types 5. T is string and x is integer type 6. T is []byte and x is string type (or vice versa) 7. T is []rune and x is string type (or vice versa)
9.2 itab (Interface Table) Internals¶
type itab struct {
inter *interfacetype // the interface type
_type *_type // the concrete type
hash uint32 // copy of _type.hash (used for type switches)
_ [4]byte
fun [1]uintptr // variable-length array of method pointers
}
When a type assertion is performed: 1. Extract itab from the interface 2. Compare itab.inter with the target interface type 3. If matched, return the concrete data pointer
This is O(1) — not a hash lookup, but a pointer comparison (with caching).
10. Benchmarks and Measurements¶
10.1 Comprehensive Benchmark Suite¶
package conversion_test
import (
"fmt"
"strconv"
"testing"
"unsafe"
)
const testString = "hello world this is a typical string value"
var testBytes = []byte(testString)
func BenchmarkStringToBytes(b *testing.B) {
s := testString
var result []byte
for i := 0; i < b.N; i++ {
result = []byte(s)
}
_ = result
}
func BenchmarkBytesToString(b *testing.B) {
bs := testBytes
var result string
for i := 0; i < b.N; i++ {
result = string(bs)
}
_ = result
}
func BenchmarkUnsafeStringToBytes(b *testing.B) {
s := testString
var result []byte
for i := 0; i < b.N; i++ {
result = unsafe.Slice(unsafe.StringData(s), len(s))
}
_ = result
}
func BenchmarkStrconvItoa(b *testing.B) {
var result string
for i := 0; i < b.N; i++ {
result = strconv.Itoa(i)
}
_ = result
}
func BenchmarkFmtSprintfInt(b *testing.B) {
var result string
for i := 0; i < b.N; i++ {
result = fmt.Sprintf("%d", i)
}
_ = result
}
func BenchmarkAppendInt(b *testing.B) {
buf := make([]byte, 0, 32)
for i := 0; i < b.N; i++ {
buf = strconv.AppendInt(buf[:0], int64(i), 10)
}
_ = buf
}
10.2 Expected Results (Go 1.21, x86-64)¶
BenchmarkStringToBytes/42chars ~15 ns/op 1 alloc/op 48 B/op
BenchmarkBytesToString/42chars ~15 ns/op 1 alloc/op 48 B/op
BenchmarkUnsafeStringToBytes ~0.5 ns/op 0 allocs/op 0 B/op
BenchmarkStrconvItoa ~30 ns/op 1 alloc/op 16 B/op
BenchmarkFmtSprintfInt ~200 ns/op 2 allocs/op 32 B/op
BenchmarkAppendInt ~25 ns/op 0 allocs/op 0 B/op
11. Escape Analysis and Conversions¶
11.1 How Escape Analysis Affects Conversions¶
// Run: go build -gcflags='-m' to see escape decisions
// Does NOT escape → stack allocation or tmpBuf
func f1(b []byte) bool {
s := string(b) // "does not escape"
return s == "hello"
}
// DOES escape → heap allocation
func f2(b []byte) *string {
s := string(b) // "s escapes to heap"
return &s
}
// DOES escape (returned)
func f3(b []byte) string {
return string(b) // may escape depending on call site
}
12. Type Conversion in the Go Type Checker¶
12.1 Walkthrough: Type-Checking a Conversion¶
From go/types package (used by tools, not the compiler directly):
// Simplified logic from go/types/conversions.go
func (check *Checker) conversion(x *operand, T Type) {
constArg := x.mode == constant_
if isString(T) {
switch {
case isInteger(x.typ):
// Valid: string(integer) — creates Unicode char
// This is the famous string(65) = "A" behavior!
case isByteSlice(x.typ):
// Valid: string([]byte)
case isRuneSlice(x.typ):
// Valid: string([]rune)
}
}
// ... many more cases
}
13. Linker and Binary Output¶
13.1 Type Descriptors in Binary¶
Every type in a Go binary has a _type structure embedded in the binary:
.rodata section:
type·"".MyInt: type descriptor for MyInt
type·int: type descriptor for int
...
itab·io.Reader·os.File: interface table
Type assertions at runtime compare pointers to these descriptors — which is why type assertion comparison is O(1) pointer equality.
14. Deep Internals: strconv Package¶
14.1 How strconv.Itoa Works¶
// From strconv/itoa.go
func Itoa(i int) string {
return FormatInt(int64(i), 10)
}
func FormatInt(i int64, base int) string {
_, s := formatBits(nil, uint64(i), base, i < 0, false)
return s
}
func formatBits(dst []byte, u uint64, base int, neg bool, append_ bool) (d []byte, s string) {
// Uses a static buffer to avoid allocation for small integers!
var a [64 + 1]byte // stack-allocated buffer
i := len(a)
if base == 10 {
// Optimized decimal path using lookup table
for u >= 100 {
is := u % 100 * 2
u /= 100
i -= 2
a[i+1] = smallsString[is+1]
a[i+0] = smallsString[is+0]
}
// Handle remaining digits
}
// ...
return a[i:], string(a[i:]) // copies to new string
}
The smallsString lookup table contains all two-digit combinations:
15. Security Internals¶
15.1 How Integer Overflow Is (Not) Detected¶
Go's compiler does NOT insert overflow checks for integer arithmetic or conversions (unlike -fsanitize=undefined in C). This is by design for performance. However:
// The compiler DOES detect overflow for constant expressions:
const x = int8(200) // COMPILE ERROR: constant 200 overflows int8
// But NOT for runtime conversions:
n := 200
m := int8(n) // silent overflow, m = -56
Go's go vet and staticcheck tools have analyzers that detect suspicious conversions, but they're not exhaustive.
16. Summary¶
The professional-level understanding of type conversion in Go reveals:
- Zero-cost conversions (
MyInt(x)) generateOCONVNOP— literally no code - Numeric conversions (
float64(i)) generate single CPU instructions (CVTSI2SDQ) - String/byte conversions call runtime functions (
slicebytetostring) that callmallocgc - The
tmpBufoptimization avoids heap allocation for small strings that don't escape - Type assertions are O(1) pointer comparisons against type descriptor pointers in
.rodata - Interface itab contains both the interface type and the concrete type — used for fast dispatch and assertion
- unsafe.Slice/unsafe.String (Go 1.20+) provide zero-copy conversion at the cost of immutability guarantees
- Escape analysis determines whether conversion results go on the stack or heap
- No overflow checks are inserted at runtime — programmer responsibility
- strconv uses lookup tables for fast integer-to-string conversion
Understanding these internals allows you to make informed decisions about when to use unsafe optimizations, how to structure code for minimal GC pressure, and where to focus optimization efforts in high-throughput systems.