cgo and C Interoperability

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Go 1.27 (go1.27.2), checked on 2026-10-18; every go.mod shown has the line go 1.27. cgo is the part of the Go toolchain that lets a Go package call C code and lets C code call back into Go. It is how Go programs use operating-system libraries that only have a C interface (graphics, audio, hardware, SQLite, libgit2, the macOS and Windows platform frameworks), and how a Go package is shipped as a C library for programs written in other languages. It is also the most expensive and least Go-like feature of the language: it changes how a program is built, how it is cross-compiled, what its binary links against, how fast a call is, and what the garbage collector and the scheduler are allowed to do. This page explains how it works, the pointer-passing rules that keep the garbage collector safe, the cost of crossing the boundary, the build consequences and the alternatives.

Every example was compiled, vetted with go vet and run with Go 1.27.2 on an Apple-silicon Mac (macOS, arm64, Apple clang as the C compiler); the output shown is the real output, and the compiler, vet and runtime messages are copied from real runs. Addresses, byte counts, timings and file sizes are machine-specific. Examples that need a Linux toolchain, a library that is not installed here or a second machine are labelled as not run. The go.mod of the example module is:

module example.com/shelf

go 1.27

When cgo is the right tool

cgo is a tool for integration: reuse a C library that already exists, talk to a platform API that has no Go binding, or publish Go code to C programs. It is not a tool for speed. The Go toolchain deliberately does not treat it as ordinary Go, and the saying that "cgo is not Go" summarizes the trade (the book Learning Go by Jon Bodner reaches the same conclusion in its chapter on unsafe, reflect and cgo: use cgo to integrate with existing C libraries, not to make Go code faster).

What the price is:

  • The build is no longer just go build. A C compiler must be installed and must match the target. Cross-compiling stops being a matter of two environment variables (see Building).

  • Every call crosses a boundary. A cgo call costs far more than a Go call, cannot be inlined, and ties up an operating-system thread while it runs (see The cost of a call).

  • The garbage collector cannot see through the boundary. Go memory handed to C is subject to the pointer-passing rules, and C memory is never collected (see The pointer-passing rules).

  • Many Go tools see less. The race detector does not instrument C code, escape analysis cannot look inside a C function (go build -gcflags=-m can say nothing about it), profiles show C time as one opaque frame, and go test cannot use import "C" in test files.

  • The failure modes are those of C. A buffer overflow in C code, a use after free or a data race in a library is a crash or silent corruption of the whole process, not a Go panic that recover can handle.

The alternatives, in the order in which they are usually worth trying:

Alternative Use it when Consequence

A pure Go implementation or a Go port of the library

One exists, is maintained and is fast enough; the library is small (checksums, compression, parsers)

Static, cross-compilable builds; the usual tools work. Often the best answer

Run the program: os/exec

The tool is a command-line program (git, ffmpeg, pandoc) and a process boundary is acceptable

Isolation and a simple build; process start-up cost and text or binary protocols

A network protocol: gRPC, HTTP, a Unix socket

The code lives in another service or sidecar

Language-independent and independently deployable; latency, serialization and operations

WebAssembly (GOOS=wasip1, or a Wasm runtime library in Go)

Running untrusted or portable code with a sandbox

See WebAssembly and WASI

syscall / golang.org/x/sys

The "C API" is just the operating-system call interface

No C compiler; platform-specific files with build constraints

cgo

The only practical way to use a native library or to expose Go to C

Everything on the previous list of prices

Hello, C: import "C" and the preamble

A Go file uses cgo when it imports the pseudo-package "C". The comment immediately above the import is called the preamble: it is C code that cgo compiles and makes visible to the Go file, with every name declared in it available as C.name. The shelf library below validates ISBN-10 check digits in C (the same logic that the Benchmarks, Fuzzing and synctest page fuzzes in Go) and calls it from a Go wrapper.

package main

/*
#include <stdlib.h>
#include <string.h>

// isbn10_valid reports whether s is a well-formed ISBN-10 with a correct check digit.
static int isbn10_valid(const char *s) {
	if (strlen(s) != 10) {
		return 0;
	}
	int sum = 0;
	for (int i = 0; i < 10; i++) {
		int d;
		if (s[i] >= '0' && s[i] <= '9') {
			d = s[i] - '0';
		} else if (i == 9 && s[i] == 'X') {
			d = 10;
		} else {
			return 0;
		}
		sum += d * (10 - i);
	}
	return sum % 11 == 0;
}
*/
import "C"

import (
	"fmt"
	"unsafe"
)

// ValidISBN10 asks the C implementation whether s is a valid ISBN-10.
func ValidISBN10(s string) bool {
	cs := C.CString(s) // malloc'ed copy with a trailing NUL
	defer C.free(unsafe.Pointer(cs))
	return C.isbn10_valid(cs) != 0
}

func main() {
	for _, s := range []string{"0306406152", "080442957X", "030640615X", "12345"} {
		fmt.Printf("%-12s %v\n", s, ValidISBN10(s))
	}
	fmt.Printf("%T %T\n", C.int(0), C.size_t(0))
}

Output (the types in the last line show that C.int is a distinct Go type, not int, and that C.size_t is the platform’s unsigned long):

0306406152   true
080442957X   true
030640615X   false
12345        false
main._Ctype_int main._Ctype_ulong

