CGO: Crossing the Go-C Boundary
Go compiles to a self-contained binary by default, but cgo lets a package call C code and link against native libraries.
That bridge is powerful and expensive: every crossing changes how the runtime schedules work, how you build, and how you reason about memory.
Summary
- cgo is Go's foreign-function interface: an
import "C"package compiles Go together with C via a generated shim, so Go functions can call C and C can call exported Go. - Insight: Legacy C/C++ libraries, OS APIs without pure-Go wrappers, and hardware SDKs often exist only on the C ABI side of the fence.
- Key Concepts: cgo directive, C namespace, OS thread, pointer rules, LDFLAGS/CGO_CFLAGS, build tags.
- When to Use: Wrapping a stable C library you cannot rewrite, calling platform-specific APIs, or shipping a Go plugin consumed from C.
- Limitations/Trade-offs: Slower calls, thread overhead, harder cross-compilation, C memory ownership, and binaries that depend on libc or
.sofiles. - Related Topics: Pure-Go
syscall/x/sysports, RPC sidecars, WebAssembly paths, and FFI safety rules.
Foundations
A normal Go program is compiled entirely by gc (the Go compiler) and linked into one binary.
cgo inserts a second compiler front: cmd/cgo reads Go files that contain import "C" and emits C glue code.
The Go compiler then compiles your package alongside that glue, and the platform C linker pulls in libc and any libraries you name in #cgo LDFLAGS.
The mental model is a three-layer sandwich:
Go code <--> cgo-generated shim <--> C library / libc
The C pseudo-package in Go source is not a real Go package.
Types like C.int and functions like C.sqrt are declarations cgo maps to C symbols.
Comments immediately above import "C" are cgo directives: #include, #cgo CFLAGS, #cgo LDFLAGS, and #define lines that configure the C side.
Without import "C", a file is pure Go even if it lives next to cgo files.
Build tags such as //go:build cgo (and the legacy // +build cgo) let you ship pure-Go fallbacks when CGO_ENABLED=0, which is common in CI, static containers, and cross-compiles.
Mechanics & Interactions
Each cgo call from Go into C runs on a dedicated OS thread.
While inside C, that thread is not running Go code, so the scheduler may spawn another thread so other goroutines keep progressing.
Heavy cgo traffic therefore increases OS thread count and context-switch cost.
This is why a tight loop calling C can cap throughput below what pure Go achieves on the same hardware.
Crossing the boundary also triggers pointer checks.
Go's garbage collector must not move memory that C still references.
Rules documented in cmd/cgo restrict passing Go pointers into C in ways that could outlive the call or hide pointers inside non-pointer Go values.
Violations panic at runtime in checked builds.
Building with cgo enabled requires a working C toolchain (gcc or clang on Linux, Xcode CLI tools on macOS, MinGW on Windows).
CGO_ENABLED=0 disables cgo entirely; go build then fails on packages that import C unless alternate files exist.
Static linking, musl vs glibc, and cross-compiling to GOOS=linux GOARCH=arm64 from macOS all become release-engineering tasks instead of a single go build line.
/*
#include <stdio.h>
*/
import "C"
func greet(name string) {
cs := C.CString(name)
defer C.free(unsafe.Pointer(cs))
C.printf(C.CString("hello %s\n"), cs)
}The snippet shows the recurring pattern: convert Go strings to C (C.CString), free C allocations (C.free), and keep C calls short so pointer lifetimes stay obvious.
Advanced Considerations & Applications
Teams usually isolate cgo behind a small internal package with a pure-Go API.
Callers depend on Go types and errors; only the wrapper imports C.
That boundary makes it possible to test logic without C in most packages and to swap implementations later.
| Approach | Strength | Weakness | Best Fit |
|---|---|---|---|
| cgo in-process | Lowest call latency, shared address space | Thread + build complexity | Hot path must stay in-process |
| RPC to C/C++ sidecar | Independent release and crash isolation | Network/IPC overhead | Legacy code you cannot link safely |
Pure-Go port or x/sys | Simple builds, easy cross-compile | Upfront rewrite or incomplete coverage | Syscalls and algorithms with Go equivalents |
| Rewrite in Go | Idiomatic APIs, one toolchain | Time and validation cost | Libraries with manageable scope |
Security and supply-chain reviews treat cgo code as native code: buffer overflows in C become your process's problem.
Fuzzing and sanitizers (ASan/UBSan) on the C side belong in the same quality bar as Go tests.
Observability splits across runtimes: Go profiles show cgo time under runtime.cgocall, but C hotspots need pprof on the C library or external profilers.
For containers, document whether the image needs libc, libstdc++, or vendor .so files and whether you ship CGO_ENABLED=0 variants for scratch images.
Common Misconceptions
- "cgo is just another import" - It changes the build to require a C compiler and often dynamic libraries; CI must install toolchain packages pure Go skipped.
- "Go's GC frees C.CString memory" -
C.CStringallocates in the C heap; you mustC.freeit (or hand ownership to C with a documented contract). - "cgo calls are cheap like a normal function call" - Each crossing costs more than a Go call and may pin an OS thread for the duration of C work.
- "I can cross-compile Go anywhere with cgo" - You need a cross C toolchain matching the target unless you build on the target or use QEMU builders.
- "Disabling cgo only affects optional features" - Many database drivers and graphics stacks hard-require cgo;
CGO_ENABLED=0builds fail unless you choose alternate drivers.
FAQs
What triggers cgo in a package?
Any Go file with import "C" and the cgo comment block above it.
The go tool invokes cmd/cgo automatically during go build and go test.
Can I mix cgo and pure Go files in one package?
Yes - only files that import C participate in cgo.
Keep import "C" files few and push logic into pure Go siblings.
Why does cgo use OS threads?
The Go scheduler cannot preempt arbitrary C code safely.
cgo runs C on a thread the runtime manages so goroutines and signals still behave predictably.
What is CGO_ENABLED?
An environment variable defaulting to 1 when a C toolchain is detected.
Set CGO_ENABLED=0 to force pure Go builds for static binaries and simpler CI.
Do I need cgo for all syscalls?
No - many syscalls are wrapped in pure Go (golang.org/x/sys) or the standard library.
Reach for cgo when no safe Go wrapper exists or a vendor ships only a C SDK.
How do build tags help?
//go:build cgo files compile only when cgo is on; !cgo files provide stubs.
This pattern powers portable modules like sqlite drivers and terminal libraries.
Can C call back into Go?
Yes, with //export functions and careful use of runtime.LockOSThread when C holds thread-local state.
Callbacks are harder than one-way calls - see the exporting guide in this section.
Does cgo work with modules and vendoring?
Yes - #cgo LDFLAGS: -L${SRCDIR}/lib paths are common for vendored .a files.
You still own licensing and platform-specific artifacts in vendor/ or internal/.
What breaks when I move from amd64 to arm64?
You need arm64 builds of every .so and a matching cross compiler if you cross-build.
Pure Go artifacts do not have that coupling.
When is cgo clearly the wrong default?
When a maintained pure-Go library exists, when RPC isolation is acceptable, or when you need tiny static binaries without libc.
Measure latency before committing to in-process cgo.
How do I see cgo cost in production?
CPU profiles label runtime.cgocall.
Also watch thread count and tail latency under load; both rise when C sections run long.
Is WebAssembly an alternative to cgo?
For browser and some edge hosts, yes - Go can target wasm with GOOS=js or wasip1.
Native OS integration still needs cgo or pure-Go syscall wrappers.
Related
- CGO Basics - Minimal program, headers, and
#cgoflags - Calling C from Go & Exporting Go to C - Directives, callbacks, and exports
- CGO Performance & Scheduler Interactions - Thread overhead under load
- syscall & Pure-Go Alternatives to CGO - When to skip the C bridge
- Interop Decision Guide: CGO vs RPC vs Rewrite - Picking an integration strategy
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).