Cgo and Foreign Function Interfaces

Updated

July 30, 2026

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 with CGO_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.