AlgoMaster Logo

unsafe Package

Last Updated: May 22, 2026

Low Priority
13 min read

Go is a memory-safe language with one deliberate hole in the floor: the unsafe package. It exists so that the runtime, the standard library, and a handful of high-performance libraries can step outside Go's normal type and memory rules when they have to. This chapter explains what unsafe is, why a safe language ships an escape hatch in the first place, what's inside the package at a high level, and when using it is warranted.

What the unsafe Package Is

Most of the time, Go works hard to keep your program from doing anything dangerous with memory. The compiler refuses to let you cast a *int to a *float64. The runtime refuses to let a slice index walk off the end of its backing array. The garbage collector tracks every pointer and reclaims memory once nothing references it. These rules are what people mean when they call Go "memory safe", and they are why Go programs are far less likely than C or C++ programs to corrupt memory, leak resources, or crash with undefined behavior.

The unsafe package suspends some of those rules. It gives you tools to convert between unrelated pointer types, compute a pointer from an arbitrary integer, find the in-memory size and alignment of any value, and build slices or strings whose backing memory the runtime doesn't own. None of that is callable from idiomatic Go code under normal circumstances. The whole package is named unsafe precisely so that every import statement reads as a warning: this file is doing something the compiler has stopped checking.

The package lives at pkg.go.dev/unsafe. Its API is small. The full list of identifiers is Pointer, Sizeof, Alignof, Offsetof, Add, Slice, SliceData, String, StringData, and the ArbitraryType and IntegerType placeholders used in the documentation. That's it. Nothing in there hides much complexity, but every single function or type carries rules that, if broken, can lead to silently incorrect programs, crashes that only surface under load, or data corruption that the garbage collector amplifies.

The diagram captures the shape of the trade. Importing unsafe opens a door from the safe world into a smaller world where you take responsibility for things the compiler and runtime normally guard. Used carefully, that door is what makes the runtime, the standard library, and certain performance-critical libraries possible. Used carelessly, it's where memory corruption lives.

The smallest thing the package can do is report sizes. Every Go value has a fixed size in bytes, and unsafe.Sizeof reports it.

On a 64-bit machine, int is 8 bytes, a string header is 16 bytes (a pointer plus a length), and the Product struct takes 16 bytes (8 for the float64, 4 for the int32, plus 4 bytes of padding so the next struct in an array would still be aligned). On a 32-bit machine the numbers would be different. That word-size dependence is one of the first hints that unsafe lets you see things Go normally hides.

Why a Safe Language Ships an Escape Hatch

Go's value proposition is that it produces fast native binaries with garbage collection, while keeping the code straightforward enough for large teams to maintain. The cost of memory safety is some lost performance, because every slice access checks bounds, every interface call goes through a vtable, and every allocation goes through the garbage collector. For most code, that cost is invisible. For a few narrow cases, it's the difference between a feature shipping and not shipping.

The runtime itself is the clearest case. Go's scheduler, garbage collector, and channel implementation are all written in Go, and they need to do things no normal Go program should: take the address of a struct field as a raw integer, walk memory byte by byte, treat one type's memory as another type. The runtime is the system that enforces safety for everyone else's programs, so by definition it can't be written using only the rules it enforces.

The standard library is the second case. Packages like sync/atomic, reflect, time, syscall, encoding/binary, and internal/bytealg all use unsafe somewhere inside. sync/atomic needs it to perform compare-and-swap operations on pointer-sized words. reflect needs it to walk struct memory and set fields by offset. time uses it to grab the monotonic clock from the OS without going through a function call per read. None of these packages would be usable from Go code if they hadn't been allowed to dip into the unsafe layer.

The third case is interop with C, which the import "C" (CGO) feature handles. The CGO machinery translates between Go's calling convention and C's, and on the Go side that translation involves unsafe.Pointer conversions.

