The Bridge Between Worlds
You are building a Go service. It compiles fast, runs fast, and the code is clean. Then you hit a wall. You need to use a legacy encryption library written in C. Or you need to interface with a kernel driver that only exposes a C header file. Or you have a performance-critical math routine that someone else wrote in C twenty years ago, and rewriting it is not an option.
Go does not have a built-in way to import "legacy_c_lib". The Go runtime manages memory with a garbage collector. C manages memory manually. Go passes arguments based on the Go ABI. C uses the platform ABI. The two languages speak different dialects of the same machine, and they disagree on how to pack data, how to handle errors, and how to clean up after themselves.
CGO is the bridge. It is a tool built into the Go toolchain that generates wrapper code to let Go call C functions and vice versa. It compiles C code, links it with your Go binary, and generates the glue code that translates types and calls across the boundary.
CGO is powerful, but it adds complexity. It disables some Go optimizations, introduces memory management responsibilities, and can cause subtle runtime panics if you misuse pointers. Use it when you must, but understand the cost.
How CGO Generates Code
When the Go compiler sees import "C", it does not treat this like a normal package import. It triggers a special build step. The compiler extracts the comment block immediately preceding the import, treats it as C code, and invokes the C compiler (usually gcc or clang). It compiles that C code into an object file.
The compiler also generates Go wrapper functions. Every C symbol you reference gets a thin Go function that marshals Go arguments into C-compatible formats, calls the C function, and unmarshals the result back into Go types. The C. prefix in your Go code tells the compiler "this symbol comes from the C world; generate the wrapper for it."
The generated code links the C object file with your Go binary. The result is a single executable that contains both Go and C code. The Go garbage collector runs alongside the C code, but it has to be careful. C code might hold pointers to Go memory, and the GC needs to know about those pointers to avoid moving or freeing memory while C is still using it.
This coordination costs performance. Every call across the CGO boundary requires the runtime to save state, switch contexts, and check pointers. Frequent small calls add up. Batch your work or minimize the number of crossings.
Types and the C Prefix
Go types and C types are not the same. int in Go is 64 bits on a 64-bit machine. int in C is often 32 bits, even on 64-bit machines. size_t in C matches the pointer size of the platform. Go has no direct equivalent.
CGO provides a pseudo-package C that exposes C types to Go. You must use the C. prefix to reference C types. C.int is the C int. C.size_t is the C size_t. C.char is the C char. Using Go types where C types are expected can cause data truncation or misalignment.
Here's how to handle type conversions safely.
package main
/*
#include <stdint.h>
// Define a C function that takes a C int and returns a C size_t.
// This demonstrates explicit type usage.
size_t compute_size(int x) {
return (size_t)(x * 100);
}
*/
import "C"
func main() {
// C.int matches the C int type.
// Passing a Go int directly might truncate on some platforms.
cInput := C.int(42)
// Call the C function.
// The return type is C.size_t.
cResult := C.compute_size(cInput)
// Convert C.size_t to Go uint64.
// C.size_t is unsigned and matches pointer size.
// uint64 is safe for 64-bit platforms.
goResult := uint64(cResult)
// Print the result.
// fmt.Println(goResult)
}
The compiler rejects code that mixes types incorrectly. If you pass a Go int where a C.int is expected, the compiler complains with cannot use x (untyped int constant) as C.int value in argument. The error message tells you the type mismatch. Fix it by casting explicitly.
Convention aside: always use C. types for parameters and return values when crossing the boundary. Convert to Go types immediately after the call, and convert to C types immediately before the call. Keep the C types confined to the interface layer.
Strings and Memory Management
Strings are the most common source of bugs in CGO. Go strings are UTF-8 byte slices with a length. They are not null-terminated. C strings are null-terminated byte arrays. Passing a Go string directly to a C function that expects a C string will likely crash or read garbage.
CGO provides helper functions for string conversion. C.CString converts a Go string to a C string. It allocates memory on the C heap and copies the data, adding a null terminator. C.GoString converts a C string back to a Go string. It reads until the null terminator and creates a Go string.
Here's how to manage string memory correctly.
package main
/*
#include <stdio.h>
#include <string.h>
*/
import "C"
import "unsafe"
func main() {
// Go string to be passed to C.
goStr := "Hello from Go"
// C.CString allocates a C-compatible string on the C heap.
// It copies the Go string and appends a null byte.
// The caller is responsible for freeing this memory.
cStr := C.CString(goStr)
// Defer C.free to release the C heap allocation.
// C.free takes a C pointer. unsafe.Pointer converts the Go pointer.
// This prevents a memory leak.
defer C.free(unsafe.Pointer(cStr))
// Call a C function that expects a C string.
// C.printf prints the string to stdout.
C.printf(cStr)
// Convert a C string back to Go.
// C.GoString reads until the null terminator.
// It allocates a new Go string.
backToGo := C.GoString(cStr)
// Use the Go string.
// fmt.Println(backToGo)
}
Memory leaks are silent in CGO. If you forget C.free, the C heap grows. The Go profiler will not show the leak because the memory is not managed by the Go runtime. The process will eventually run out of memory. Always pair C.CString with C.free. Use defer to ensure cleanup.
Convention aside: C.CString returns a *C.char. This is a C pointer. You cannot pass it directly to C.free because C.free expects a C.pointer (which is unsafe.Pointer in Go). You must cast using unsafe.Pointer. The cast is safe because both are raw pointers.
Realistic Usage: Wrapping a Library
Real code rarely calls printf. You usually need to wrap a C library. This involves including headers, linking flags, and handling errors. C libraries often return error codes instead of raising exceptions. You need to translate those codes into Go errors.
Here's a realistic example that wraps a hypothetical C library.
package main
/*
#cgo LDFLAGS: -lmylib
#include <mylib.h>
// Wrap the C function to handle errors in Go style.
// This keeps the error handling logic in C, reducing CGO crossings.
int safe_compute(double x, double *result) {
int status = mylib_compute(x, result);
if (status != 0) {
return status;
}
return 0;
}
*/
import "C"
import (
"errors"
"fmt"
)
// Compute calls the C library and returns a Go error.
// It translates C error codes into Go errors.
func Compute(x float64) (float64, error) {
var cResult C.double
// Call the wrapper function.
// C.double matches the C double type.
status := C.safe_compute(C.double(x), &cResult)
// Check the return status.
if status != 0 {
// Return a Go error.
return 0, errors.New("mylib_compute failed")
}
// Convert C.double to Go float64.
return float64(cResult), nil
}
func main() {
result, err := Compute(3.14)
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result)
}
The #cgo LDFLAGS directive tells the linker to link against libmylib. You can also use #cgo CFLAGS to add compiler flags, like -I/path/to/headers for include paths. These directives go in the comment block before import "C".
The wrapper function safe_compute reduces the number of CGO crossings. Instead of calling the C function and then checking the error in Go, the C wrapper does both and returns a single status code. This is faster and cleaner.
Convention aside: context.Context is not used in CGO calls. C functions do not understand Go contexts. If you need cancellation, you must implement it at the Go layer, usually by running the CGO call in a goroutine and selecting on a done channel.
Pitfalls and Runtime Panics
CGO introduces runtime checks that pure Go code does not have. The Go runtime monitors pointers passed to C to prevent memory corruption. If C stores a Go pointer and the GC moves the object, the program will crash.
Pass a Go pointer to a C function that stores it, and the runtime panics with runtime error: cgo argument has Go pointer. This error means C is holding a reference to Go memory. The GC might move or free that memory while C is still using it. The solution is to copy the data to C memory before passing the pointer, or use C.CString for strings.
Goroutines and CGO interact in subtle ways. Each CGO call blocks the goroutine but keeps the underlying OS thread alive. If you spawn thousands of goroutines that all call CGO, you may exhaust OS threads. The runtime has a limit on the number of threads. Hitting the limit causes the program to hang or panic.
Batch your CGO calls. Use worker pools to limit concurrency. Avoid calling CGO in tight loops. If you need high concurrency, consider rewriting the C code in Go or using a different architecture.
Another pitfall is build portability. CGO requires a C compiler. If you build on a machine with gcc, the binary might not run on a machine with a different libc version. Cross-compilation is harder with CGO. You need to install C cross-compilers and set environment variables like CGO_ENABLED=1, CC, and CXX.
Disable CGO when you do not need it. Set CGO_ENABLED=0 to build a pure Go binary. This produces a statically linked binary that runs anywhere. Use build tags to conditionally include CGO code.
//go:build cgo
package mypkg
/*
#include <mylib.h>
*/
import "C"
// WithCGO is available only when CGO is enabled.
func WithCGO() {
C.mylib_init()
}
The //go:build cgo tag ensures the file is only compiled when CGO is enabled. This allows you to provide fallback implementations for pure Go builds.
Convention aside: gofmt handles CGO comments correctly. Run gofmt on your files to ensure the comment block is formatted properly. The Go community expects CGO code to follow standard formatting rules.
When to Use CGO
CGO is a tool, not a default. It adds build complexity, runtime overhead, and memory management risks. Choose it based on your requirements.
Use CGO when you need to call an existing C library that has no Go port and rewriting it is not feasible. Use CGO when you need to access system-level APIs exposed only via C headers, such as kernel interfaces or proprietary drivers. Use CGO when you have a performance-critical C routine that you have profiled and confirmed cannot be matched by Go code.
Reach for pure Go when you can implement the logic without external dependencies. Pure Go builds are faster, portable, and safer. The Go standard library and third-party packages cover most use cases.
Pick assembly or unsafe when you need raw performance and can avoid the CGO overhead. Inline assembly and unsafe pointers let you optimize hot paths without crossing the C boundary.
Trust the compiler. If the compiler rejects your CGO code, fix the types and pointers. Do not suppress errors with //go:nocheckptr unless you have audited the code and understand the risk.
The worst CGO bug is the one that leaks memory silently. Profile your C heap. Free every allocation. Test under load.