The rules that this small program already follows:

  • The preamble must touch the import. Only a comment that directly precedes import "C", with no blank line between them, is the preamble. Both the /* ... */ form and a run of // lines work. With a blank line the comment is a plain comment and the C names are unknown.

  • The import of "C" stands alone. It is conventionally not grouped with other imports (a grouped import is allowed but then the comment must precede the whole import ( line, which is hard to read).

  • Static functions are available, static variables are not. The preamble may contain any C, including definitions of functions, so a static function can be used from Go; a static variable cannot be referenced from Go (declare the variable non-static or wrap it in an accessor function).

  • C names keep their C spelling. C.isbn10_valid is the C function; names starting with a lower-case letter are fine. The prefixes struct_, union_, enum_ and sizeof_ are reserved for the forms described below.

  • The C memory returned by C.CString is yours to free. C.CString allocates with malloc; the matching C.free needs <stdlib.h> in the preamble and an unsafe.Pointer conversion.

Forgetting the first rule is a common beginner mistake, and the error is not obvious. This file separates the comment from the import with a blank line:

package main

// #include <stdlib.h>

import "C"

func main() {
	_ = C.abs(-3)
}

The real error from go build:

# example.com/shelf
./main.go:8:6: could not determine what C.abs refers to

Source: cmd/cgo.

What the go command does with it

go build notices that a package imports "C" and runs an extra stage before the Go compiler: the cgo tool reads the Go files, asks the C compiler what every C.name means (types, sizes, constants), and writes Go and C glue code. Then the C compiler builds the glue and the package’s own C files, and the linker links the lot. The generated files can be inspected with go tool cgo -objdir <dir> main.go (here for the callback example used later on this page):

_cgo_export.c   _cgo_export.h   _cgo_gotypes.go   _cgo_main.c
main.cgo1.go    main.cgo2.c

_cgo_gotypes.go defines the Go types for the C types the file uses (type _Ctype_int int32, type _Ctype_ulong uint64, and so on) and a Go wrapper for every C function, which is where the cost of a call comes from. The wrapper for C.scan_loans in that example is short:

//go:cgo_unsafe_args
func _Cfunc_scan_loans(p0 _Ctype_int, p1 *[0]byte, p2 _Ctype_uintptr_t) (r1 _Ctype_void) {
	_cgo_runtime_cgocall(_cgo_a36c3a492804_Cfunc_scan_loans, uintptr(unsafe.Pointer(&p0)))
	if _Cgo_always_false {
		_Cgo_use(p0)
		_Cgo_use(p1)
		_Cgo_use(p2)
	}
	return
}

(The hash in the name is generated and differs between builds.) main.cgo1.go is the user’s file with every C.name rewritten to the corresponding _Cfunc_ or _Ctype_ name, and main.cgo2.c is the C side of the wrappers. This is generated code for understanding only, and it must not be edited or committed: the go command regenerates it on every build.

go build -x shows the same stage as it runs: one cgo invocation, several clang (or gcc) invocations with -fPIC and the target -arch, and the link step. The C compiler is taken from the CC environment variable (go env CC, clang on macOS and gcc on most Linux systems), the C++ compiler from CXX.

The #cgo directives

Lines of the form #cgo ... in the preamble configure the C toolchain for that package. They are not C: cgo removes them before the preamble reaches the compiler.

// #cgo CFLAGS: -DLOAN_LIMIT=5 -I${SRCDIR}/include
// #cgo linux LDFLAGS: -lm
// #cgo darwin LDFLAGS: -framework CoreFoundation
// #cgo pkg-config: sqlite3
// #include <sqlite3.h>
import "C"

(Shown for syntax; not compiled here because it needs sqlite3 and pkg-config.)

Directive Meaning

#cgo CFLAGS: ..., CPPFLAGS, CXXFLAGS, FFLAGS

Flags for compiling the package’s C, C++ or Fortran. Preprocessor flags (-D, -I) go in CPPFLAGS or CFLAGS

#cgo LDFLAGS: ...

Linker flags (-lm, -L, -framework). Concatenated across every package in the program

#cgo pkg-config: pkg ...

Ask the pkg-config tool (the PKG_CONFIG variable chooses another) for flags

#cgo linux amd64 CFLAGS: ...

A directive prefixed with build constraints (operating system, architecture, tags) applies only there

${SRCDIR}

Replaced by the absolute path of the directory with the source file, so the package can link a library it ships

#cgo noescape f and #cgo nocallback f

Promises about a C function that make calls cheaper (Go 1.24, see noescape and nocallback)

For security, a #cgo line may only use a safe subset of flags (-D, -U, -I, -l and a few others) from the source: an unfamiliar flag fails the build. The CGO_CFLAGS_ALLOW and CGO_CFLAGS_DISALLOW environment variables (and their CPPFLAGS, CXXFLAGS, FFLAGS and LDFLAGS counterparts) hold regular expressions that widen or narrow the set, and the CGO_CFLAGS, CGO_LDFLAGS (and similar) variables add flags from the environment, which are not restricted. Package-specific flags belong in the directive, so that the package builds in an unmodified environment; the environment variables are for the person doing the build.

C and C++ files in the package

When a package contains a Go file that imports "C", the go command also builds the other languages' files in the same directory: .c, .s, .S and .sx with the C compiler, .cc, .cpp and .cxx with the C++ compiler, .f, .F, .for and .f90 with the Fortran compiler. Headers (.h, .hh, .hpp, .hxx) are not compiled by themselves but changing one rebuilds the package. All of these must sit in the package directory itself (not a subdirectory), because a change elsewhere does not trigger a rebuild.

Putting the C code in its own files, with a header the Go file includes, keeps the preamble short and is the usual structure for anything beyond a few lines. The scanner below lives in scan.c; the header declares it:

#ifndef SCAN_H
#define SCAN_H

#include <stdint.h>

typedef void (*visit_fn)(uintptr_t handle, int book_id);

// scan_loans calls fn once for each of the n overdue book ids, passing handle back unchanged.
void scan_loans(int n, visit_fn fn, uintptr_t handle);

#endif
#include "scan.h"

void scan_loans(int n, visit_fn fn, uintptr_t handle) {
	for (int i = 0; i < n; i++) {
		fn(handle, 100 + i);
	}
}

C++

cgo speaks C. To use C++, put the C++ in a .cpp file in the package and expose it through a C interface: extern "C" functions with C types only. The header is shared by both languages through an #ifdef __cplusplus guard. Here a C++ file sorts ids with std::sort:

#ifndef SORTER_H
#define SORTER_H

#ifdef __cplusplus
extern "C" {
#endif

// sort_ids sorts n ids in place with std::sort.
void sort_ids(int *ids, int n);

#ifdef __cplusplus
}
#endif

#endif
#include "sorter.h"

#include <algorithm>

extern "C" void sort_ids(int *ids, int n) {
	std::sort(ids, ids + n);
}
package main

// #cgo CXXFLAGS: -std=c++17
// #cgo linux LDFLAGS: -lstdc++
// #include "sorter.h"
import "C"

import "fmt"

func main() {
	ids := []C.int{42, 7, 19, 3}
	C.sort_ids(&ids[0], C.int(len(ids)))
	fmt.Println(ids)
}

Output:

[3 7 19 42]

The CXXFLAGS directive picks the language standard and the linux LDFLAGS line links the C++ standard library (the Linux line was not exercised on this Mac; on macOS the go tool links libc++ itself, and an explicit -lc++ only produces a duplicate-library linker warning). A Go package that mixes C++ exceptions with Go panics needs care: an exception must not unwind through Go frames, so catch every exception inside the extern "C" wrapper and return an error code.

Source: cmd/cgo.

Types and conversions

The type system on the Go side of the boundary is explicit: every C type is a distinct, unexported Go type, and conversions are written out. The mapping, from the cmd/cgo documentation:

C Go name in cgo Underlying Go type here

char, signed char, unsigned char

C.char, C.schar, C.uchar

int8, int8, uint8 (C.char is always int8)

short, unsigned short, int, unsigned int

C.short, C.ushort, C.int, C.uint

int16, uint16, int32, uint32

long, unsigned long, long long, unsigned long long

C.long, C.ulong, C.longlong, C.ulonglong

int64 and uint64 on LP64 platforms (macOS and Linux), int32 and uint32 for long on Windows

float, double

C.float, C.double

float32, float64

float _Complex, double _Complex

C.complexfloat, C.complexdouble

complex64, complex128

size_t, intN_t, uintN_t, uintptr_t

C.size_t, C.int32_t, C.uintptr_t and so on

Whatever the platform defines them as (on this Mac C.size_t and C.uintptr_t are uint64, and C.int32_t is the same type as C.int)

void *

unsafe.Pointer

unsafe.Pointer

struct s, union u, enum e

C.struct_s, C.union_u, C.enum_e

A Go struct, a byte array of the union’s size, an unsigned integer type (uint32 in the run below)

sizeof(T)

C.sizeof_T

A constant holding the size of the type, computed by the C compiler

#define N 3, an enum constant

C.N

A Go constant (only when the macro is a simple constant expression)

Pointer to function

*[0]byte (and a named type such as C.visit_fn)

Can be stored and passed back to C, but not called from Go

Because C.int is not int, arithmetic needs conversions: int(C.strlen(cs)), C.size_t(len(buf)). The compiler reports a mismatch as an ordinary type error. The sizes of the C types follow the target platform’s C compiler (C.long is 32 bits on Windows and 64 bits on Linux and macOS), so a Go program must never assume unsafe.Sizeof(C.long(0)). C types do not cross package boundaries: the same C type used in two Go packages gives two different Go types, so exported functions of a Go package must not expose C types and should convert at the edge. Since Go 1.24 the compiler also rejects a method whose receiver is a cgo-generated type, including through an alias.

Strings, bytes and structs in one example

The next program exercises the conversions and the C type constructs: strings in both directions, GoStringN and GoBytes with an explicit length, a struct with a field named type (a Go keyword, reached with a leading underscore), an enum, a union (a byte array in Go), #define constants, sizeof, and errno returned as a second result.

package main

/*
#include <errno.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>

enum loan_state { LOAN_ACTIVE, LOAN_RETURNED = 5, LOAN_LOST };

struct loan {
	int32_t book_id;
	char    member[16];
	int     type; // "type" is a Go keyword: Go sees the field as _type
	enum loan_state state;
	double  fine;
};

union cell {
	int32_t  i;
	float    f;
	uint8_t  raw[8];
};

#define MAX_LOANS 3
#define LIBRARY_NAME "Shelf Central"

static void loan_init(struct loan *l, int32_t id, const char *member) {
	memset(l, 0, sizeof *l);
	l->book_id = id;
	strncpy(l->member, member, sizeof l->member - 1);
	l->state = LOAN_ACTIVE;
}

static int parse_count(const char *s) {
	char *end;
	errno = 0;
	long n = strtol(s, &end, 10);
	if (*end != '\0') {
		errno = EINVAL;
		return -1;
	}
	return (int)n;
}
*/
import "C"

import (
	"fmt"
	"unsafe"
)

func main() {
	// Strings and byte slices are copied across the boundary.
	cs := C.CString("shelf")
	defer C.free(unsafe.Pointer(cs))
	fmt.Println(C.GoString(cs), C.GoStringN(cs, 3), C.GoBytes(unsafe.Pointer(cs), 5))
	cb := C.CBytes([]byte{1, 2, 3})
	defer C.free(cb)

	// Structs, enums, unions, #define constants and sizeof.
	var l C.struct_loan
	member := C.CString("ada")
	defer C.free(unsafe.Pointer(member))
	C.loan_init(&l, 42, member)
	fmt.Println(l.book_id, C.GoString(&l.member[0]), l._type, l.state == C.LOAN_ACTIVE)
	var st C.enum_loan_state = C.LOAN_LOST
	fmt.Println(st, C.LOAN_RETURNED, C.MAX_LOANS, C.LIBRARY_NAME)
	fmt.Println(C.sizeof_struct_loan, unsafe.Sizeof(l), len(C.union_cell{}))

	// errno comes back as a second result.
	n, err := C.parse_count(cs)
	if n < 0 { // check the result first; errno may be set even on success
		fmt.Println("parse_count failed:", err)
	}
	good := C.CString("17")
	defer C.free(unsafe.Pointer(good))
	n, err = C.parse_count(good)
	fmt.Println(n, err)
}

Output:

shelf she [115 104 101 108 102]
42 ada 0 true
6 5 3 Shelf Central
40 40 8
parse_count failed: invalid argument
17 <nil>

What each line shows:

  • C.GoString and C.GoStringN copy a NUL-terminated or explicitly sized C string into a Go string; C.GoBytes copies raw bytes into a []byte. The copy means the Go value stays valid after the C memory is freed.

  • C.CString and C.CBytes allocate with malloc and copy the Go data in. They are the only places where Go data is copied for C automatically, and the caller frees the result, typically with defer C.free(unsafe.Pointer(p)). C.CBytes returns an unsafe.Pointer, which C.free takes directly.

  • A field named type in the C struct is l._type in Go. Bit-fields and misaligned members cannot be expressed in Go and are omitted from the Go struct (replaced by padding), so write C accessor functions for them.

  • The union C.union_cell is a [8]byte (its length printed as 8); reading a member means converting the bytes, for example with *(*C.int32_t)(unsafe.Pointer(&u[0])), or better, writing small C helpers.

  • The enumeration constants (C.LOAN_ACTIVE, C.LOAN_RETURNED) are Go constants; C.enum_loan_state is an integer type. This is not a Go iota enum: there is no String method and no exhaustiveness. Define a Go type with the same values for the public API (see Variables, Constants and iota).

  • C.sizeof_struct_loan equals unsafe.Sizeof(l): cgo lays the Go struct out exactly like the C one, padding included (see Structs and Embedding and, for the C side, Memory Model and Alignment).

  • A C function can be called in a two-value context to receive errno as an error (a syscall.Errno). Check the function’s result first: errno may be non-zero even when the call succeeded, which is the opposite of the usual Go convention. Here parse_count returns -1 for a bad string, so the err is meaningful only in that branch. C.malloc is special: it never returns nil (a failed allocation crashes the program like a Go out-of-memory) and so it has no two-value form.

Things that are not callable or not representable

  • Variadic C functions (printf, snprintf) cannot be called from Go. Write a fixed-signature C wrapper (see the next example).

  • Function pointers cannot be called from Go, but a Go variable of type C.visit_fn (or *[0]byte) can hold one and pass it to C, and C can call it. This is how callbacks work (see Calling Go from C).

  • Fixed-size array parameters in C are really pointers. Pass &x[0], not x.

  • Go structs cannot embed C types as fields, and Go types cannot be used in C struct definitions.

  • A few C types are uintptr in Go, because their values are not always real pointers and the garbage collector would be confused by them: the CoreFoundation *Ref types on macOS, the JNI object types (jobject, jstring and the others) and the EGL types. They are initialized with 0, not nil, and all operations on them happen in C.

  • 128-bit integers (__int128_t) are [16]byte.

Borrowing a Go string, wrapping variadics and viewing C memory

C code can read a Go string without copying it if a function declares a parameter of the special type _GoString_. Inside the preamble, _GoStringLen and _GoStringPtr return the length and a pointer to the bytes. The bytes are not NUL-terminated and must not be modified or retained after the call returns. The same example wraps the variadic snprintf in a fixed-signature function, and shows the two directions of slice conversion: C memory viewed as a Go slice with unsafe.Slice (no copy; the garbage collector does not own the memory, so it is freed with C.free), and Go memory passed to C by the address of its first element:

package main

/*
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// count_vowels borrows the bytes of a Go string without copying them.
static size_t count_vowels(_GoString_ s) {
	size_t n = _GoStringLen(s);
	const char *p = _GoStringPtr(s);
	size_t count = 0;
	for (size_t i = 0; i < n; i++) {
		if (strchr("aeiouAEIOU", p[i]) && p[i] != '\0') {
			count++;
		}
	}
	return count;
}

// Variadic C functions cannot be called from Go; wrap them.
static int format_shelf_mark(char *buf, size_t size, const char *section, int row, int slot) {
	return snprintf(buf, size, "%s-%02d-%03d", section, row, slot);
}

static int *make_ids(int n) {
	int *ids = malloc(n * sizeof *ids);
	for (int i = 0; i < n; i++) {
		ids[i] = 1000 + i;
	}
	return ids;
}

static int sum_ints(const int *p, int n) {
	int s = 0;
	for (int i = 0; i < n; i++) {
		s += p[i];
	}
	return s;
}
*/
import "C"

import (
	"fmt"
	"unsafe"
)

func main() {
	fmt.Println(C.count_vowels("The Left Hand of Darkness"))

	var buf [32]C.char
	section := C.CString("SF")
	defer C.free(unsafe.Pointer(section))
	n := C.format_shelf_mark(&buf[0], C.size_t(len(buf)), section, 7, 42)
	fmt.Println(n, C.GoString(&buf[0]))

	// C memory viewed as a Go slice: no copy, and Go's GC does not own it.
	ids := C.make_ids(4)
	defer C.free(unsafe.Pointer(ids))
	view := unsafe.Slice((*int32)(unsafe.Pointer(ids)), 4)
	view[0] = 1
	fmt.Println(view, C.sum_ints(ids, 4))

	// Go memory passed to C: pass the address of the first element, not the slice.
	goIDs := []C.int{1, 2, 3}
	fmt.Println(C.sum_ints(&goIDs[0], C.int(len(goIDs))))
}

Output:

6
9 SF-07-042
[1 1001 1002 1003] 3007
6

The unsafe.Slice call replaces the older technique of casting a pointer to a hand-built slice header, which the vet unsafeptr check and the unsafe and Low-Level Programming page both discourage: unsafe.Slice(ptr, n) (Go 1.17) is the safe, checked way to build a slice over n elements at ptr. Modifying view[0] changed the C array (it printed in the sum), which confirms there is no copy.

Source: unsafe.Slice and cmd/cgo.

Calling Go from C

A Go function is made callable from C with an //export comment. cgo generates a C declaration for it in the header _cgo_export.h, which the package’s C files can include (and, for a library, the build tool writes under a name you choose, see Building Go as a C library).

The example shows the most useful pattern, a callback. The C scanner reports each overdue book by calling a function pointer; Go supplies an exported function; the Go state travels through C as a cgo.Handle, an integer that refers to a Go value in a table inside the runtime.

package main

// #include "scan.h"
//
// // Only declarations are allowed in a file that uses //export.
// extern void goVisit(uintptr_t handle, int book_id);
import "C"

import (
	"fmt"
	"runtime/cgo"
)

// Overdue collects book ids reported by the C scanner.
type Overdue struct {
	IDs []int
}

//export goVisit
func goVisit(handle C.uintptr_t, bookID C.int) {
	o := cgo.Handle(handle).Value().(*Overdue)
	o.IDs = append(o.IDs, int(bookID))
}

func main() {
	o := &Overdue{}
	h := cgo.NewHandle(o) // an integer that C can store; the Go value stays in Go memory
	defer h.Delete()

	C.scan_loans(3, C.visit_fn(C.goVisit), C.uintptr_t(h))
	fmt.Println(o.IDs)
}

Output:

[100 101 102]

How the pieces fit:

  • The preamble of a file that contains //export must contain declarations only, never definitions. cgo copies the preamble into two different C output files, so a definition would be compiled twice and fail at link time with duplicate symbols. That is why extern void goVisit(...) is declared in the preamble and the C code lives in scan.c. A C file that wants the exported declarations includes "_cgo_export.h".

  • C.visit_fn(C.goVisit) converts the address of the exported Go function to the C function-pointer type. The Go function can not be called as C.goVisit(...) directly from Go; Go calls its own function the ordinary way.

  • runtime/cgo.Handle is how Go state reaches the callback. cgo.NewHandle(v) stores v in a table and returns a uintptr-sized integer, which can be given to C and stored there freely (it is not a Go pointer). The callback recovers the value with h.Value() and the owner calls h.Delete() when the C side can no longer use the handle. Never pass a Go pointer to the value itself, and never store Go state in a C global.

  • Parameter types of an exported function should be C types or Go types with a C counterpart. Go structs and arrays are not supported (use C structs and pointers), strings arrive as GoString in C, slices as GoSlice, and a function with several results becomes a C function returning a struct named <name>_return.

  • A callback may block, spawn goroutines and use any Go feature. If it panics and nothing recovers inside Go, the panic does not propagate through the C frames; the program crashes. Recover inside the exported function if the C caller must survive, and translate the failure into a C return code.

  • A C thread that the Go runtime did not create may also call an exported function: the runtime attaches the thread on demand. The thread has no goroutine identity between calls, so thread-local state in Go is meaningless there.

The pointer-passing rules

The garbage collector needs to know where every pointer to Go memory is. C code can neither be scanned nor told when a Go object moves, is freed or is reachable only from C. The rules therefore restrict what may cross the boundary, and the runtime checks them dynamically. The vocabulary:

  • A Go pointer points to memory allocated by Go (new, &T{}, the address of a local variable or a slice element).

  • A C pointer points to memory allocated by C (C.malloc, C.CString, a C global). Whether a pointer is a Go pointer or a C pointer is a property of the memory, not of its type.

  • Some Go values always contain Go pointers: interfaces, channels, maps and functions. Strings, slices, arrays and structs may contain them depending on their contents.

  • Pinned memory is memory that the garbage collector will not move or free while it is pinned.

Diagram of the Go heap and C memory with the cgo boundary between them. A pinned Go array and a cgo.Handle may be passed to C. A Go struct that points to unpinned Go memory is rejected with a runtime panic

The rules, as of Go 1.27:

Situation Allowed?

Pass a Go pointer to a C function when the memory it points to contains no unpinned Go pointers

Yes. The memory is pinned for the duration of the call

Pass a Go pointer to memory that contains a Go pointer to unpinned memory (a struct with a pointer, a slice of strings)

No: runtime panic, unless the inner pointers are pinned

Pass a pointer to a struct field

The memory considered is that field, not the whole struct

Pass a pointer to an array or slice element

The memory considered is the whole backing array

C keeps a Go pointer after the call returns

Only if the memory is pinned with runtime.Pinner and stays pinned while C uses it

C keeps a string, slice, channel or map

Never (these cannot be pinned for C’s use)

Go code called from C stores a Go pointer to unpinned memory in C memory

No

Go code called from C stores nil, a C pointer or a pinned Go pointer in C memory

Yes

Go code called from C returns a Go pointer

Only to pinned memory

Before Go 1.21 the same rules were written without the word "pinned": the memory was exempt from the check only for the duration of the call, and a pointer that C retained was simply forbidden. runtime.Pinner, added in Go 1.21, turned the restriction into a feature: a pinned object can be given to C and kept by C for as long as the Pinner has not unpinned it. Older articles that say "C code may not keep a Go pointer, period" describe the earlier state.

A violation, and two fixes

The struct below holds a pointer to a Go byte slice. Passing a pointer to the struct breaks the rule, because the struct contains a Go pointer to memory that is not pinned. Two fixes follow: pin what the struct points to for the duration of the call, or let C own the memory.

package main

/*
#include <stddef.h>
#include <stdlib.h>
#include <string.h>

struct request {
	char  *title;
	size_t n;
};

static size_t title_len(struct request *r) {
	return strlen(r->title);
}

*/
import "C"

import (
	"fmt"
	"runtime"
	"unsafe"
)

func tryCall(name string, f func()) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Printf("%s: panic: %v\n", name, r)
		}
	}()
	f()
	fmt.Printf("%s: ok\n", name)
}

