Cgo and Foreign Function Interfaces
Cgo and Foreign Function Interfaces
The boring default is pure Go. Write the function in Go. Ship with CGO_ENABLED=0. A foreign function interface (calling C from Go, or Go from C) is a last resort for a library that does not exist in Go and cannot be rewritten in a weekend.
Mental model
cgo is the toolchain’s bridge: a file that import "C" can contain a C snippet (or include a header) and call those functions as C.name(...). The Go compiler invokes a C compiler. The binary typically links libc. Cross-compilation stops being two environment variables. go test gets slower. The resulting program is no longer a single obvious file you copy between Linux boxes.
An FFI is that bridge in the abstract. cgo is Go’s built-in one. Other languages have ctypes, JNI, and so on. The cost is the same: two runtimes, two memory models, two failure modes.
If the C function is three lines, it is a Go function. That is Case 1.
Worked examples
Case 1: The pure-Go function you should ship
Save as bump.go. A table number goes up by one. No C.
// bump.go
package main
import "fmt"
func bump(n int) int {
return n + 1
}
func main() {
fmt.Println(bump(3))
}Run:
go run bump.goOutput:
4
Build the boring binary:
CGO_ENABLED=0 go build -o bump bump.go
./bumpOutput:
4
Stop here unless you have a reason you can write in a commit message.
Case 2: The same increment, optionally in C
This program needs a C compiler (gcc or clang) and CGO_ENABLED=1 (the default on most developer machines). If go run complains that cgo is disabled or CC is missing, skip this case. The point of the chapter is Case 1.
The C comment must sit immediately above import "C". Do not put import "C" inside a grouped import.
Save as bump_c.go:
// bump_c.go
package main
/*
int bump(int n) {
return n + 1;
}
*/
import "C"
import "fmt"
func main() {
fmt.Println(int(C.bump(3)))
}Run:
CGO_ENABLED=1 go run bump_c.goOutput:
4
C.bump returns a C int. Convert it with int(...) before you print if you want a Go int. Types that look the same (int, C.int) are not the same type.
You cannot CGO_ENABLED=0 go run bump_c.go. That command fails. That failure is the tax you pay for Case 2 on every machine and every CI image.
Case 3: Cross-compile as the reason to stay in Go
Save Case 1 as bump.go again. Cross-compile without a C toolchain for the target:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bump-arm64 bump.goNo output on success. You have a Linux arm64 binary. Repeat for windows/amd64 if you need it.
Now imagine Case 2. You would need a C cross-compiler, headers, and a libc for each target. Teams that “just wrap OpenSSL” discover this on the first Windows build.
Case 4: C strings and manual memory management
When passing strings or byte buffers to C, C.CString(s) allocates a null-terminated copy on the C heap using malloc(). The Go garbage collector does not know this memory exists. You must explicitly free it with C.free().
Save as c_strings.go:
// c_strings.go
package main
/*
#include <stdlib.h>
#include <string.h>
int c_length(const char* s) {
return strlen(s);
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
// Allocate on C heap: Go GC will NEVER free this automatically
cs := C.CString("desk ticket #101")
defer C.free(unsafe.Pointer(cs)) // Must be freed explicitly
len := int(C.c_length(cs))
fmt.Println("string length from C:", len)
}Run:
CGO_ENABLED=1 go run c_strings.goOutput:
string length from C: 16
If you omit defer C.free(unsafe.Pointer(cs)), every call leaks bytes into the process memory space that Go runtime GC cycles will never reclaim. That is the manual memory burden cgo introduces to your codebase.
The trap
Pulling in a C library on day one of a desk CLI because “the C version is the real one” is how a 40-line tool becomes a docker image.
This Go program already does the job. Wrapping C printf would not make it more correct:
// open.go
package main
import "fmt"
func main() {
fmt.Println("opened table 3")
}Run:
go run open.goOutput:
opened table 3
If you need TLS, use crypto/tls. If you need SQLite, prefer a pure-Go driver until you measure a reason not to. If you must call C, isolate import "C" in one package, test it hard, and keep it off the public API of the desk.
cgo also means:
- Stack switches: Go goroutines have small growing stacks (starting at 2–4 KB); C functions require fixed POSIX stacks. Crossing the boundary forces stack switches and carries significant call overhead.
- Crash propagation: A segfault in C immediately crashes the entire Go process — it cannot be caught by Go’s
recover(). - Leaked memory: Memory allocated with C
mallocis invisible to Go GC and must be manually tracked and freed.
None of that is worth an increment function.
The boring rule
- Write it in Go first. Case 1 is the product.
- Use cgo only for a dependency that does not exist in Go and will not be rewritten.
- When passing Go strings to C with
C.CString, immediately pair it withdefer C.free(unsafe.Pointer(cs)). - Keep
import "C"in one file. Convert C types at the boundary. - Default
CGO_ENABLED=0in release builds so cgo cannot sneak in through a dependency. - Do not use cgo to “go faster.” Measure. The boundary switch overhead often wipes out C speed advantages.
Try this
- Time
go buildonbump.gowithCGO_ENABLED=0and, if you ran Case 2, onbump_c.gowith cgo on. The difference is the C compiler. - In
c_strings.go, verify what happens if you remove#include <stdlib.h>. Read the compiler error aboutC.free. - Run
CGO_ENABLED=0 go run bump_c.goand read the error. Keep that error in mind when CI is a tiny image. - Change
bumpin Case 1 to add 10 instead of 1. Notice you did not edit C, installgcc, or worry aboutC.intoverflow. - If you must practise cgo, add a C function
int tables(void) { return 12; }and printC.tables(). Then delete the file and go back to Case 1.