The fourth case is performance-critical user code, and it's the rarest. A handful of libraries (high-throughput JSON parsers, columnar database engines, networking buffers) reach for unsafe to avoid a copy or a bounds check that profiling has shown to dominate their hot path. These are libraries that have measured the cost, isolated the unsafe code behind a safe API, and tested aggressively. They are not application code, and they are not where a typical Go programmer should be reaching for unsafe.

LayerUses unsafe?Why
Go runtimeHeavilyImplements GC, scheduler, channels, the things that enforce safety for everyone else
Standard library internalsIn specific packagessync/atomic, reflect, time, syscall, encoding/binary need it
CGO bridgeYesTranslates between Go and C calling conventions and memory models
High-performance librariesOccasionallyAfter profiling shows a copy or bounds check is the bottleneck
Application codeAlmost neverThe performance ceiling of safe Go is high enough that most apps never need it

Using unsafe in application code is a strong signal that something has gone wrong, either with the design or with the constraints. The package is there because the runtime needs it, not because day-to-day code does.

When to Use unsafe

A few specific motivations appear consistently.

Interop with C or system calls. When you're calling into a C library through CGO, or making a raw syscall, the data on the other side is laid out the C way, not the Go way. You need a *C.char for a string, a *C.int for an integer, sometimes a pointer into a Go slice's backing array. unsafe.Pointer is the universal type that the conversion goes through.