func main() {
	title := []byte("Dune\x00")

	// Violation: the struct is passed, and it contains a Go pointer to memory that is not pinned.
	tryCall("unpinned", func() {
		req := C.struct_request{title: (*C.char)(unsafe.Pointer(&title[0]))}
		fmt.Println(C.title_len(&req))
	})

	// Fix 1: pin what the struct points to for the duration of the call.
	tryCall("pinned", func() {
		var p runtime.Pinner
		defer p.Unpin()
		p.Pin(&title[0])
		req := C.struct_request{title: (*C.char)(unsafe.Pointer(&title[0]))}
		fmt.Println(C.title_len(&req))
	})

	// Fix 2: let C own the memory.
	tryCall("c memory", func() {
		req := (*C.struct_request)(C.calloc(1, C.sizeof_struct_request))
		req.title = C.CString("Dune")
		fmt.Println(C.title_len(req))
		C.free(unsafe.Pointer(req.title))
		C.free(unsafe.Pointer(req))
	})
}

Output, with the default runtime checks (GODEBUG=cgocheck=1):

unpinned: panic: runtime error: argument of cgo function has Go pointer to unpinned Go pointer
4
pinned: ok
4
c memory: ok

With the checks turned off by GODEBUG=cgocheck=0 the same program prints unpinned: ok: the violation is still a violation, but nothing detects it. The C function here only reads the string during the call, so it would work by luck; with a concurrent garbage collection that moves or frees the object, or with C retaining the pointer, it would corrupt memory.

