Pointers exist in Go, but they tend to make people uneasy—often because of habits carried over from C. The difference is that Go keeps pointers on a much tighter leash, so the everyday experience is much simpler. Still, once unsafe enters the picture, that simplicity disappears fast.
Ordinary pointers in Go
At the basic level, a pointer is just an address.
a := 1
p := &a
fmt.Println(p)
Output:
0xc42001c070
So p is a pointer, or more specifically, the address of a.
You can also declare it more explicitly:
a := 1
var p *int
p = &a
fmt.Println(p)
And when you want the value behind the pointer, dereference it with *:
a := 1
p := &a
fmt.Println(*p)
Output:
1
That is basically the core of normal pointer usage in Go: take an address with &, and read or write the underlying value with *.
Why Go pointers feel so simple
Go pointers seem easy largely because they are deliberately limited. In normal code, a pointer points to an address, and you can pass it around or use it to access the value stored there. That is about it.
What you cannot do is just as important:
- You cannot do pointer arithmetic like
p++the way you can in C. That kind of memory walking is intentionally blocked because it is easy to step into invalid memory. - Pointers of different types cannot be freely assigned, converted, or compared. Trying to assign
*intto*float32, for example, leads to errors likecannot use &a (type *int) as type *float32 in assignment.
If Go stopped here, pointers would be a very short topic. But Go also ships with the unsafe package, and once you use it, many of those restrictions can be bypassed.
Enter unsafe
Three pointer-related types
There are really three forms involved here:
- the ordinary typed pointer written with
* unsafe.Pointeruintptr
Their conversion relationship looks like this:
* <=> unsafe.Pointer <=> uintptr
One detail matters a lot:
uintptrdoes not have pointer semantics. That means the object it refers to is not protected from garbage collection just because you converted its address into auintptr.unsafe.Pointerdoes have pointer semantics, so the runtime can still treat it like a real reference when needed.
That also hints at the usual trick: convert a typed pointer into unsafe.Pointer, then into uintptr, do arithmetic there, and convert it back. In effect, that gives you pointer arithmetic.
Using unsafe on a slice
func main() {
s := make([]int, 10)
s[1] = 2
p := &s[0]
fmt.Println(*p)
up := uintptr(unsafe.Pointer(p))
up += unsafe.Sizeof(int(0)) // 这里可不是up++哦
p2 := (*int)(unsafe.Pointer(up))
fmt.Println(*p2)
}
Output:
0
2
What happens here is straightforward:
ppoints to the first element of the slice.- It is converted to
uintptr. - The address is moved forward by the size of one
int. - That address is converted back into
*int. - Dereferencing it reads the second element.
Notice the addition uses unsafe.Sizeof(int(0)), not up++. The next element is not one byte away; it is one int away.
Using unsafe on a struct
You might say that the slice example is not very impressive because indexing already solves that cleanly. Structs are where things get more interesting.
Suppose there is a struct in another package:
package basic
type User struct {
age int
name string
}
Both fields start with lowercase letters, so they are unexported. Under normal rules, code outside that package should not be able to modify them directly.
But with unsafe:
package main
func main() {
user := &basic.User{}
fmt.Println(user)
s := (*int)(unsafe.Pointer(user))
*s = 10
up := uintptr(unsafe.Pointer(user)) + unsafe.Sizeof(int(0))
namep := (*string)(unsafe.Pointer(up))
*namep = "xxx"
fmt.Println(user)
}
Output:
&{0 }
&{10 xxx}
So even though age is unexported, it still gets modified by treating the struct memory directly.
A useful detail behind this: when a struct is allocated, its fields occupy a contiguous block of memory, and the struct address corresponds to the address of its first field.
Converting string to []byte in place
A normal string-to-byte-slice conversion is easy:
s := "123"
a := []byte(s)
But that allocates new memory and copies data. What if you want a conversion without allocating another backing array?
At the storage level, a string and a []byte both describe a region of memory starting at some address. That makes this kind of conversion possible with unsafe.
func main() {
s := "123"
a := []byte(s)
print("s = " , &s, "\n")
print("a = " , &a, "\n")
a2 := (*[]byte)(unsafe.Pointer(&s))
print("a2 = " , a2, "\n")
fmt.Println(*a2)
}
Output:
s = 0xc420055f40
a = 0xc420055f60
a2 = 0xc420055f40
[49 50 51]
The addresses show the difference clearly:
sandaare different, because[]byte(s)creates a new slice with new storage.sanda2have the same address, meaninga2is built directly from the string representation.
So far, that looks clever. It is also broken.
The hidden problem
The issue is that the resulting []byte does not get a properly initialized Cap.
If you print capacities:
fmt.Println("cap a =", cap(a))
fmt.Println("cap a2 =", cap(*a2))
You may get something like:
cap a = 32
cap a2 = 17418400
That huge capacity is garbage. It is not a valid, intentional slice capacity.
Why it happens
The root cause becomes obvious if you look at the internal headers:
type StringHeader struct {
Data uintptr
Len int
}
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
A string has Data and Len, but no Cap. A slice has all three. So when you reinterpret a string directly as []byte, the capacity field is not meaningfully initialized.
That makes sense conceptually too: strings do not grow, so capacity is not part of their representation.
Fixing the conversion
The safe way to do this unsafe trick is to build a proper SliceHeader and set Cap yourself:
stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))
bh := reflect.SliceHeader{
Data: stringHeader.Data,
Len: stringHeader.Len,
Cap: stringHeader.Len,
}
return *(*[]byte)(unsafe.Pointer(&bh))
By explicitly assigning Cap, the resulting slice header becomes structurally valid.
So what is all this actually good for?
In ordinary application code, most of the time the answer is: not much. If you do not need unsafe, do not touch it.
Two points are worth keeping in mind:
- If you are using
unsafecasually, you are probably doing something wrong. - Inside Go's own implementation, low-level pointer movement is used heavily.
For example, map internals use pointer offset calculations when locating a value by key:
v := add(unsafe.Pointer(b), dataOffset+bucketCnt * uintptr(t.keysize)+i * uintptr(t.valuesize))
That expression computes an offset from a bucket pointer to reach the stored value directly.
This is why understanding pointers and unsafe is useful even if you rarely write such code yourself: it makes Go runtime and standard library internals much easier to read.
Still, the rule does not change. Unless you are very clear about what the code is doing at the memory level, leave unsafe alone.