CGO & Interoperability Best Practices
Isolate cgo behind small packages and test on all targets.
These rules keep native interop shippable: clear ownership, reproducible builds, and escape hatches when CGO_ENABLED=0 is required.
How to Use This List
- Apply at design review before adding
import "C"to application packages. - Tick items during PR review for any diff touching
unsafe,#cgo, or vendor.sofiles. - Run CI on both
CGO_ENABLED=1andCGO_ENABLED=0when the module claims portable builds. - Tie violations to linters (
staticcheck,govulncheck) and documented exceptions in ADRs.
A - Package boundaries and APIs
- Confine
import "C"to one internal package per native dependency. Application code depends on Go types and errors only. - Expose narrow Go functions, not
C.*types, at module boundaries. Callers should not need cgo knowledge to compile. - Provide
//go:build !cgofallbacks or clear errors when cgo is mandatory. CI and cross-compiles must fail loudly, not silently misbehave. - Version-pin vendor SDKs in SBOM and README. Record
.sonames, licenses, and supported OS/arch matrix. - Avoid
initfunctions that call C. Lazy-init on first use simplifies tests and startup ordering.
B - Memory, pointers, and callbacks
- Pair every
C.CString/C.CByteswithC.freeon all paths. Usedeferimmediately after allocation succeeds. - Document ownership for pointers returned from C. Copy into Go with
C.GoString/C.GoBytesunless C retains ownership by contract. - Never let C store Go pointers past a call without pinning and reachability. Copy into C heap when lifetime extends async.
- Protect
//exportcallbacks with mutexes or channels. Assume C calls from arbitrary OS threads. - Use
runtime.LockOSThreadonly around thread-local C setup, not entire handlers. Unlock promptly to preserve scheduler throughput.
C - Performance and reliability
- Batch work to reduce cgo crossings in hot loops. Profile
runtime.cgocallbefore micro-optimizing Go code. - Cap concurrent cgo entry with semaphores sized to CPUs or library docs. Prevent unbounded OS thread growth.
- Keep C sections short; treat long C like blocking I/O. Time out at Go boundaries with contexts where possible.
- Run C code under ASan/Valgrind in native test harnesses. Go tests alone miss C buffer overflows.
- Recover panics inside
//exportfunctions. Uncaught panics across the FFI boundary abort the process.
D - Build, CI, and release
- Document
CGO_CFLAGS,CGO_LDFLAGS, and pkg-config packages for CI images. Reproducible builds beat laptop-only paths. - Test linux/arm64 and darwin builds when shipping cgo. Cross-compiling cgo needs target libraries and compilers.
- Ship container images with required libc/SDK layers documented. Scratch images need pure-Go variants or static musl plans.
- Gate merges on
go test ./...with both cgo on and off when applicable. Catch missing build tags early. - Prefer pure-Go drivers in libraries consumed by others. Downstream modules inherit your cgo toolchain requirements.
E - Strategy and alternatives
- Try
golang.org/x/sysand pure ports before new cgo. Simpler consumer experience and smaller attack surface. - Choose RPC when crash isolation or independent release beats microsecond latency. Record the decision in an ADR.
- Plan wasm paths without cgo for browser/WASI targets. Tag out
import "C"files with!wasm. - Review interop choices yearly as stdlib and community ports mature. Yesterday's cgo may be today's pure Go.
- Measure end-to-end latency before committing to in-process cgo. Integration style matters more than crossing nanoseconds.
FAQs
How small should a cgo wrapper package be?
Often one directory per vendor SDK with Go types mirroring the 10-20 calls you actually use.
Delete unused C headers from the comment block to speed builds.
Should libraries re-export cgo types?
No - keep C.* internal.
Public APIs use Go slices, strings, and structs.
What belongs in CI for cgo projects?
C toolchain metapackage, pkg-config, vendor dev headers, matrix for CGO_ENABLED values, and native tests for pointer ownership.
How do I review //export code?
Check thread safety, panic recovery, allocation frees, and whether C holds pointers after return.
Require tests that invoke callbacks from multiple threads when C allows it.
When is skipping !cgo builds acceptable?
When the module explicitly documents cgo as required and all consumers agree.
Open-source libraries should try harder to offer pure-Go paths.
How do best practices relate to the decision guide?
Practices assume you already chose in-process cgo.
Use the decision guide first when strategy is still open.
Should I vendor .a files?
Yes when upstream permits - pin hashes and document platform slices in ${SRCDIR} paths.
Verify licenses permit redistribution.
What metrics signal cgo trouble?
Rising OS thread count, runtime.cgocall in CPU profiles, RSS growth from C heap leaks, and tail latency under load.
How do I onboard Go devs to a cgo repo?
Point them to Basics and FFI safety pages; restrict first tasks to pure Go layers above the wrapper.
C changes go through reviewers who read C.
Does golangci-lint cover cgo?
Enable staticcheck and govet; C itself needs separate clang-tidy/ASan.
Pin golangci-lint version per manifest at build.
Related
- CGO: Crossing the Go-C Boundary - Mental model and trade-offs
- FFI Safety: Pointers, C.CString & Free - Ownership rules
- CGO Performance & Scheduler Interactions - Thread and latency limits
- Interop Decision Guide: CGO vs RPC vs Rewrite - Strategy before implementation
- CGO Basics - Runnable starting examples
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).