Notes on the three variants:

  • p.Pin(&title[0]) pins the object that holds the bytes. A Pinner can pin many objects, Unpin releases all of them, and the same Pinner can be reused. Keep the Pinner itself reachable for as long as C may use the memory (defer p.Unpin() does that). An unreachable Pinner unpins its objects automatically, which would be too soon.

  • Pin accepts only a pointer: a nil argument, a non-pointer value or an object allocated in an arena panics with a message starting runtime.Pinner:. A pointer to memory that is not on the Go heap (a global, C memory) needs no pinning, and pinning it has no effect.

  • The "C memory" variant allocates the struct with C.calloc, so the struct is not Go memory at all. Everything inside it may then point anywhere C likes, provided Go does not store an unpinned Go pointer into it.

What the checker catches, and how to see more

The default check, cgocheck=1, is cheap: it examines the arguments of cgo calls and the results of exported Go functions. It does not notice the case that is the most dangerous, a Go pointer stored into C memory behind the checker’s back. The program below stores a pointer to a Go array into a calloc'd C struct. With default settings it prints its message and exits normally:

package main

/*
#include <stdlib.h>

struct slot {
	char *title;
};
*/
import "C"

import (
	"fmt"
	"unsafe"
)

func main() {
	slot := (*C.struct_slot)(C.calloc(1, C.sizeof_struct_slot))
	defer C.free(unsafe.Pointer(slot))

	title := new([8]byte)
	// Storing a Go pointer in C memory breaks the rules; only cgocheck=2 notices the store itself.
	slot.title = (*C.char)(unsafe.Pointer(title))
	fmt.Println("stored a Go pointer in C memory")
}

