When the compiler is too cautious
You are writing a high-performance memory allocator. You pass a pointer to a buffer into a function that reads the data and returns a result. The profiler shows heap allocations on every call. You look at the code. The function is simple. It does not store the pointer. It does not return it. It just reads. The Go compiler, in its infinite caution, decides the pointer might escape to the heap. Suddenly, your hot path is littered with garbage collection pressure.
Or you are inside a signal handler holding a lock. The runtime tries to grow the goroutine stack. The stack growth routine needs to acquire a mutex. You are already holding that mutex. The program freezes. Deadlock.
These are edge cases. Most Go code never encounters them. The compiler is usually right. But when you are writing low-level libraries, wrapping C code, or hacking the runtime, the compiler's safety checks can become performance bottlenecks or correctness traps. You need to whisper to the compiler: "Trust me. I know what I am doing."
That is what //go:noescape and //go:nosplit are for. They are compiler directives that override default behavior. They are escape hatches for experts. Use them sparingly. Use them correctly.
Escape analysis and the heap tax
Go manages memory automatically. The compiler decides where to put each variable: on the stack or on the heap. Stack allocation is fast. The memory is reclaimed automatically when the function returns. Heap allocation is slower. It requires the garbage collector to track and clean up the memory later.
The compiler uses escape analysis to make this decision. It traces every pointer in your code. If a pointer might outlive the function, the compiler moves the data to the heap. This is called "escaping."
Escape analysis is conservative. The compiler cannot always prove that a pointer stays local. If you pass a pointer to a function that the compiler cannot analyze deeply, it assumes the worst. It assumes the pointer escapes. It allocates on the heap.
This assumption protects you from bugs. It prevents dangling pointers. It keeps the language safe. But safety has a cost. Heap allocations trigger garbage collection. In a tight loop, thousands of unnecessary heap allocations can degrade performance by orders of magnitude.
//go:noescape tells the compiler to stop worrying. It promises that a pointer argument does not escape the function. The compiler believes you. It keeps the data on the stack. The heap tax disappears.
Telling the compiler to trust you
The directive goes on the line immediately before the function declaration. There is no space between the slashes and the directive name. The compiler is strict about formatting.
Here is a minimal example. The function takes a pointer to a byte. It reads the byte. It does not store the pointer. Without the directive, the compiler might be conservative. With the directive, it knows the pointer stays on the stack.
//go:noescape
func readByte(ptr *byte) byte {
// Read the value pointed to by ptr
// The pointer itself does not escape this function
return *ptr
}
The directive applies to pointer arguments. It does not apply to return values. If the function returns a pointer, the compiler rejects the program. The compiler enforces this rule to prevent you from accidentally returning a stack address that becomes invalid after the function returns.
The compiler complains with go:noescape function returns pointer if you try to return a pointer from a noescape function. This error saves you from a use-after-free bug.
Realistic usage: wrapping C code
The most common use of //go:noescape is when wrapping C functions or assembly routines. C functions often take pointers to buffers. The Go compiler cannot analyze C code. It sees a pointer passed to an external function and assumes escape.
You write a Go wrapper. You add //go:noescape to the wrapper. You tell the compiler the C function only reads the buffer. The compiler keeps the buffer on the stack. You avoid heap allocations.
//go:noescape
func c_read(fd int, buf *byte, count int) int
func Read(fd int, data []byte) (int, error) {
// Convert slice to pointer for C call
// The slice data stays on the stack if possible
ptr := &data[0]
n := c_read(fd, ptr, len(data))
if n < 0 {
return 0, syscall.GetErrno()
}
return n, nil
}
The //go:noescape directive on c_read is crucial. Without it, the compiler might allocate data on the heap just to pass it to C. With it, the compiler knows the pointer does not escape the C call. The data can stay on the stack.
Convention aside: receiver names in Go are usually one or two letters matching the type. (b *Buffer) Read(...) is standard. (this *Buffer) or (self *Buffer) is not. Keep your code idiomatic even when using low-level directives.
Stack splitting and the deadlock trap
Goroutines start with small stacks. The initial size is usually 4KB or 8KB. This is efficient. It allows millions of goroutines to run on a single machine. But small stacks fill up quickly.
When a goroutine needs more stack space, the runtime grows the stack. This process is called stack splitting. The runtime allocates a larger stack on the heap. It copies the old stack data to the new stack. It updates pointers. It resumes execution.
Stack splitting is safe. It is transparent. But it is not free. It involves memory allocation. It involves copying data. It involves acquiring locks. The runtime must pause the goroutine. It must ensure no other goroutine is accessing the stack.
In most code, this overhead is negligible. But in critical sections, it is fatal.
Imagine you are holding a lock. You call a function. The function needs more stack. The runtime tries to grow the stack. The stack growth routine needs to acquire a mutex. You are already holding that mutex. The runtime waits for you to release the lock. You wait for the stack to grow. Deadlock.
Or imagine you are in a signal handler. Signal handlers run in a special context. They must not block. They must not call functions that might grow the stack. If the stack grows, the runtime might corrupt the signal context. The program crashes.
//go:nosplit tells the runtime to never grow the stack in this function. If the function needs more stack than is available, the runtime panics. It does not try to grow. It stops.
The no-split guarantee
The directive goes on the line immediately before the function. Like //go:noescape, it requires no space after the slashes.
Here is a minimal example. The function acquires a lock. It must not grow the stack.
//go:nosplit
func criticalSection() {
// Acquire lock without risking stack growth
// If the stack is full, the runtime panics
lock.Lock()
// ...
}
The directive applies to the function and any functions it calls. If you call a function without //go:nosplit from a no-split function, the compiler rejects the program. The compiler enforces this rule to prevent accidental stack growth.
The compiler complains with go:nosplit function calls function that may split stack if you violate this rule. This error ensures the no-split guarantee holds across call chains.
Realistic usage: signal handlers and runtime code
The standard library uses //go:nosplit in signal handlers and runtime internals. Signal handlers must be fast. They must not block. They must not grow the stack.
When a signal arrives, the runtime calls a handler. The handler must record the signal. It must wake up a goroutine to process it. It cannot afford stack splitting. The handler uses //go:nosplit.
//go:nosplit
func sigtramp(sig uint32) {
// Record signal in a global variable
// Wake up the signal handling goroutine
// Do not grow the stack
}
Library authors use //go:nosplit when writing functions that must not block. For example, a function that acquires a lock that protects the stack growth mechanism itself. If the function could grow the stack, it would deadlock. The function uses //go:nosplit.
Convention aside: context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. This convention does not apply to low-level runtime functions, but it is good to remember for the rest of your codebase.
Pitfalls and compiler rejections
Directives are promises. Break them and the consequences are severe.
//go:noescape is a promise that a pointer does not escape. If you lie, you get undefined behavior. The compiler keeps the data on the stack. The function returns. The stack frame is reused. The pointer now points to garbage. You read garbage. You write garbage. The program crashes.
The compiler does not always catch lies. It trusts the directive. It does not verify the promise at runtime. You must verify it yourself.
//go:nosplit is a promise that the function does not need more stack. If you lie, you get a panic. The function needs more stack. The runtime cannot grow it. The runtime panics with runtime: goroutine stack exceeds 1000000000-byte limit or a similar stack overflow error. The program crashes.
The compiler helps you avoid some mistakes. It rejects //go:noescape functions that return pointers. It rejects //go:nosplit functions that call non-no-split functions. But it cannot catch all mistakes. You must understand the mechanics.
Debugging directive bugs
Debugging directive bugs is hard. The compiler does not warn you. The runtime might crash later. The crash might look unrelated.
If you use //go:noescape, check the function carefully. Does it store the pointer? Does it return the pointer? Does it pass the pointer to a function that might store it? If yes, remove the directive. Let the compiler allocate on the heap.
If you use //go:nosplit, check the stack usage. Does the function allocate large variables? Does it call functions that allocate? Does it recurse? If yes, remove the directive. Let the runtime grow the stack.
The worst directive bug is the one that never crashes. It corrupts memory silently. It causes intermittent failures. It wastes days of debugging.
Trust the compiler unless you have proof. Profile your code. Measure the impact. If the directive does not improve performance, remove it.
Decision matrix
Directives are powerful. They are also dangerous. Use them only when you have a clear reason.
Use //go:noescape when you wrap a C function or assembly routine that reads a buffer but never stores the pointer. Use //go:noescape when the profiler shows unnecessary heap allocations on a hot path and you have verified the pointer does not escape. Use standard Go functions when the compiler can prove pointer safety and heap allocations are acceptable.
Use //go:nosplit when you are inside a signal handler, a runtime lock critical section, or a function that must not block or grow the stack. Use //go:nosplit when you are writing low-level runtime code that protects the stack growth mechanism. Use standard Go functions when stack growth is safe and the function does not hold locks that conflict with the runtime.
The compiler is conservative by design. Override it only when you have proof. Stack splitting is a safety net. //go:nosplit removes the net. Directives are promises. Break them and the runtime crashes.