Skip to content

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

  • //export functions cannot be defined in the same file as the C preamble that uses them. Split into separate files or use extern declarations.
  • 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

  1. 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.
  2. Always free C-allocated memory — use defer C.free(unsafe.Pointer(ptr)) immediately after C.CString, C.CBytes, or any C allocation.
  3. 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.
  4. Pin C interactions to OS threads when needed — use runtime.LockOSThread() for libraries that require thread affinity (OpenGL, some database drivers).
  5. Prefer pure Go alternatives — before reaching for CGo, check if a pure Go library exists. modernc.org/sqlite is a CGo-free SQLite, for example.
  6. 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
Go's pointer passing rules forbid C from storing Go pointers beyond the call's lifetime. Use C.CBytes to copy data to the C heap if C needs to retain it.

Blank line between preamble and import

/*
#include <stdio.h>
*/

import "C"  // BUG: blank line — preamble is ignored, compilation fails
The C preamble comment must be immediately followed by import "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.CString allocates on the C heap and must be freed with C.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=0 produces 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.