Default output:

stored a Go pointer in C memory

Rebuilding with the thorough checker, selected at build time by the cgocheck2 experiment, catches the write itself:

GOEXPERIMENT=cgocheck2 go run .
write of unpinned Go pointer 0x73248931e020 to non-Go memory 0x1028113a0
fatal error: unpinned Go pointer stored into non-Go memory

runtime stack:
runtime.throw({0x102249715?, 0x0?})
	/Users/albertoirurueta/sdk/go1.27.2/src/runtime/panic.go:1243 +0x38 fp=0x16dc5e420 sp=0x16dc5e3f0 pc=0x102214b28
...

(The addresses and the long stack trace that follows are machine-specific and are abbreviated here.)

Setting Where set What it does

GODEBUG=cgocheck=1

At run time (the default)

Cheap checks of cgo call arguments and results

GODEBUG=cgocheck=0

At run time

Disables the checks. Use only to measure their cost

GOEXPERIMENT=cgocheck2

At build time (go build, go test, go run)

Complete checking, including every pointer write into non-Go memory. Slower; use it in CI tests of cgo packages

go vet (the cgocall analyzer)

At vet time

Reports passing a Go chan, map, func or slice to C, directly or through a pointer, array or struct

Go 1.21 moved the complete checker out of GODEBUG (where cgocheck=2 was a start-up setting) to the cgocheck2 experiment: programs and books that say GODEBUG=cgocheck=2 describe a mode that no longer exists.

The vet analyzer finds some mistakes before the program runs. This code passes a pointer to a struct that has a slice field:

package main

/*
#include <stddef.h>
static void consume(void *p, size_t n) {}
*/
import "C"

import "unsafe"

type Entry struct {
	Tags []string
	ID   int
}

func main() {
	e := Entry{Tags: []string{"fiction"}, ID: 1}
	C.consume(unsafe.Pointer(&e), C.size_t(unsafe.Sizeof(e)))
}

go vet reports:

main.go:18:12: possibly passing Go type with embedded pointer to C

and running it panics with argument of cgo function has Go pointer to unpinned Go slice. Vet is deliberately conservative: it flags types that are "almost always invalid", not every violation (for example the struct that holds a *string in the previous section passes vet and fails at run time). See Formatting, Vet, Fix and Static Analysis.

Practical rules of thumb

  • Prefer to pass flat data to C: a pointer to the first element of a []byte, a []C.int or a C.struct that contains only numbers. Flat data satisfies the rules by construction.

  • Prefer to let C own anything C keeps. Allocate with C.malloc, C.CString or C.CBytes, copy in, and free explicitly.

  • Pass a cgo.Handle, not a pointer, when C only needs an opaque token to give back to a callback.

  • If C must retain a pointer to Go memory (a registration API that stores a buffer), pin it with a runtime.Pinner that outlives the registration, and unpin when the C side is told to forget it.

  • Zero C memory before giving it to Go code that may store pointers into it. The documentation names a known limitation: Go code may legally store nil or a C pointer into C memory, but the current implementation can raise a spurious error if the existing bytes of that memory happen to look like a Go pointer.

  • Do not defeat the checks with unsafe conversions. The checks can be bypassed, and the program then fails "in unexpected and unpredictable ways".

  • Do not keep a C.CString result in a long-lived Go structure without a clear owner; it is a leak the garbage collector can neither see nor reclaim.

noescape and nocallback

By default the compiler is conservative about calls into C. It assumes that the C function might keep a Go pointer argument (so the Go object is allocated on the heap even if it would otherwise live on the stack), and that the C function might call back into Go (so every call prepares for that). Two directives, added in Go 1.24, promise otherwise and make a call cheaper:

#cgo noescape sum_bytes
#cgo nocallback sum_bytes
  • noescape f says no Go pointer passed to f is kept by f or handed back to Go. The argument can then be stack allocated. If the promise is false, the program may crash or corrupt memory.

  • nocallback f says f never calls an exported Go function. If it does, the program panics. The panic is the safe failure, and it is easy to provoke. This example exports goHook and a C function that calls it, marked nocallback:

package main

/*
#cgo nocallback run_hook
extern void run_hook(void);
*/
import "C"

import "fmt"

//export goHook
func goHook() { fmt.Println("hook ran") }

func main() {
	C.run_hook()
}
#include "_cgo_export.h"

