AlgoMaster Logo

CGO Pitfalls & Alternatives

Low Priority25 min readUpdated June 6, 2026

CGO lets Go programs call into C libraries. What hasn't been covered is the cost of the bridge: how slow each crossing is, how it changes scheduling and garbage collection, how it complicates builds and deployments, and when you should pick a non-CGO path instead. This chapter focuses on those trade-offs and on the alternatives that exist for cases where CGO is too expensive.

The Cost of a CGO Call

Every call from Go into C has fixed overhead, and the number matters because it sets a floor on how cheap your wrapped C function can ever look. The runtime has to save the current goroutine state, hand control off to an OS thread that is allowed to run unmanaged code, switch stacks, and run the call. On the way back it has to restore the goroutine and check whether garbage collection wants to step in.

On modern hardware the round-trip lands somewhere in the high tens to low hundreds of nanoseconds. The exact number depends on the Go version, the CPU, and what else the runtime is doing at the time. Treat that as an order-of-magnitude figure, not a benchmark guarantee. A CGO call is roughly two to three orders of magnitude slower than a plain Go function call, which takes a single-digit number of nanoseconds.

The overhead breaks down into a few moving pieces that the runtime has to handle on every call:

PhaseWhat happens
Argument marshallingGo types are converted to their C equivalents, sometimes copied
Goroutine detachThe current goroutine is removed from its P so the P can run other Gs
Stack switchGo goroutines use small growable stacks; C needs the OS thread stack
C callThe actual function runs on the OS thread
Return marshallingThe C return value is converted back to the Go type
Goroutine reattachThe goroutine is reattached to a P, possibly a different one
GC checkpointThe runtime checks whether garbage collection wants to pause

Each row adds a small amount of work. None is expensive on its own, but their sum sets a hard floor on how fast a CGO call can ever be, regardless of how trivial the C function is.

The diagram shows where the overhead lives. Each box on the path adds latency. The C function in the middle could be a one-instruction increment, and you would still pay for both CGO boundary boxes and both stack switches.

Here is a small program that demonstrates the wrapping pattern for a trivial C function that adds two integers. The Go and C code live in the same file.

The work add does is trivial. The CGO overhead is not. We measure this in the benchmark section later in the chapter, and the difference is large enough that it changes how you should design the boundary: pass batches of data per call rather than one item at a time whenever you can.

Scheduler and GMP Impact

The Go scheduler runs goroutines on OS threads using the GMP model: G is a goroutine, M is an OS thread, and P is a processor (a scheduling slot, with a count equal to GOMAXPROCS). Most of the time a single M handles many Gs by quickly switching between them, because Go controls both ends and knows where every goroutine can be safely preempted.

C code is opaque to the scheduler. While a goroutine is executing inside a C call, the runtime cannot preempt it, scan its stack, or relocate it. The runtime handles this by detaching the goroutine from its P at the start of the call and parking it on a dedicated M that will stay in C until the call returns. The freed-up P is given to another M, so the rest of the program keeps moving.

The diagram shows what happens when a goroutine crosses into C. G1 is a normal Go goroutine running on P1 and M1. G2 is in C, so its M2 is stuck for as long as the C call lasts; the scheduler hands P2 to a fresh M3 to keep the rest of the program scheduled. The cost is the extra OS thread, plus the latency of detaching and reattaching.

For short C calls this is fine, because the M will be reused for many calls and the thread count stays bounded. For long-running C calls (say, anything over a few hundred microseconds) the picture changes. If you have a hundred goroutines that each enter a long C call at the same time, the runtime ends up with a hundred dedicated OS threads, and the OS scheduler now has to manage them all. That can starve the rest of your Go code and inflate memory use, because each OS thread carries its own stack.

The runtime also has a soft cap on the number of OS threads through the runtime.SetMaxThreads function, which defaults to 10,000. A program that crosses that limit because of CGO concurrency will crash with a runtime: program exceeds N-thread limit message rather than silently degrading. That is intentional: a program needing more than a few thousand threads is almost certainly misusing CGO, and crashing early is better than thrashing forever.

A worked example. Suppose we have a C function that simulates a slow batch operation on an order:

The buffered channel caps concurrent C calls at 8. Without that cap, 64 goroutines would enter C at the same time and the runtime would spawn 64 OS threads. With the cap, we hold the count at 8 and the OS scheduler stays sane. Total time goes up a little because we are serializing within each batch, but memory and thread count stay predictable.

