Cgo and Foreign Function Interfaces
Overview
Cgo enables calling C code from Go. It’s useful for interfacing with system libraries but adds complexity.
Basic Cgo
package main
/*
#include <stdio.h>
void hello() {
printf("Hello from C!\n");
}
*/
import "C"
func main() {
C.hello()
}Passing Data
/*
int add(int a, int b) {
return a + b;
}
*/
import "C"
func main() {
result := C.add(C.int(2), C.int(3))
fmt.Println(int(result)) // 5
}Strings
/*
#include <stdlib.h>
#include <string.h>
char* greet(const char* name) {
char* buf = malloc(100);
sprintf(buf, "Hello, %s!", name);
return buf;
}
*/
import "C"
import "unsafe"
func main() {
name := C.CString("World")
defer C.free(unsafe.Pointer(name))
result := C.greet(name)
defer C.free(unsafe.Pointer(result))
fmt.Println(C.GoString(result))
}Linking Libraries
// #cgo LDFLAGS: -lssl -lcrypto
// #include <openssl/sha.h>
import "C"Downsides
- No cross-compilation without toolchain
- CGO_ENABLED=1 required
- Performance overhead at boundaries
- Complicates deployment
When to Use
✅ Use for: - System libraries (OpenSSL, SQLite) - Hardware interfaces - Legacy code integration
❌ Avoid for: - Simple functionality - When pure Go libraries exist - Cross-platform tools
Summary
| Task | Approach |
|---|---|
| Call C | Import pseudo-package “C” |
| Pass string | C.CString() / C.GoString() |
| Free memory | C.free(unsafe.Pointer(...)) |
| Link library | #cgo LDFLAGS: -lname |
Worked example
Pure-Go FFI boundary stand-in: keep a thin wrapper package around “foreign” logic.
Save as main.go. Then:
go mod init example
go run .package main
import "fmt"
// Imagine this lives behind cgo; callers only see Go types.
type Lib struct{}
func (Lib) Add(a, b int) int { return a + b }
func (Lib) Version() string { return "pure-go-shim/1.0" }
func main() {
var lib Lib
fmt.Println("version:", lib.Version())
fmt.Println("add:", lib.Add(20, 22))
}Expected output:
version: pure-go-shim/1.0
add: 42
More examples
Document why you would enable cgo (feature gate).
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Println("GOOS:", runtime.GOOS)
fmt.Println("cgo available at compile time only; this binary is pure Go")
}Expected output: (GOOS varies)
GOOS: darwin
cgo available at compile time only; this binary is pure Go
Runnable example
Default path is pure Go so
go run .works withCGO_ENABLED=0. The optional block below is a separate cgo program (needs a C toolchain).
Save as main.go. Then:
go mod init example
go run .package main
import "fmt"
// Pure-Go stand-in for a tiny C helper.
func add(a, b int) int { return a + b }
func greet(name string) string {
return "Hello, " + name + "!"
}
func main() {
fmt.Println("add:", add(2, 3))
fmt.Println(greet("World"))
fmt.Println("pure Go: no cgo required")
}Expected output:
add: 5
Hello, World!
pure Go: no cgo required
Optional cgo twin (only if you have a C compiler; do not mix into the pure-Go file):
package main
/*
#include <stdlib.h>
int add(int a, int b) { return a + b; }
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
sum := int(C.add(2, 3))
fmt.Println("add:", sum)
cname := C.CString("World")
defer C.free(unsafe.Pointer(cname))
fmt.Println("c string bytes:", C.GoString(cname))
}CGO_ENABLED=1 go run .What to notice: Crossing the C boundary has call overhead, complicates cross-compilation, and forces library/toolchain concerns. Prefer pure Go unless a C library is the product.
Try next: Compare CGO_ENABLED=0 go build vs CGO_ENABLED=1 go build binary sizes on your machine.