void run_hook(void) { goHook(); }

Output (the goroutine trace is shortened and the path is machine-specific):

panic: runtime: function marked with #cgo nocallback called back into Go

goroutine 1 [running, locked to thread]:
main._Cfunc_run_hook()
	_cgo_gotypes.go:47 +0x34
main.main()
	main.go:15 +0x1c
exit status 2

The cost of a call

A Go-to-C call is not a function call. The runtime switches from the goroutine’s stack to the thread’s system stack (C code needs a large, fixed stack), marks the thread as being in a system call so that the scheduler may hand its processor to another thread if the call takes long, and switches back on return. Go 1.26 reduced that baseline overhead by about 30 percent, but a call is still an order of magnitude slower than a Go call, and the compiler can neither inline across it nor optimize around it.

The benchmark below compares a trivial operation (adding two numbers), where the cost is nearly all call overhead, with a loop over a 4 KiB buffer, where the C loop can amortize it. It uses b.Loop, so the compiler cannot eliminate the work, and the C function that takes a pointer carries both directives from the previous section.

package crcbench

/*
#include <stddef.h>
#include <stdint.h>

static uint32_t add(uint32_t a, uint32_t b) { return a + b; }

static uint32_t sum_bytes(const uint8_t *p, size_t n) {
	uint32_t s = 0;
	for (size_t i = 0; i < n; i++) {
		s += p[i];
	}
	return s;
}

#cgo noescape sum_bytes
#cgo nocallback sum_bytes
*/
import "C"

import "unsafe"

// AddC adds two numbers in C: the cost is almost entirely the cgo call.
func AddC(a, b uint32) uint32 { return uint32(C.add(C.uint32_t(a), C.uint32_t(b))) }

// AddGo is the same work in Go.
func AddGo(a, b uint32) uint32 { return a + b }

// SumC sums the bytes of p in C.
func SumC(p []byte) uint32 {
	if len(p) == 0 {
		return 0
	}
	return uint32(C.sum_bytes((*C.uint8_t)(unsafe.Pointer(&p[0])), C.size_t(len(p))))
}

// SumGo sums the bytes of p in Go.
func SumGo(p []byte) uint32 {
	var s uint32
	for _, b := range p {
		s += b2u(b)
	}
	return s
}

func b2u(b byte) uint32 { return uint32(b) }
package crcbench

import "testing"

var sink uint32

func BenchmarkAddGo(b *testing.B) {
	for b.Loop() {
		sink = AddGo(1, 2)
	}
}

func BenchmarkAddC(b *testing.B) {
	for b.Loop() {
		sink = AddC(1, 2)
	}
}

func BenchmarkSumGo4K(b *testing.B) {
	p := make([]byte, 4096)
	for b.Loop() {
		sink = SumGo(p)
	}
}

func BenchmarkSumC4K(b *testing.B) {
	p := make([]byte, 4096)
	for b.Loop() {
		sink = SumC(p)
	}
}

Run with a fixed iteration count (go test -bench . -benchtime=200000x); the numbers are machine-specific, from an Apple-silicon laptop:

goos: darwin
goarch: arm64
pkg: example.com/shelf
cpu: Apple M5
BenchmarkAddGo-10      	  200000	         3.640 ns/op
BenchmarkAddC-10       	  200000	        30.67 ns/op
BenchmarkSumGo4K-10    	  200000	      2188 ns/op
BenchmarkSumC4K-10     	  200000	       130.9 ns/op
PASS
ok  	example.com/shelf	0.646s

Reading the numbers: adding two integers in C is about eight times slower than in Go (the Go version is inlined and effectively free; the figure here is dominated by the call). Summing 4096 bytes is faster in C, but but this particular Go loop is a plain byte-at-a-time loop, and an optimizing C compiler can vectorize the equivalent C. A production Go implementation (encoding/binary, bytes, hash/crc32, crypto/*, which use assembly) is usually at least as fast as the C library it replaces, with no call overhead. The lesson is the one the books give: measure, and do not reach for cgo to make code faster. A benchmark that crosses the boundary once per element loses; one that crosses it once per large buffer can win.

The benchmark file is an ordinary _test.go file without import "C". A test file cannot use cgo directly. The real error is:

use of cgo in test .../main_test.go not supported
FAIL	example.com/shelf [setup failed]

The workaround is the one used above: put the cgo code in a regular file of the package (or an internal package) and test it through Go functions that wrap it. See Testing and Benchmarks, Fuzzing and synctest.

Blocking calls and threads

A goroutine inside a C call occupies an operating-system thread for the whole call and cannot be preempted. The Go scheduler copes by detaching the processor (the "P") from a thread that has been in a call for too long and giving it to another thread, so the other goroutines keep running. The program below sets GOMAXPROCS(1) and starts eight goroutines that each block in C for 300 ms. Without the detach they would serialize to 2.4 seconds; with it they overlap:

package main

/*
#include <unistd.h>

static void block_ms(int ms) { usleep(ms * 1000); }
*/
import "C"

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)

func main() {
	runtime.GOMAXPROCS(1) // one P: blocked C calls must not hold it
	start := time.Now()

	var wg sync.WaitGroup
	for range 8 {
		wg.Go(func() { C.block_ms(300) })
	}
	wg.Wait()

	elapsed := time.Since(start)
	fmt.Println("8 blocking C calls of 300ms with GOMAXPROCS=1 finished in under 1s:", elapsed < time.Second)
	fmt.Println("serialised they would take 2.4s; elapsed rounded to 100ms:", elapsed.Round(100*time.Millisecond))
}

Output:

8 blocking C calls of 300ms with GOMAXPROCS=1 finished in under 1s: true
serialised they would take 2.4s; elapsed rounded to 100ms: 300ms

Source: runtime and cmd/cgo.

The price is eight threads. Threads blocked in C are not goroutines: they count toward the runtime’s thread limit (runtime/debug.SetMaxThreads, 10000 by default), each has its own stack, and a program that calls a blocking C function from thousands of goroutines can exhaust it. Bound the concurrency with a semaphore or a worker pool (see Concurrency Patterns) when the C call blocks.

More consequences of the thread model:

  • Thread-local state in C needs a locked thread. Libraries that keep per-thread state (some GUI and graphics toolkits, OpenGL contexts, errno-like variables, thread-affine handles) must always be called from the same thread. Call runtime.LockOSThread() in the goroutine (commonly in an init function or at the top of a dedicated goroutine) so that its calls always run on one thread. See runtime.LockOSThread.

  • Signals. A C library that installs its own signal handlers can clash with Go’s. The runtime documents how to cooperate (SA_ONSTACK, chaining to the previous handler) in the os/signal package documentation.

  • The race detector instruments the Go code only; a data race inside C, or between Go and C, is invisible to it.

  • Profiles attribute C time to the cgo call frame; use the operating system’s profiler to see inside the library. See Profiling, Tracing and Diagnostics.

  • Memory. The Go garbage collector knows nothing about C allocations: they do not count toward GOMEMLIMIT, they are absent from heap profiles and they are never freed by the collector. A Go object that owns C memory should release it deterministically with a Close method, optionally backed by runtime.AddCleanup as a safety net (see Cleanups, Weak Pointers and Interning).

Building

CGO_ENABLED and build constraints