Garbage Collection and Pointer Rules

Go's garbage collector walks goroutine stacks to find live pointers. It cannot walk C stack frames, because it does not know where the pointers are or what shapes they have. While a goroutine is inside C, the runtime treats its Go stack as a black box: the GC marks roots from the boundary inward, and the C-side frames are skipped.

That arrangement is safe as long as one rule holds: Go pointers must not be stored where the garbage collector cannot see them. The cgo runtime enforces a stricter version of that rule, summarized in the cgo documentation:

  • A Go pointer may be passed to C, but the C code may only use it for the duration of the call.
  • A Go pointer passed to C cannot point to memory that itself contains further Go pointers.
  • C cannot store a Go pointer in C-allocated memory (such as something returned from malloc) across calls.

The third rule is the one most likely to trip people up. The pointer rule is what governs lifetime: if a Go pointer escapes into C-owned memory, the garbage collector cannot follow it, and the pointed-to memory can be moved or reclaimed without the C side knowing.

GODEBUG=cgocheck=1 (the default in recent Go versions) makes the runtime check incoming pointer arguments for violations of the second rule, and a higher setting like cgocheck=2 makes the checks more thorough at a cost in speed. You can disable the checks for performance work with GODEBUG=cgocheck=0, but only after you've convinced yourself the code is correct.

GODEBUG=cgocheck=2 runs a deep walk of every pointer-argument struct on every CGO call. It catches more bugs and is roughly an order of magnitude slower than the default. Use it during testing, not in production.

The rule follows directly from what the garbage collector can and cannot see:

The diagram captures the underlying reason for the rule. The garbage collector knows how to walk Go-side memory, both heap and stacks. C-side memory is opaque to it. A Go pointer that ends up only reachable from a C location is, from the GC's perspective, a pointer that has no roots, and the object it points to can be reclaimed at the next collection. The cgo runtime checks help catch this; the rule itself is what keeps you safe.

The right pattern for passing data from Go to C and back is usually one of:

PatternWhat to do
Small fixed-size dataPass by value (e.g., C.int, C.double) or pack into a small struct passed by value
Read-only bufferPass (*C.char, C.size_t), do not retain past the call
Buffer C must fill inC.malloc the buffer in C land or pre-allocate it in Go and pass a pointer for the duration of the call only
Long-lived stateKeep the data in C-owned memory; pass an opaque handle (unsafe.Pointer to a C struct) back to Go

The first three are about staying within the cgo pointer rules. The fourth is the common pattern for wrapping C libraries that have their own object lifecycle (a database handle, a parsed XML document, an SSL context): C owns it, Go holds an opaque pointer, and Go calls a C destructor when it's done.

A short example of the opaque-handle pattern, applied to a fictional cart object that lives in C:

The Cart struct lives entirely on the C heap. Go holds a *C.Cart, which from Go's side is just an opaque pointer. Every operation is a CGO call, which is fine here because the cart only changes when the user adds an item, not in a tight inner loop. The matching cart_free is wired to a defer at the allocation site so the cleanup is obvious.

Build Complexity and Portability

A pure Go program builds with a single command and no system dependencies beyond the Go toolchain. A CGO-enabled program adds a C compiler to the chain, plus headers and libraries for whatever C code you are calling.

The simplest portability switch is CGO_ENABLED. When it is set to 0, the Go build skips CGO entirely; any package that uses import "C" fails to compile. With CGO disabled, the resulting binary is fully statically linked, depends on no system libraries, and cross-compiles trivially.

The static binary is the default Docker pattern for Go services: CGO_ENABLED=0 go build, then drop the binary into a FROM scratch or distroless image. As soon as you turn CGO on, that simplicity goes away.

The two branches lead to different deployment stories. The pure Go branch produces a binary you can scp anywhere with a compatible CPU and OS. The CGO branch produces a binary that needs the right shared libraries at the right versions on the target machine.

A few portability gotchas worth knowing about:

  • Linux glibc vs musl. A CGO binary built against glibc on Ubuntu will not start on Alpine, which uses musl. The error is usually not found even though the file is right there, because the dynamic linker is looking for /lib64/ld-linux-x86-64.so.2. Build inside an Alpine container if you want to ship to Alpine, or build a static binary with the right flags.
  • Windows toolchain. CGO on Windows requires a gcc-compatible toolchain, usually MinGW-w64. The Go installer does not bundle it. CI pipelines need to install MinGW separately.
  • macOS SDK versions. CGO on macOS picks up headers from whichever Xcode Command Line Tools are installed. A binary built on macOS 14 may not run on macOS 11, depending on what it links against. Cross-compiling CGO to macOS from another OS is painful and most teams skip it.
  • Cross-compilation. GOOS=linux GOARCH=arm64 go build works for pure Go. With CGO it requires a cross-compiler for the target. Tools like xgo and zig cc exist to make this less painful, but they add their own moving parts.

