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.
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:
| Phase | What happens |
|---|---|
| Argument marshalling | Go types are converted to their C equivalents, sometimes copied |
| Goroutine detach | The current goroutine is removed from its P so the P can run other Gs |
| Stack switch | Go goroutines use small growable stacks; C needs the OS thread stack |
| C call | The actual function runs on the OS thread |
| Return marshalling | The C return value is converted back to the Go type |
| Goroutine reattach | The goroutine is reattached to a P, possibly a different one |
| GC checkpoint | The 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.
Cost: a CGO call costs roughly 100x more than an equivalent pure Go call. If the C function does very little, the cost of the call dominates the cost of the work.
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.
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.
Cost: every goroutine currently inside a C call costs one OS thread for the duration. Long C calls amplify this. Bound concurrency at the Go side with a worker pool or semaphore when wrapping long-running C functions.
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.
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:
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:
| Pattern | What to do |
|---|---|
| Small fixed-size data | Pass by value (e.g., C.int, C.double) or pack into a small struct passed by value |
| Read-only buffer | Pass (*C.char, C.size_t), do not retain past the call |
| Buffer C must fill in | C.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 state | Keep 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.
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:
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.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.Cost: every CGO dependency is a build-host dependency too. Document the exact toolchain and library versions your build expects, and bake them into your CI image. A working laptop build is not a portable build.
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:
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.dlv. The Go runtime helpers (runtime/cgo) are visible in these tools.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:
| Switch | Effect |
|---|---|
GOTRACEBACK=crash | Full goroutine dump plus core file on fatal error |
GOTRACEBACK=all | Full traces for all goroutines, no core file |
GODEBUG=cgocheck=1 | Default; checks pointer arguments for the most common rule violations |
GODEBUG=cgocheck=2 | Deeper checks at significant cost; useful during testing |
GODEBUG=cgocheck=0 | Disables checks for performance work; only after correctness is established |
GODEBUG=schedtrace=1000 | Prints 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.
Cost: every CGO call slightly complicates debugging. Stack traces are interleaved, signals are shared, and tooling has to be configured for both languages. Plan for longer incident investigation when a CGO-heavy service goes down.
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.
Cost: the right CGO design pays the per-call overhead at a coarse grain. A loop calling C once per element is usually wrong; the same loop run inside C with a single call from Go is usually right.
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.
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.
(*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.malloc or hold a pinned Go pointer using runtime.Pinner (Go 1.21+).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.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.errno. CGO exposes errno via the two-value return form: n, err := C.read(...). Drop the second value and you lose the error.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.malloc and document who frees it.//export FunctionName comment. Direct calls to internal Go functions fail to link.unsafe.Pointer to convert it to a *C.char is fine within a single call; storing the result for later is not.error, and translate the sentinel value once at the boundary.Cost: every item on this list is a real production incident someone has had. Reading the checklist is cheap; debugging the consequences is not.
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.
| Alternative | When it fits | Trade-off |
|---|---|---|
| Pure Go reimplementation | The C function is small, well-defined, and you can read it | Up-front engineering cost; no boundary overhead afterwards |
syscall / golang.org/x/sys | You need OS-level calls (sockets, ioctls, /proc) | OS-specific code paths; no general C library access |
plugin package | Linux or macOS, you control both sides, dynamic loading helps | Limited platform support; ABI changes break loading |
WebAssembly (wazero) | Untrusted code, sandboxing matters, language interop | Sandbox adds overhead and limits direct system access |
| Out-of-process / RPC | The C dependency is heavy, stateful, or already a service | Latency of the IPC path; deployment of a separate process |
purego (third-party) | dlopen style interop without the Go C bridge | Smaller ecosystem; not in the standard library |
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.
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.
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.
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.
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:
| Property | CGO | Out-of-Process | wasm (wazero) |
|---|---|---|---|
| Per-call latency | High tens of ns | Hundreds of µs to ms | Low µs |
| Crash isolation | None: C crash kills Go | Strong: separate process | Strong: sandbox |
| Build complexity | High: needs C toolchain | Low: each side built separately | Medium: needs wasm module |
| Deployment | Single binary, dynamic links | Multiple binaries | Single Go binary, embedded wasm |
| State sharing | Direct pointer access | Serialize across the wire | Linear 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.
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.
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.
10 quizzes