Whether cgo is available is decided by CGO_ENABLED (0 or 1). The defaults, from the documentation: enabled for native builds on systems where it is expected to work; disabled when cross-compiling; and disabled when CC is unset and the default C compiler is not found on PATH. The go command sets the cgo build constraint when it is enabled, and a file that imports "C" implicitly has //go:build cgo, so with CGO_ENABLED=0 such files are silently excluded. If that leaves the package empty, the build fails:

package example.com/shelf: build constraints exclude all Go files in .../e1

Many packages in the standard library have a cgo and a pure-Go implementation and choose one by this constraint (the net package’s resolver and os/user are the well-known ones: see Static binaries). A library of your own can do the same with a pair of files. This is the pattern; each file has a build constraint and both provide the same function:

//go:build cgo

package main

/*
#include <time.h>

static long long now_ns(void) {
	struct timespec ts;
	clock_gettime(CLOCK_REALTIME, &ts);
	return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec;
}
*/
import "C"

func clockSource() string {
	if C.now_ns() > 0 {
		return "C clock_gettime"
	}
	return "unknown"
}
//go:build !cgo

package main

import "time"

func clockSource() string {
	if time.Now().UnixNano() > 0 {
		return "Go time.Now"
	}
	return "unknown"
}
package main

import "fmt"

func main() {
	fmt.Println("clock source:", clockSource())
}

Both configurations compile, vet cleanly and run:

$ CGO_ENABLED=1 go run .
clock source: C clock_gettime
$ CGO_ENABLED=0 go run .
clock source: Go time.Now

go list shows which files each configuration selects (the other is ignored):

$ CGO_ENABLED=0 go list -f '{{.GoFiles}} {{.IgnoredGoFiles}}' .
[clock_nocgo.go main.go] [clock_cgo.go]
$ CGO_ENABLED=1 go list -f '{{.GoFiles}} {{.CgoFiles}}' .
[main.go] [clock_cgo.go]

Cross-compiling

Pure Go cross-compiles by setting GOOS and GOARCH. With cgo, the Go side still cross-compiles, but the C side needs a C compiler that targets the destination, and CGO_ENABLED has to be turned on explicitly because it defaults to off for a different target. Using the package from the first example (go env CGO_ENABLED prints 0 once GOARCH=amd64 is set on this arm64 Mac):

$ GOOS=linux go build -o /dev/null .
package example.com/shelf: build constraints exclude all Go files in .../e1

$ GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -o /dev/null .
# runtime/cgo
gcc_amd64.S:27:8: error: unknown token in expression
 pushq %rbx
       ^

The first command fails because cgo is off. The second turns it on, but the host’s clang is invoked for Linux and produces an assembler error. A cross-compiling C compiler is needed, selected with CC (and CXX):

# Not run here: needs a Linux cross toolchain.
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 CC=x86_64-linux-gnu-gcc go build ./cmd/shelf
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc go build ./cmd/shelf

Common sources of such a toolchain are the distribution’s gcc-<triplet> packages, a musl cross compiler, or a container that builds natively on the target (the most reliable choice: build in a golang image for the target architecture). Some projects use zig cc -target ... as the CC (community practice, not part of the Go toolchain). Any C libraries the code links must also exist for the target (the headers and libraries of the cross sysroot).

Between two architectures of the same operating system the host compiler may be enough. Apple clang can target both architectures of macOS, so the arm64 Mac builds an x86-64 binary of the cgo example when told to enable cgo:

$ CGO_ENABLED=1 GOARCH=amd64 go build -o shelf_amd64 .
$ file shelf_amd64
shelf_amd64: Mach-O 64-bit executable x86_64

go version -m on a binary lists the settings it was built with, including CGO_ENABLED=1 and the CGO_CFLAGS, CGO_CPPFLAGS, CGO_CXXFLAGS and CGO_LDFLAGS values, which is the quickest way to learn whether a binary used cgo. See The go Command, Builds and Cross-Compilation.

Static binaries

A program built without cgo is statically linked on Linux and needs no C library, which is why FROM scratch containers work for pure Go. A program that uses cgo links the system C library dynamically by default, which has two effects: the binary depends on a compatible libc at run time (building on a new glibc and running on an old distribution fails), and it will not run in scratch or in an Alpine image if it was built against glibc.

Two packages of the standard library switch to a cgo implementation when cgo is available, on Linux and several other Unix systems: net (the DNS resolver, using getaddrinfo) and os/user (group and user lookup). Two build tags select the pure-Go versions even when cgo is on:

# Not run here (the effect is visible on Linux): force the pure-Go resolver and user lookup,
# or switch cgo off entirely for a fully static executable.
go build -tags netgo,osusergo ./cmd/shelfd
CGO_ENABLED=0 go build ./cmd/shelfd

On macOS this experiment shows no difference, because the Go runtime there links the system library (libSystem) whether or not cgo is used. The following program, which uses net/http and os/user, built with CGO_ENABLED=1, with CGO_ENABLED=0 and with -tags netgo,osusergo, gives executables of nearly the same size that link the same system libraries (otool -L lists libSystem, libresolv, CoreFoundation and Security for all three):

package main

import (
	"fmt"
	"net/http"
	"os/user"
)

func main() {
	u, _ := user.Current()
	fmt.Println(u != nil, http.StatusOK)
}
5115842 prog_0
5115842 prog_1
5114290 prog_netgo

(Sizes in bytes from ls -l; prog_0 is CGO_ENABLED=0, prog_1 is CGO_ENABLED=1, and sizes are machine- and version-specific.) On Linux the same experiment is expected to give a dynamically linked executable with cgo and a static one without it, which ldd or file would show; that was not run here.

Source: cmd/go and os/user.

When a C library genuinely must be linked into a Linux executable that runs anywhere, the usual recipe is static linking against musl or a static glibc, with the external linker told to link statically:

# Not run here: needs a Linux host with a static libc (for example the musl toolchain).
CGO_ENABLED=1 go build -ldflags '-linkmode external -extldflags "-static"' ./cmd/shelf

A glibc static build still warns that getaddrinfo and friends need the shared library at run time, which is the reason to combine static linking with the netgo and osusergo tags, or to use musl. Go 1.27 adds cgo, PIE and external linking support on big-endian 64-bit PowerPC Linux (linux/ppc64, ELFv2 ABI), but programs without cgo options still default to static, internally linked binaries.

Building Go as a C library

