CGo Advanced¶
Introduction¶
CGo is Go's foreign function interface (FFI) for calling C code from Go and vice versa. It bridges the two languages through a special import "C" pseudo-package and a magic comment block containing C declarations. CGo lets you wrap existing C libraries, call system APIs not exposed by Go's standard library, and interface with legacy codebases.
But the Go proverb applies: "Cgo is not Go." Every CGo call crosses the Go/C boundary, bypassing Go's goroutine scheduler, garbage collector, and memory safety. The overhead per call is significant (~100–200ns vs ~2ns for a pure Go call), builds become more complex, and cross-compilation breaks. Use CGo only when no pure Go alternative exists.
Syntax & Usage¶
Basic CGo — Calling C Functions¶
The comment block immediately before import "C" is treated as C source code. No blank line is allowed between the comment and the import.
package main
/*
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
*/
import "C"
import "fmt"
func main() {
fmt.Println("sqrt(144) =", C.sqrt(144))
C.puts(C.CString("Hello from C"))
}
C Types in Go¶
CGo maps C types to Go through the C pseudo-package:
| C Type | Go Access | Go Equivalent |
|---|---|---|
int |
C.int |
int32 (usually) |
long |
C.long |
int64 (on 64-bit) |
char |
C.char |
byte |
float |
C.float |
float32 |
double |
C.double |
float64 |
void* |
unsafe.Pointer |
— |
char* |
*C.char |
— |
size_t |
C.size_t |
uint |
Passing Strings Between Go and C¶
C strings (char*) and Go strings (string) have different representations. You must convert explicitly and free the C string when done.
package main
/*
#include <stdlib.h>
#include <string.h>
int string_length(const char* s) {
return strlen(s);
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
goStr := "Hello, CGo!"
// Go string → C string (allocates C memory — you MUST free it)
cStr := C.CString(goStr)
defer C.free(unsafe.Pointer(cStr))
length := C.string_length(cStr)
fmt.Printf("C says length = %d\n", int(length))
// C string → Go string (copies data into Go memory)
backToGo := C.GoString(cStr)
fmt.Println("Back in Go:", backToGo)
}
String and Byte Conversion Functions¶
| Function | Direction | Allocates | Must Free? |
|---|---|---|---|
C.CString(string) |
Go → C | C heap | Yes (C.free) |
C.CBytes([]byte) |
Go → C | C heap | Yes (C.free) |
C.GoString(*C.char) |
C → Go | Go heap | No (GC handles it) |
C.GoStringN(*C.char, C.int) |
C → Go (with length) | Go heap | No |
C.GoBytes(unsafe.Pointer, C.int) |
C → Go | Go heap | No |
Memory Management¶
C memory is invisible to Go's garbage collector. You must manage it manually.
package main
/*
#include <stdlib.h>
#include <string.h>
typedef struct {
char* name;
int age;
} Person;
Person* new_person(const char* name, int age) {
Person* p = (Person*)malloc(sizeof(Person));
p->name = strdup(name);
p->age = age;
return p;
}
void free_person(Person* p) {
free(p->name);
free(p);
}
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
name := C.CString("Alice")
defer C.free(unsafe.Pointer(name))
p := C.new_person(name, 30)
defer C.free_person(p) // C allocated — C must free
fmt.Printf("Name: %s, Age: %d\n", C.GoString(p.name), int(p.age))
}
Callbacks — Calling Go from C¶
Use //export to make a Go function callable from C. The C code must declare it as extern.
package main
/*
extern int goCallback(int a, int b);
static int callGoFromC(int x, int y) {
return goCallback(x, y);
}
*/
import "C"
import "fmt"
//export goCallback
func goCallback(a, b C.int) C.int {
return a + b
}
func main() {
result := C.callGoFromC(10, 20)
fmt.Println("C called Go, result:", int(result))
}
Export restrictions
//exportfunctions cannot be defined in the same file as the C preamble that uses them. Split into separate files or useexterndeclarations.- Exported Go functions must not accept or return Go pointers to C code (the pointer passing rules forbid it unless the memory is pinned).
Wrapping a C Library¶
A production pattern for wrapping an existing C library:
package sqlite
/*
#cgo LDFLAGS: -lsqlite3
#include <sqlite3.h>
#include <stdlib.h>
*/
import "C"
import (
"errors"
"unsafe"
)
type DB struct {
handle *C.sqlite3
}
func Open(path string) (*DB, error) {
cPath := C.CString(path)
defer C.free(unsafe.Pointer(cPath))
var db *C.sqlite3
rc := C.sqlite3_open(cPath, &db)
if rc != C.SQLITE_OK {
errMsg := C.GoString(C.sqlite3_errmsg(db))
C.sqlite3_close(db)
return nil, errors.New(errMsg)
}
return &DB{handle: db}, nil
}
func (db *DB) Close() error {
if rc := C.sqlite3_close(db.handle); rc != C.SQLITE_OK {
return errors.New(C.GoString(C.sqlite3_errmsg(db.handle)))
}
db.handle = nil
return nil
}
CGo Build Directives¶
Use #cgo directives in the preamble to set compiler and linker flags:
/*
#cgo CFLAGS: -I/usr/local/include -DDEBUG
#cgo LDFLAGS: -L/usr/local/lib -lmylib
#cgo linux LDFLAGS: -lrt
#cgo darwin CFLAGS: -DMACOS
#cgo pkg-config: libpng
*/
import "C"
| Directive | Purpose |
|---|---|
#cgo CFLAGS: |
C compiler flags (includes, defines) |
#cgo LDFLAGS: |
Linker flags (libraries, library paths) |
#cgo pkg-config: |
Use pkg-config for flags |
#cgo GOOS CFLAGS: |
Platform-specific flags |
Static Builds with CGO_ENABLED=0¶
Disabling CGo produces fully static binaries — critical for minimal Docker images and cross-compilation:
# Static binary with no C dependencies
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o myapp .
# Note: packages like net use CGo by default for DNS resolution
# CGO_ENABLED=0 forces the pure Go DNS resolver
Quick Reference¶
| Concept | Syntax / Command |
|---|---|
| Enable CGo | import "C" (with C preamble comment) |
| Go string → C | cStr := C.CString(s) — must free |
| C string → Go | goStr := C.GoString(cStr) |
| Free C memory | C.free(unsafe.Pointer(ptr)) |
| Allocate C memory | C.malloc(C.size_t(n)) |
| Export Go func to C | //export FuncName (no space) |
| Compiler flags | #cgo CFLAGS: -I/path |
| Linker flags | #cgo LDFLAGS: -lmylib |
| Disable CGo | CGO_ENABLED=0 go build |
| CGo overhead | ~100–200ns per call |
Best Practices¶
- Minimize boundary crossings — batch work on the C side rather than calling C functions in a tight loop. Each Go→C transition has ~100–200ns overhead.
- Always free C-allocated memory — use
defer C.free(unsafe.Pointer(ptr))immediately afterC.CString,C.CBytes, or any C allocation. - Wrap C APIs with idiomatic Go interfaces — expose Go-style APIs (error returns, named types, methods) and hide all CGo details in an internal package.
- Pin C interactions to OS threads when needed — use
runtime.LockOSThread()for libraries that require thread affinity (OpenGL, some database drivers). - Prefer pure Go alternatives — before reaching for CGo, check if a pure Go library exists.
modernc.org/sqliteis a CGo-free SQLite, for example. - Keep CGo code in dedicated packages — isolate CGo usage so the rest of your codebase compiles with
CGO_ENABLED=0.
Common Pitfalls¶
Forgetting to free C.CString
func leaky(name string) {
cName := C.CString(name)
C.some_function(cName)
// BUG: cName is never freed — memory leak every call
}
func correct(name string) {
cName := C.CString(name)
defer C.free(unsafe.Pointer(cName))
C.some_function(cName)
}
C.CString allocates on the C heap. The Go garbage collector will never reclaim it. This is the single most common CGo bug.
Passing Go pointers to C that persist
// BUG: Go's GC can move or collect this memory
var data []byte = makeData()
C.store_pointer(unsafe.Pointer(&data[0])) // C holds a Go pointer
// The GC may relocate data, leaving C with a dangling pointer
C.CBytes to copy data to the C heap if C needs to retain it.
Blank line between preamble and import
The C preamble comment must be immediately followed byimport "C" with no intervening blank line.
Cross-compilation breaks with CGo
CGo requires a C compiler for the target platform. GOOS=linux GOARCH=arm64 go build will fail unless you have a cross-compiler installed. For portable builds, disable CGo or use Docker with the target toolchain.
Performance Considerations¶
- Call overhead: Each Go→C call costs ~100–200ns due to stack switching, scheduler coordination, and signal masking. A pure Go function call is ~2ns. This is a 50–100x difference.
- Goroutine scheduler impact: During a C call, the goroutine is pinned to an OS thread and invisible to Go's scheduler. Long-running C calls can starve other goroutines —
runtime.LockOSThread()might be needed. - No escape analysis across boundary: The compiler cannot optimize allocations across the CGo boundary. Data passed to C is always heap-allocated from Go's perspective.
- Build time: CGo packages are significantly slower to compile because they invoke the C compiler. Isolating CGo in small packages limits the blast radius.
- Binary size: CGo pulls in the C runtime and any linked libraries, bloating the binary compared to a pure Go build.
- Batching: If you need to call a C function 10,000 times, write a C wrapper that does the loop and call it once from Go. This amortizes the boundary crossing cost.
Interview Tips¶
Interview Tip
"When would you use CGo?" Only when wrapping an existing C library with no pure Go alternative — think FFmpeg, OpenGL, or a proprietary vendor SDK. CGo adds build complexity, breaks cross-compilation, has significant per-call overhead, and complicates memory management. Always check for pure Go alternatives first.
Interview Tip
"What's the performance impact of CGo?" Each Go↔C call costs ~100–200ns due to stack switching and scheduler coordination (vs ~2ns for pure Go). The goroutine is pinned to an OS thread during the C call, reducing scheduler flexibility. You can't inline across the boundary, and escape analysis stops at the CGo border.
Interview Tip
"How do you handle memory management in CGo?" Two memory worlds: Go (GC-managed) and C (manual). C.CString allocates on the C heap — you must free it with C.free. C.GoString copies into Go memory — GC handles it. The critical rule: Go pointers passed to C must not be stored by C beyond the function call's return.
Interview Tip
"What does CGO_ENABLED=0 do?" It disables CGo entirely, forcing Go to use pure Go implementations for everything (including the net package's DNS resolver). This produces fully static binaries — essential for scratch/distroless Docker images and reliable cross-compilation.
Key Takeaways¶
- CGo bridges Go and C via
import "C"and a C preamble comment block — no blank line between them. C.CStringallocates on the C heap and must be freed withC.free— Go's GC will not reclaim it.- Each Go→C call costs ~100–200ns — minimize boundary crossings by batching work on the C side.
- Go pointer passing rules forbid C from retaining Go pointers beyond the call's return.
CGO_ENABLED=0produces static binaries and is required for cross-compilation without a C cross-compiler.- Always prefer pure Go alternatives over CGo — CGo adds build complexity, hinders cross-compilation, and complicates debugging.
- Isolate CGo in dedicated packages so the rest of your codebase stays pure Go.