Debugging Across the Boundary

Debugging a CGO program means dealing with two runtimes at once. Go's runtime owns goroutine scheduling, the garbage collector, and panic handling. The C side has its own ideas about stacks, signals, and lifetimes. Crashes can come from either side, and the trace you get is often a mix of both.

Useful tools and switches:

  • `runtime.GOTRACEBACK=crash`. Setting this environment variable makes the runtime dump full goroutine stacks and a core file on a fatal error. Without it, you get an abridged trace that often hides the C side.
  • `delve` for source-level debugging. dlv works with CGO programs, but you may need to enable CGO debug info with -gcflags=all="-N -l" on the Go side and -g in the C build flags through #cgo CFLAGS: -g.
  • `lldb` or `gdb` for C-side investigation. Once the program has crashed inside C, a native debugger attached to the core file is often easier than fighting through dlv. The Go runtime helpers (runtime/cgo) are visible in these tools.
  • `go build -ldflags="-linkmode=external"`. Useful when you need to debug linker issues, especially on macOS where the default linker behavior changed in recent Go versions.

A crash inside C usually surfaces as a signal: SIGSEGV for a bad pointer dereference, SIGABRT for an explicit abort(), SIGBUS for an alignment fault. The Go runtime installs its own signal handlers at startup, and it cooperates with C libraries that install theirs, but only up to a point.

If a C library installs a signal handler that does not chain to the previously installed handler, it can break the Go runtime. The typical symptom is a hang or a confusing trace where the runtime appears to have stopped responding. The solution is to avoid C libraries that hijack signal handling, or wrap them so they cooperate with the runtime.

A quick reference for the most useful debug switches:

SwitchEffect
GOTRACEBACK=crashFull goroutine dump plus core file on fatal error
GOTRACEBACK=allFull traces for all goroutines, no core file
GODEBUG=cgocheck=1Default; checks pointer arguments for the most common rule violations
GODEBUG=cgocheck=2Deeper checks at significant cost; useful during testing
GODEBUG=cgocheck=0Disables checks for performance work; only after correctness is established
GODEBUG=schedtrace=1000Prints scheduler state every 1000ms; helpful for thread-explosion diagnoses

The exact pointer changes per build and OS, but the purpose is to confirm that a handler is installed. If the address matches a Go runtime symbol, the runtime handler is in place; if it matches a third-party library, the library has overwritten it. The fact that you can ask this question at all is one reason CGO is so much harder to debug than pure Go.

Benchmarking: CGO vs Pure Go

The cleanest way to feel the cost of a CGO call is to benchmark it side by side with a pure Go function that does the same work. The Go testing package has a built-in benchmarking framework that takes care of timing and iteration counts.

Create a file bench_test.go in a CGO-enabled package:

Run it with the standard benchmark command:

The pure Go call inlines into a single instruction, so the benchmark loop iterates a billion times in under half a second. The CGO call runs roughly 200x slower because almost all the measured time is the boundary, not the addition. The C function does the same arithmetic; the cost is the crossing.

The same lesson scales up. If you wrap a C image library and call it once per pixel, the boundary overhead alone will dominate. If you call it once per image and let C process the pixels in a loop, the overhead disappears into the work. The fix is almost always the same: move the loop into C, pass batches across the boundary, and pay the per-call cost as few times as possible.

A concrete restructuring. Suppose you have a C function apply_discount that adjusts a single price, and you want to apply it to every price in an order. The naive design crosses the boundary once per price:

The better design pushes the loop into C and crosses the boundary once for the whole slice:

For 1,000 prices, the first version pays the boundary cost 1,000 times. The second pays it once. The C-side work is unchanged. The difference is wall-clock time that you do not have to spend.

One more useful experiment: measure the cost of passing different argument types across the boundary. Small integers and floats are cheap because they fit in registers. Strings, slices, and structs are more expensive because they require copying or marshalling. A benchmark that varies the argument type shows where the marginal costs sit.

