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 *int to *float32, for example, leads to errors like cannot 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.Pointer
  • uintptr

Their conversion relationship looks like this:

* <=> unsafe.Pointer <=> uintptr

One detail matters a lot:

  • uintptr does not have pointer semantics. That means the object it refers to is not protected from garbage collection just because you converted its address into a uintptr.
  • unsafe.Pointer does 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:

  • p points 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:

  • s and a are different, because []byte(s) creates a new slice with new storage.
  • s and a2 have the same address, meaning a2 is 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 unsafe casually, 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.