Avoiding allocations in hot paths. Converting a []byte to a string normally allocates because Go has to copy the bytes (since strings are immutable and the byte slice isn't). For a JSON parser reading millions of small fields per second, that copy is the bottleneck. unsafe.String lets you build a string that shares memory with the byte slice, skipping the copy. The trade is that you have to guarantee the bytes don't change for the lifetime of the string, which is a guarantee the compiler can't check.

Reading from or writing to packed binary formats. When you're parsing a network protocol, a wire format, or a memory-mapped file, you often have a []byte and need to interpret pieces of it as integers, floats, or structs. You can do this with encoding/binary, which uses reflection internally and pays a per-call cost, or you can use unsafe.Pointer to reinterpret the bytes directly. The unsafe approach is significantly faster but requires you to handle alignment and byte order yourself.

Building data structures the language doesn't directly support. Some lock-free data structures need to atomically swap pointers of arbitrary types. Some pool implementations need to store values of unknown types without boxing them into interfaces. These are real needs in systems-level code, and unsafe is how Go lets you express them.

Implementing reflection, serialization, or test frameworks. If you're writing a library like encoding/json or reflect itself, you'll touch unsafe somewhere to walk struct memory by offset, or to construct a reflect.Value that wraps an arbitrary pointer. This is exactly the kind of work unsafe.Pointer was designed for.

None of these motivations apply to a typical e-commerce app reading product data from a SQL database and rendering it as HTML. The standard library handles all of that with safe APIs, and the overhead is invisible at any reasonable request rate. The question to ask, every time, is: have I measured? If you haven't, the unsafe code you're about to write is almost certainly making things worse, not better.

What's Inside the Package

The full surface area of unsafe is small enough to list completely. Here's every type and function in the package, with a one-line purpose.

IdentifierKindWhat it's for
unsafe.PointertypeA typeless pointer that can be converted to or from any other pointer type.
unsafe.Sizeof(x)functionReturns the in-memory size of the type of x in bytes.
unsafe.Alignof(x)functionReturns the required alignment of the type of x in bytes.
unsafe.Offsetof(s.f)functionReturns the byte offset of struct field f from the start of struct s.
unsafe.Add(ptr, len)function (Go 1.17+)Returns a pointer offset by len bytes from ptr. Safer than raw uintptr arithmetic.
unsafe.Slice(ptr, len)function (Go 1.17+)Builds a slice header that views len elements starting at ptr.
unsafe.SliceData(s)function (Go 1.20+)Returns a pointer to the first element of slice s.
unsafe.String(ptr, len)function (Go 1.20+)Builds a string header that views len bytes starting at ptr.
unsafe.StringData(s)function (Go 1.20+)Returns a pointer to the first byte of string s.

The signatures use two placeholder types, ArbitraryType and IntegerType, which exist only in the documentation. They mean "any type" and "any integer type". You'll see them in the godoc but not in real code, because the compiler treats these functions as built-ins with special type-checking rules.

The high-level shape: unsafe.Pointer is the type that crosses type boundaries. The Sizeof family answers questions about memory layout. Add, Slice, SliceData, String, and StringData (the newer additions) provide safer, more readable alternatives to the older patterns of manual uintptr arithmetic.

The following example shows the surface — sizes, alignment, and field offsets — not the mechanics:

The struct is 16 bytes. Both fields are 8 bytes wide, the struct's alignment is 8, and Total sits 8 bytes after the start. unsafe exposes these layout details that Go normally treats as implementation details.

Safety, Portability, and What the Compiler Stops Checking

The moment you import unsafe, three things change about what the compiler is willing to verify.

First, you can convert between unrelated pointer types. Normally, the compiler rejects *int to *float64 because reading a float from bytes that were stored as an integer would give you garbage. With unsafe.Pointer as an intermediate, the conversion compiles and the runtime won't check it. If the bytes happen to be a valid float, you'll get one. If they don't, you get nonsense. There is no exception, no panic, no log message.

Second, your code becomes dependent on the layout decisions of a specific Go version, a specific architecture, and sometimes a specific operating system. The size of int is 8 bytes on amd64 and arm64 but 4 bytes on 386 and arm. The order of fields in a struct is the order you declared them, but the padding between them depends on alignment rules that differ across architectures. A program that uses unsafe.Offsetof to read a struct field's offset works on amd64 today, but it might compile and silently give the wrong answer on a different platform, or after a Go release that reorders something in the standard library.

Third, you lose the garbage collector's help. Normally, every pointer in your program is something the GC knows about. As long as a pointer to an object exists, the GC won't reclaim that object. When you store a pointer as a uintptr (a plain integer), the GC stops tracking it. The object the integer "points to" can be collected, and the integer keeps pointing to memory that may now hold something else, or nothing. The equivalent rule is that any pointer-to-integer conversion needs the integer to be converted back to a Pointer before the GC scans memory, or the object is gone.

Every safety property Go normally gives you is something unsafe can turn off, and the compiler will not warn you when it's been turned off incorrectly.

The diagram captures what shifts. Importing unsafe doesn't change whether the compiler runs, but it does add categories of mistake that the compiler can no longer catch for you. The responsibility for getting those things right moves from the language to the programmer.

A small concrete example. Here's a program that converts a slice of integers to a slice of equal-sized floats without copying. This is the kind of thing unsafe makes possible, and the kind of thing that breaks on platforms with different word sizes.

The float values look like nonsense because they are: we're reading the bit pattern of int64 values and interpreting them as float64. The memory is the same, but the meaning depends entirely on which type the program decides to use. Go's safe rules prevent this kind of mistake by default. unsafe lifts that prevention.

The Go 1 Compatibility Promise Does Not Cover unsafe

Go 1's compatibility guarantee says that code written against the language and standard library will keep working across future Go releases. That promise is one reason Go has been a steady platform for so long. The unsafe package is explicitly excluded.

The package's own documentation says: "The function and type definitions of unsafe.Pointer are part of the Go 1 specification, but the rules of unsafe.Pointer are not". In practice, this means the compiler treats certain patterns as legal, and any program that follows those patterns will keep working. Programs that don't follow them, like ones that do raw uintptr arithmetic outside of those patterns, are running on borrowed time. The runtime may add stricter GC scanning, the compiler may add escape analysis that breaks the assumed memory ordering, or go vet may flag the pattern as a bug in a future release.

This matters because it changes how you should structure unsafe code. If you do reach for unsafe, isolate the unsafe operations behind a narrow, safe API. Document the conversion patterns you depend on. Run go vet regularly. If a future Go release breaks your code, the smaller the unsafe surface, the easier the fix.

The function does what string(b) does without the byte copy. It uses unsafe.SliceData (added in Go 1.20) to get a pointer to the slice's first byte, then unsafe.String (also Go 1.20) to build a string header that points at those bytes. The structure matters: the unsafe code is one small function with a clearly stated precondition, called from safe code.

The older approach used reflect.SliceHeader and reflect.StringHeader, which are now deprecated precisely because they encouraged unsafe code that wouldn't be checkable. Modern Go code should use the unsafe.Slice, unsafe.SliceData, unsafe.String, and unsafe.StringData helpers instead, which are at least amenable to compiler and tool analysis.

go vet's unsafeptr Check

go vet runs a set of static analyses on Go code, looking for patterns that compile fine but are usually bugs. One of those analyses is the unsafeptr check, which looks for invalid uintptr-to-unsafe.Pointer conversions, the most common source of GC-related bugs in unsafe code.

The check flags code like this:

The problem is that converting a pointer to uintptr, doing arithmetic, and converting back is only safe when the original pointer, the arithmetic, and the conversion all happen in a single expression. Splitting them across statements gives the GC a window to move or collect the underlying object, leaving the uintptr pointing to nothing meaningful. go vet catches the most common forms of this mistake.

A clean vet run is a necessary check for any package that uses unsafe, but it's not sufficient. vet catches the most common mistakes, not all of them. It won't catch a struct field offset that's wrong on arm but right on amd64. It won't catch a unsafe.Slice call whose length argument is larger than the underlying allocation. It won't catch the case where you held a string built from unsafe.String past the lifetime of the bytes it views.

Check toolCatchesMisses
CompilerType rule violations, syntax errorsAnything you do through unsafe.Pointer
go vetInvalid uintptr conversion patterns, format string mismatchesLayout assumptions, lifetime bugs, alignment errors
Race detector (-race)Concurrent access without synchronizationSingle-threaded unsafe bugs
TestsWhatever you write tests forBugs that only show up on other architectures

For unsafe code, the layered defense matters. Every package that imports unsafe should pass go vet, run its tests with -race, and ideally run those tests on every target architecture, not just the developer's machine. The Go team's own builders do this for the standard library; application teams that use unsafe should plan for similar discipline.

A Motivating Example: Fast Byte-to-String Conversion

Here's the example we built up earlier, in a slightly more realistic shape. Imagine a server that logs every order record it processes. Each log line is built from a byte buffer, formatted, and then handed to a logger that takes a string. The naive conversion copies bytes; for a server logging hundreds of thousands of records per second, that copy adds up.

The function builds a string header that points at the same bytes as the slice. There's no copy and no allocation. The catch is the safety contract: if anything writes to buf after this call, logLine will silently change too, because they share memory. That's exactly the kind of guarantee Go's normal string semantics give you by default (strings are immutable, so this concern never arises), and exactly the kind of guarantee unsafe puts back on you.

The example shows the structure that robust unsafe code takes: a tiny function, a clear contract, used from safe code that knows what it's promising.

A common alternative when you want to avoid the copy without going through unsafe is to keep the byte slice and use APIs that accept []byte directly. The standard library has []byte versions of most string operations precisely so callers can avoid the conversion. If the API you're calling only takes a string, that's the moment to consider unsafe.String. Without measuring the copy cost, the optimization is unwarranted.

Overview

The following diagram maps what unsafe provides:

The diagram is a roadmap for the rest of this section. unsafe.Pointer and the helpers built on top of it (Add, Slice, String, and friends) are the core mechanics for crossing type boundaries and viewing memory differently. The Sizeof family answers questions about layout. Beyond that, the cgo chapters step out of the unsafe package entirely and into CGO, where many of these primitives are used to bridge Go and C memory models.