The opposite direction packages Go code so that a program in C (or in any language with a C FFI: Python via ctypes, Rust, Swift, C#, Java) can call it. go build -buildmode=c-shared produces a shared library and a header; -buildmode=c-archive produces a static archive and a header. Exported functions are the entry points, and the package must be package main with an empty main function (the Go runtime starts in the library’s initializer, not in main).

package main

import "C"

import "strings"

// ShelfNormalizeISBN removes hyphens and spaces from an ISBN. The caller owns the returned string
// and must release it with ShelfFree.
//
//export ShelfNormalizeISBN
func ShelfNormalizeISBN(s *C.char) *C.char {
	clean := strings.NewReplacer("-", "", " ", "").Replace(C.GoString(s))
	return C.CString(clean)
}

// ShelfFree releases a string returned by ShelfNormalizeISBN.
//
//export ShelfFree
func ShelfFree(s *C.char) {
	// C.free needs stdlib.h; keeping it in a separate file avoids a preamble in a file with //export.
	freeCString(s)
}

// main is required by -buildmode=c-shared and c-archive, but is not run.
func main() {}

The matching free.go keeps the C.free call in a separate file because the file with //export may not define anything in its preamble:

package main

/*
#include <stdlib.h>
*/
import "C"

import "unsafe"

func freeCString(s *C.char) { C.free(unsafe.Pointer(s)) }

The build commands and a C client that uses the library:

#include <stdio.h>

#include "libshelf.h"

int main(void) {
	char *clean = ShelfNormalizeISBN("978-0-13-468599-1");
	printf("%s\n", clean);
	ShelfFree(clean);
	return 0;
}
go build -buildmode=c-shared  -o libshelf.dylib .      # shared library plus libshelf.h
go build -buildmode=c-archive -o libshelf.a     .      # static archive plus libshelf.h
cc -I. client/main.c -L. -lshelf -o main_c             # link the shared library
DYLD_LIBRARY_PATH=. ./main_c                           # (LD_LIBRARY_PATH on Linux)
cc -I. client/main.c libshelf.a -framework CoreFoundation -framework Security -lresolv -o main_a
./main_a

Both clients print the cleaned ISBN:

9780134685991

The generated header declares the exported functions after some boilerplate (the GoInt, GoString, GoSlice typedefs):

extern char* ShelfNormalizeISBN(char* s);
extern void ShelfFree(char* s);

The files from this run were a 1.3 MB shared library and a 3.6 MB archive (machine-specific): they contain the entire Go runtime, including the garbage collector and scheduler, which start on first use.

Rules for a library boundary:

  • Ownership is explicit. The Go function returned a C.CString, so the caller must give it back to the library’s own release function. Do not ask C clients to call free on memory the Go side allocated with a different allocator, and never return a Go pointer to unpinned memory.

  • Use only C-compatible types in exported signatures. Numbers, *C.char, unsafe.Pointer, C.struct_x pointers. Go strings and slices are accepted as GoString and GoSlice in the header, but C cannot create them or keep them.

  • The Go runtime lives inside the host process. It creates threads, installs signal handlers and starts the garbage collector. Initialize the library once and avoid loading a second Go library into the same process.

  • Callbacks run on the caller’s thread. A function exported to C may be invoked from many C threads concurrently, so the Go code must be thread-safe.

  • Mobile and WebAssembly. gomobile (a separate tool, golang.org/x/mobile) builds on these build modes. For WebAssembly, the analogous export mechanism is //go:wasmexport, described in WebAssembly and WASI.

Debugging, testing and tooling with cgo

  • Run tests with the thorough checker. In CI, GOEXPERIMENT=cgocheck2 go test ./... for packages that use cgo catches violations that cgocheck=1 cannot see. Add -race for the Go side.

  • Use go vet. The cgocall analyzer is part of the default vet set.

  • Native debuggers. gdb and lldb can step into C frames; Go’s Delve (dlv) can show goroutines but sees C only as opaque frames. Build with -gcflags='all=-N -l' for readable Go frames while debugging.

  • AddressSanitizer and friends. The Go toolchain can run C code under the sanitizers: go build -asan, -msan (Clang on Linux only) and -race are the standard flags; use them to catch memory errors in the C side. They need a toolchain that supports them on your platform.

  • go doc cmd/cgo is the authoritative reference and is installed with the toolchain.

  • Keep the surface small. A cgo package is easier to maintain when the Go API is thin: one internal package wraps the C library, converts types at the boundary, turns return codes and errno into Go error values, and exposes no C.* types. Everything else in the program is plain Go (see Error Handling for wrapping C errors).

Releases and changes

Version Change

Go 1.5

-buildmode=c-shared and c-archive introduced (libraries callable from C)

Go 1.10

The CoreFoundation *Ref and JNI object types became uintptr on the Go side

Go 1.17

unsafe.Slice (and, in 1.20, unsafe.String and unsafe.StringData): the checked way to view C memory as a Go slice

Go 1.21

runtime.Pinner and the "pinned memory" formulation of the rules; GODEBUG=cgocheck=2 replaced by GOEXPERIMENT=cgocheck2; runtime/cgo linked when an external linker is used

Go 1.24

#cgo noescape and #cgo nocallback; methods on cgo-generated receiver types always rejected; a stricter check for C functions with incompatible declarations in different files

Go 1.26

About 30 percent lower baseline overhead for cgo calls; heap base address randomization on 64-bit platforms (it makes addresses harder to predict for attackers); Windows/arm64 supports internal linking of cgo programs

Go 1.27

Cgo, PIE and external linking supported on linux/ppc64 (ELFv2 ABI); response-file (@file) arguments for the cgo tool. No change to the pointer-passing rules

For comparison, nothing in the 1.27 release notes changes import "C" itself: examples in this page were checked with go 1.27 in go.mod.

Outdated advice you will meet

Older advice Current state

"Go pointers passed to C must not contain Go pointers, and C may not hold one, period"

Still the default for unpinned memory, but runtime.Pinner (1.21) lets C keep a pinned Go pointer

GODEBUG=cgocheck=2

Removed as a run-time option in 1.21; build with GOEXPERIMENT=cgocheck2

Casting a pointer to a hand-built slice header to view C memory

Use unsafe.Slice(ptr, n); the old technique is flagged by vet and the header types are deprecated

"cgo calls cost roughly 200 ns" (old measurements)

The baseline is far lower on current hardware and Go 1.26 cut it by about a third. Measure on your platform; it is still many times a Go call

Treating cgo as a performance tool

The books' advice stands: use it for integration, not speed. Benchmark before and after

The same problem in other languages on this site

Language Mechanism Page

C

The library’s own language. The pointer, allocation and layout rules that the C side of a cgo boundary obeys

Pointers, Dynamic Memory Allocation, Memory Model and Alignment

Rust

extern "C", #[repr(C)], unsafe blocks, bindgen/cbindgen; ownership rules instead of a collector’s pinning rules

FFI & C Interop, Memory Layout & repr

C#

P/Invoke, DllImport, marshalling attributes, function pointers

Native Interop

Swift

Direct import of C headers through a module map; C++ interop

"Interoperability with C, Objective-C and C++"

Objective-C

A superset of C; the bridge to Swift

Objective-C++ and C Interoperability

The design difference is worth stating: Rust, Swift and C# generally call a C function with a declared signature and check nothing at run time; Go inserts a runtime check (cgocheck) because its moving, concurrent garbage collector needs the rule that C must never hold an unpinned Go pointer, and because a goroutine blocked in C costs a thread. The practical outcome is the same in all of them: keep the foreign surface thin, convert at the edge, and own the memory on one side.

Common mistakes

  • A blank line between the preamble and import "C".

  • Using C.free without #include <stdlib.h>, or forgetting to free the result of C.CString.

  • Passing a Go struct that contains pointers, slices or strings to C by address.

  • Storing a Go pointer in C memory and expecting the collector to keep the object alive.

  • Calling a variadic C function (wrap it), or calling a C function pointer from Go.

  • Checking err before the result of a C call that returns an errno.

  • Assuming C.long or C.size_t has the same width on every platform.

  • Using import "C" in a _test.go file.

  • Putting definitions in the preamble of a file that uses //export.

  • Expecting CGO_ENABLED to be on for a cross-build, or expecting a cgo binary to run in scratch.

  • Blocking in C from thousands of goroutines and running out of threads.

  • Reaching for cgo for speed.