Go Advanced #5 unsafe and cgo — Outside the Safe Zone

6 min read

If #4 reflect showed tools for working with types at runtime — this post covers two tools that step outside the type system. unsafe and cgo.

The goal of this post is understanding why these tools exist and when they show up. You will rarely use them directly, but they appear inside libraries from time to time.

unsafe.Pointer — Go’s void* #

Normally, Go blocks pointer arithmetic and arbitrary type conversions. You cannot cast *int to *float64, and you cannot add 1 to a pointer to advance to the next memory address.

unsafe.Pointer — bypasses those guards.

basics
import "unsafe"

x := int32(42)
p := unsafe.Pointer(&x)        // *int32 → unsafe.Pointer
y := (*float32)(p)              // unsafe.Pointer → *float32
fmt.Println(*y)                  // interpret 32-bit memory as float32

It plays the same role as C’s void*a pointer stripped of type information.

unsafe’s four rules #

The conversions Go officially guarantees as valid fall into 4 patterns:

1) *T1*T2 conversion #

var x int64 = 0x0102030405060708
p := (*[8]byte)(unsafe.Pointer(&x))    // reinterpret int64 memory as a byte array

Reinterpret the same memory as a different type. Alignment and size must be compatible.

2) unsafe.Pointeruintptr #

addr := uintptr(unsafe.Pointer(&x))
fmt.Printf("%x\n", addr)

Address as integer. uintptr is not tracked by the GC, so use it only briefly. Holding it across a GC can leave you with a dangling reference.

3) struct field address — unsafe.Offsetof #

field offset
type S struct {
	A int32
	B int32
}

s := S{}
addrA := unsafe.Pointer(&s)
addrB := unsafe.Pointer(uintptr(addrA) + unsafe.Offsetof(s.B))

This produces the same result as &s.B, but is used in dynamic cases (similar to reflect).

4) unsafe.Slice / unsafe.String (Go 1.17+, 1.20+) #

slice from a pointer
arr := [5]int{1, 2, 3, 4, 5}
s := unsafe.Slice(&arr[0], 5)    // []int{1,2,3,4,5}

bytes := []byte("hello")
str := unsafe.String(&bytes[0], len(bytes))    // reinterpret as string with no copy

Fits C interfaces or zero-copy conversions.

Where unsafe is justified #

  • byte slice ↔ string conversion (when you want to avoid GC cost)
  • A debugging tool to inspect a struct’s memory layout
  • Exchanging data with C (together with cgo)
  • In very hot spots — serialization libraries faster than reflect (some implementations like easyjson)
byte→string with no copy
func bytesToString(b []byte) string {
	return unsafe.String(unsafe.SliceData(b), len(b))
}

This bypasses the immutability of string — dangerous but very fast. A pattern found only inside libraries, in well-tested code.

Where unsafe breaks #

common pitfall
// Holding only uintptr, dropping unsafe.Pointer
addr := uintptr(unsafe.Pointer(p))
runtime.GC()                        // p may be GC'd
ptr := unsafe.Pointer(addr)          // ✗ may be a dead address

uintptr is treated as a number and not tracked by GC. Always hold it as unsafe.Pointer to be safe.

go vet -unsafeptr — checking tool #

go vet — detects some unsafe usage that doesn’t match the four patterns. If you use unsafe, always confirm vet passes.

go vet ./...

cgo — the bridge between Go and C #

cgo lets you call C functions from Go code. It is not a standard package — it is part of the Go compiler’s build system.

cgo basics
package main

/*
#include <stdio.h>

void greet() {
	printf("Hello from C\n");
}
*/
import "C"

func main() {
	C.greet()
}

Two key points:

  • C code in a comment — the compiler builds it with a C compiler
  • import "C" — magic import; can’t be in the same line as another import

Exchanging data #

passing strings
import "C"

func cstring(s string) *C.char {
	return C.CString(s)    // Go string → C string (copy)
}

func gostring(p *C.char) string {
	return C.GoString(p)   // C string → Go string (copy)
}

Almost every boundary crossing involves a memory copy. Go’s GC and C’s memory management are separate, so safely sharing the same memory is difficult.

cgo’s costs #

Each call carries — typically hundreds of nanoseconds of overhead.

  • Briefly leaves the goroutine scheduler — occupies an OS thread until the C function returns
  • Memory copies — for strings and slices
  • Complex build environment — C compiler required, cross-compilation harder
  • Doesn’t play well with race detector / pprof
rough costs
ordinary Go function call:    ~1 ns
cgo call:                      ~100-300 ns

cgo in a hot loop disrupts Go’s runtime scheduling. It is better to hand a large chunk of work to cgo at once and let the loop run on the C side.

Where cgo fits #

  • When using an existing C library is genuinely required (e.g., specific SDKs, native bindings)
  • Direct OS system calls
  • When performance is critical and — there’s no pure-Go implementation

In most cases, a pure-Go implementation or network IPC is the cleaner choice. Reach for cgo only when there is truly no other way.

A cgo case — SQLite #

mattn/go-sqlite3 is Go’s most well-known cgo-dependent library. SQLite is a C library, and rewriting it in pure Go would be prohibitively costly, so it is wrapped via cgo.

As an alternative — there’s a pure-Go implementation (modernc.org/sqlite), but it’s auto-translated code with compatibility tradeoffs.

cgo’s GOEXPERIMENT — purego #

Recently, approaches for calling dynamic libraries without cgo have emerged (purego). They do not cover every case yet, so cgo remains the standard.

Overlap of unsafe and cgo #

A pointer like *C.char from cgo is typically converted via unsafe.Pointer before use. The two tools often appear together.

cgo + unsafe
buf := C.malloc(C.size_t(100))
defer C.free(buf)

slice := unsafe.Slice((*byte)(buf), 100)    // view C memory as a Go slice

Memory allocated with C’s malloc is unknown to the Go GC and must be freed explicitly with free.

Why “don’t use them” is the right answer #

CostImpact
Complex build environmentcross-compilation gets harder, CI gets heavier
Hard debuggingrace detector, pprof don’t function normally
Memory safetylose Go’s big advantage of GC safety
Readabilityhard for others to read

“cgo is not Go.” — Go proverbs

The standard library itself is migrating parts that depended on cgo (such as some system calls) back to pure Go. The Go team’s direction is to avoid cgo.

Decision tree #

Before reaching for unsafe or cgo, check:

  1. Is there a pure-Go library? → use it
  2. Can the standard library solve it? → use it
  3. Can it be split out via network IPC? → run as a separate process
  4. Still no — then cgo (or unsafe)

Wrap-up #

What we covered:

  • unsafe.Pointer — bypasses the type system, similar to C’s void*
  • Four legitimate patterns — same-size/align conversion, brief uintptr conversion, Offsetof, Slice/String
  • uintptr isn’t GC-tracked — always hold it as unsafe.Pointer
  • cgo — call C from Go, hundreds of ns per call
  • Complex build, hard debugging — avoidance is the default
  • Decision tree — pure Go → standard → IPC → then cgo

In the next post (#6 Profiling) — measuring and improving performance with Go’s standard tools. We cover pprof, benchmark, and the race detector.

X