The third benchmark will be dramatically slower than the first two because C.CString allocates a fresh C buffer and copies the bytes on every iteration, and C.free then releases that buffer. If you find yourself stringifying on every call, look for an API that takes a pre-allocated buffer instead, or push the conversion into C once and operate on the C string from there.

Common Pitfalls Checklist

A short list of mistakes that come up over and over with CGO. Treat it as a review checklist whenever you add or change a CGO wrapper.

  • Passing slice headers directly. Passing a Go slice to C using its header (pointer plus length plus capacity) breaks the cgo pointer rules because slices contain a Go pointer. Pass (*C.char)(unsafe.Pointer(&s[0])) and C.size_t(len(s)) as two separate arguments instead, and only for the duration of the call.
  • Holding Go pointers in C across calls. C must not retain a Go pointer beyond the call that received it. The garbage collector might move or reclaim the referent. If C needs long-lived storage, allocate it in C with malloc or hold a pinned Go pointer using runtime.Pinner (Go 1.21+).
  • Forgetting to free C memory. Anything C.malloc'd (directly or by a library function that promises a malloc'd buffer) must be freed with C.free. Wrap allocations in defer C.free(unsafe.Pointer(p)) at the point of allocation so the cleanup is visible.
  • Leaking C strings made with `C.CString`. C.CString(s) allocates a fresh C string and copies the bytes; it does not share storage with the Go string. Free it after use, or it leaks for the lifetime of the process.
  • Ignoring `errno`. Many C functions report failure by returning a sentinel value and setting the global errno. CGO exposes errno via the two-value return form: n, err := C.read(...). Drop the second value and you lose the error.
  • Mismatched calling conventions on Windows. Windows API entry points often use the stdcall convention rather than cdecl. Wrapping them with the wrong convention works on some compilers and corrupts the stack on others. Use the __stdcall annotation in the C declaration or, better, use golang.org/x/sys/windows for native Win32 calls.
  • Returning a pointer to a local C variable. A C function that returns the address of a stack-allocated variable returns a dangling pointer. The next CGO call will overwrite the stack. Allocate with malloc and document who frees it.
  • Calling Go from C without `//export`. A C callback that needs to invoke Go code has to call a function declared with the //export FunctionName comment. Direct calls to internal Go functions fail to link.
  • Long-running C calls under high goroutine concurrency. Every goroutine in C burns one OS thread. Bound the concurrency with a worker pool when the C call is slow.
  • Using `unsafe.Pointer` to bridge types without thinking. CGO type conversions look like casts, but each one has rules. Round-tripping a Go pointer through unsafe.Pointer to convert it to a *C.char is fine within a single call; storing the result for later is not.
  • Assuming the C error model matches the Go error model. A C function that returns 0 on success and -1 on failure is the opposite of Go's convention. Wrap the C call in a thin Go helper that returns error, and translate the sentinel value once at the boundary.

Alternatives to CGO

CGO is one way to use code that isn't written in Go. It is not the only one. Each alternative trades off different costs, and the right choice depends on what the dependency is, how stateful it is, and how much code you control.

AlternativeWhen it fitsTrade-off
Pure Go reimplementationThe C function is small, well-defined, and you can read itUp-front engineering cost; no boundary overhead afterwards
syscall / golang.org/x/sysYou need OS-level calls (sockets, ioctls, /proc)OS-specific code paths; no general C library access
plugin packageLinux or macOS, you control both sides, dynamic loading helpsLimited platform support; ABI changes break loading
WebAssembly (wazero)Untrusted code, sandboxing matters, language interopSandbox adds overhead and limits direct system access
Out-of-process / RPCThe C dependency is heavy, stateful, or already a serviceLatency of the IPC path; deployment of a separate process
purego (third-party)dlopen style interop without the Go C bridgeSmaller ecosystem; not in the standard library

Pure Go Reimplementation

If the C function is small and the algorithm is straightforward, the cheapest long-term option is to rewrite it in Go. You lose the CGO boundary cost, the build complexity, the runtime concurrency limits, and the debugging headaches in one move. The standard library uses this approach for everything from crypto/sha256 to compress/gzip: the algorithms are well-defined, the Go implementations are within striking distance of their C counterparts, and the deployment story is simpler.

The case for rewriting weakens fast as the C code grows. A 200-line algorithm is a weekend. A 50,000-line library with twenty years of bug fixes is not. The honest test is whether you would be willing to own the rewrite as production code, including the bugs you'll introduce that the original code had already fixed. Most teams underestimate this. The standard library can afford it because the algorithms (hashing, compression, TLS) are public, specified, and heavily tested. Application-specific C code rarely meets that bar.

