Introduction
CGO lets Go programs call C functions and use C libraries. This is essential when you need to use a C library with no Go equivalent (SQLite, OpenSSL, system APIs). However, CGO has significant overhead per call due to stack switching and scheduler coordination. Go 1.26 reduced this overhead by ~30%, but it remains significant for fine-grained calls. Understanding this cost helps you decide when CGO is worthwhile.
Key Concepts
- CGO: Go's foreign function interface for calling C code from Go.
import "C": A pseudo-package that enables CGO. C declarations in the comment immediately above this import become accessible in Go.- CGO call overhead: Each CGO call has overhead from stack switching, signal handling, and scheduler coordination. Go 1.26 reduced this overhead by ~30% compared to earlier versions.
//go:nosplit: A compiler directive that prevents stack growth checks — used in performance-critical code but dangerous if the function uses too much stack.
Real World Context
SQLite, libsodium, and many image/video processing libraries are C-only. CGO lets Go programs use these battle-tested libraries. However, calling a C function that takes 5 ns to execute incurs tens of nanoseconds in overhead — a significant penalty. For such cases, batching calls or using Go-native alternatives is better.
Deep Dive
Basic CGO Usage
gopackage main /* #include <math.h> */ import "C" import "fmt" func main() { result := C.sqrt(C.double(144.0)) fmt.Println(float64(result)) // 12.0 }
The comment block before import "C" is the "preamble" — it can contain #include, #define, and even inline C functions.
CGO Call Overhead Breakdown
Each CGO call involves:
- Switching from Go's segmented stack to a C-compatible stack
- Saving/restoring signal handlers
- Notifying the Go scheduler that the goroutine is in a syscall
- Performing type conversions and argument marshaling
Go 1.26 reduced this baseline overhead by ~30% through optimized stack switching. The total overhead is typically tens of nanoseconds per call — far more than a Go function call (~2 ns) but far less than a process context switch.
Batching to Amortize Overhead
Instead of calling C once per item, pass a batch:
go/* // Process an array in C — one call instead of N void process_batch(double* data, int len) { for (int i = 0; i < len; i++) { data[i] = sqrt(data[i]); } } */ import "C" func processBatch(data []float64) { C.process_batch((*C.double)(unsafe.Pointer(&data[0])), C.int(len(data))) }
One CGO call for 1000 items incurs the overhead once, vs 1000× the overhead for individual calls.
CGO Build Requirements
CGO requires a C compiler. Common gotchas:
bash# Disable CGO for cross-compilation or minimal builds CGO_ENABLED=0 go build . # CGO is enabled by default when a C compiler is available CGO_ENABLED=1 go build .
Common Pitfalls
- Calling C in a tight loop — The per-call overhead dominates for trivial C functions. Batch operations or move the loop to C.
- Passing Go pointers to C — Go's GC may move objects. Use
C.mallocfor C-owned memory, or ensure Go pointers follow the CGO pointer-passing rules. - Memory management confusion — C allocations (
C.malloc) must be freed withC.free. Go's GC will not collect them.
Best Practices
- Batch CGO calls — Pass arrays/slices to C instead of calling per-element. Amortize the per-call overhead across many operations.
- Consider pure Go alternatives — If a Go-native library exists (e.g.,
modernc.org/sqlitefor SQLite), it avoids CGO overhead entirely.
Summary
- CGO enables calling C functions from Go via the
import "C"pseudo-package. - Each CGO call has significant overhead (tens of nanoseconds). Go 1.26 reduced it by ~30%.
- Batch operations to amortize overhead — one call for N items, not N calls.
- C memory (
C.malloc) must be manually freed withC.free. - Consider pure Go alternatives to avoid CGO overhead entirely.
Code Examples
package main
/*
#include <stdlib.h>
#include <string.h>
// C function: uppercase a string in-place
void to_upper(char* s, int len) {
for (int i = 0; i < len; i++) {
if (s[i] >= 'a' && s[i] <= 'z') {
s[i] -= 32;
}
}
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func toUpper(s string) string {
// Allocate C memory, copy string, process, copy back
cs := C.CString(s) // Go string → C string (copies)
defer C.free(unsafe.Pointer(cs)) // Must free C memory!
C.to_upper(cs, C.int(len(s)))
return C.GoString(cs) // C string → Go string (copies)
}
func main() {
fmt.Println(toUpper("hello")) // HELLO
}