syscall and golang.org/x/sys

For raw OS calls, the syscall package (now mostly frozen) and the supported golang.org/x/sys/unix and golang.org/x/sys/windows packages give you direct access to the kernel without CGO. Anything you would invoke through a C wrapper to libc that ultimately becomes a syscall (open, read, ioctl, kevent, epoll) can usually be done this way. The packages are maintained alongside the standard library and cover platform-specific surface areas that the core os package leaves out.

The unix.Stat call goes straight to the kernel through a Go assembly stub. No CGO, no thread parking, no boundary cost. The downside is that this only helps when the dependency is a system call; it does not let you call into an arbitrary C library.

The plugin Package

Go's plugin package lets a program load shared objects (.so on Linux, .dylib on macOS) at runtime and call exported symbols. It is sometimes pitched as a CGO alternative, but the use case is narrower: both the host program and the plugin must be Go, built with the same toolchain and flags, against the same Go version. The plugin can use CGO internally, but the plugin mechanism itself is not a way to load arbitrary C libraries dynamically.

This works on Linux and macOS only. Windows is not supported, and the ABI sensitivity makes plugins fragile across Go version upgrades. Use plugins when you genuinely need runtime extensibility within a Go ecosystem, not as a way to side-step CGO.

WebAssembly via wazero

For loading untrusted code or sandboxed extensions, a WebAssembly runtime like `wazero` is now a credible choice. The host program is pure Go, the guest is a .wasm module that can be produced from C, Rust, or other languages, and the boundary is enforced by the wasm sandbox rather than by the OS.

This route avoids CGO entirely. You give up some performance (the wasm runtime is interpreted or JITted) and some direct OS access (the guest has only the imports you give it), but you keep a static Go binary and a clear security boundary. It is a good fit for plugin systems where you don't trust the plugin code, or for ports of small algorithms that exist as wasm modules and would be a pain to compile against CGO.

Out-of-Process and RPC

When the C dependency is large, stateful, or already exists as a service, the cleanest answer is to keep it in its own process and talk to it over IPC: gRPC, HTTP, a Unix socket, or shared memory if performance demands it. The latency goes up compared to an in-process CGO call, but the operational story is straightforward: independent deployment, independent crashes, independent resource limits.

Out-of-process is the standard pattern for things like Python ML models served by a separate inference process, or for legacy systems wrapped behind a gRPC facade. It is rarely the wrong answer when the C dependency is heavy enough that a per-call millisecond doesn't matter.

A short comparison of the three in-process and out-of-process options:

PropertyCGOOut-of-Processwasm (wazero)
Per-call latencyHigh tens of nsHundreds of µs to msLow µs
Crash isolationNone: C crash kills GoStrong: separate processStrong: sandbox
Build complexityHigh: needs C toolchainLow: each side built separatelyMedium: needs wasm module
DeploymentSingle binary, dynamic linksMultiple binariesSingle Go binary, embedded wasm
State sharingDirect pointer accessSerialize across the wireLinear memory, copies in and out

There is no universally right answer in this table. The right choice depends on which property matters most for the workload.

purego

For completeness, `purego` is a third-party library that loads shared libraries with dlopen and calls into them without using CGO. It works by writing the right machine code at the boundary on supported platforms. It is interesting for cases where you want to avoid CGO's build complexity but still load a C library at runtime, such as audio drivers in games. Treat it as an ecosystem option, not a default: it covers fewer platforms than CGO and the development model is different.

Should You Use CGO? A Decision Flowchart

When the question comes up, walking through this flow tends to land in the right place.

The chart is biased away from CGO on purpose. Every other branch ends at an option with better build, debugging, or deployment properties. CGO is the right answer when none of those branches fit: you need a real C library with a real API, the algorithm is too big to rewrite, the dependency is in-process and stateful, and the workload is trusted. That is a real set of cases, and CGO exists for them. It is not the default.

It helps to phrase the decision as a budget question. CGO costs a development-time tax (build complexity, debugging tax) and a runtime tax (per-call overhead, thread pressure). Pure Go and x/sys cost engineering time once. Out-of-process and wasm cost latency on every call. Pick the option whose costs you can afford given the workload, and design the boundary to minimize the costs you pay.

Quiz

cgo Pitfalls Quiz

10 quizzes