AlgoMaster Logo

Calling C from Go

Low Priority20 min readUpdated June 6, 2026

CGO lets a Go program call functions written in C and, going the other way, lets C call back into Go. This chapter focuses on the type system at the boundary, how strings and structs cross it, the rules around memory ownership, and how callbacks via //export actually flow.

The Type Bridge

Once the build is set up, every CGO program lives at a boundary where two different type systems meet. Go has its own int, string, []byte, and struct definitions. C has its own. CGO does not silently bridge them. You convert between the two on every call, and the language gives you a small set of helpers to do that conversion safely.

The pseudo-package C is the entry point on the Go side. Anything declared in the preamble (the comment block immediately above import "C") becomes accessible through it. A C function cart_total becomes C.cart_total. A C type struct CartItem becomes C.struct_CartItem. A C constant MAX_ITEMS becomes C.MAX_ITEMS. The Go compiler does not parse the C code itself; CGO invokes the C compiler, parses its output, and generates Go shims that match.

The diagram shows what happens on a single call. Your Go value gets converted into a CGO equivalent, the generated shim hands it to the C function, the result comes back the same way, and you convert it back into something Go can use. The conversion step is not free, and it is not implicit. You write it.

Go Types and C Types

Every C primitive has a Go counterpart inside the C package. The mapping looks intuitive, but there is one important catch: Go's built-in int is not the same as C.int. Go's int is platform-defined and may be 32 or 64 bits. C.int is whatever the C compiler thinks int is on the current platform, which today is almost always 32 bits even on 64-bit machines. They look interchangeable. They are not.

C typeCGO typeTypical sizeGo counterpart
charC.char1 bytebyte (with conversion)
shortC.short2 bytesint16
intC.int4 bytesint32 (NOT Go int)
longC.long4 or 8 bytesplatform-dependent
long longC.longlong8 bytesint64
unsigned charC.uchar1 byteuint8
unsigned shortC.ushort2 bytesuint16
unsigned intC.uint4 bytesuint32
unsigned longC.ulong4 or 8 bytesplatform-dependent
unsigned long longC.ulonglong8 bytesuint64
floatC.float4 bytesfloat32
doubleC.double8 bytesfloat64
size_tC.size_tplatform worduint (with conversion)
uintptr_tC.uintptr_tplatform worduintptr

Conversions are always explicit, in both directions. There is no automatic coercion, even between types that look identical.

Both C.double(price) and float64(...) are mandatory. Drop either one and the compiler refuses. That looks tedious, but it is what keeps the boundary safe. You always see, in the source, exactly where a Go value becomes a C value and back again.

Strings Across the Boundary

Go strings and C strings are not the same thing. A Go string is a header containing a pointer and a length; the bytes do not need to be NUL-terminated, and they live in memory the garbage collector manages. A C string is a pointer to a buffer whose end is marked by a NUL byte, and its memory is owned by whoever allocated it. Converting between them always involves a copy.

CGO gives you four helpers for this:

HelperDirectionAllocates withWho frees
C.CString(goStr)Go to CmallocYou (call C.free)
C.GoString(cStr)C to GoGo heapGarbage collector
C.GoStringN(cStr, n)C to Go (not NUL-terminated)Go heapGarbage collector
C.CBytes(goBytes)Go to C (byte slice)mallocYou (call C.free)

The asymmetry is important. When CGO copies bytes into Go memory, the runtime owns them. When CGO copies into C memory (via C.CString or C.CBytes), the C side owns them, and there is no garbage collector watching. You free them yourself with C.free, or you leak the allocation.

The idiomatic pattern is defer C.free directly after the allocation, so the cleanup sits next to the cause. Forgetting it is one of the easier ways to grow memory in a long-running CGO program.

C.CString walks name, allocates a buffer of len(name)+1 bytes with malloc, copies the bytes, and writes a trailing NUL. The result is a *C.char that you can pass anywhere a C function expects a string. The defer C.free(unsafe.Pointer(cname)) lines up the cleanup, and the unsafe.Pointer cast is required because C.free takes a generic pointer rather than a typed one.

Going the other way is gentler on the eye. C.GoString allocates Go memory, copies the bytes up to the first NUL, and returns a regular Go string. The garbage collector handles the rest.

welcome_message returns a pointer to a string literal that lives in the binary's read-only data segment. C.GoString copies it into a Go string, and from that moment on the original C pointer is irrelevant. The Go side never holds onto C memory beyond the conversion call, which is exactly what you want when the lifetime of the C buffer is uncertain.

When the C side hands you a buffer that is not NUL-terminated, C.GoStringN(ptr, length) copies exactly that many bytes. This shows up when reading fixed-width records or framed network protocols, where the length comes from a header rather than a sentinel byte.

C.GoStringN does not care about NUL; it copies exactly five bytes regardless of what comes after them. Use it whenever the C side knows the length but the buffer was never NUL-terminated.

Byte Slices Across the Boundary

Byte slices follow the same pattern as strings. C.CBytes(goSlice) allocates with malloc and copies. C.GoBytes(cPtr, length) copies into Go memory. The signatures of these helpers take an unsafe.Pointer and a C.int, so you cast a *C.char (or whatever pointer you have) to unsafe.Pointer before calling.

C.CBytes returns an unsafe.Pointer (not a typed *C.uchar), so you cast it on the way into the C call. The matching defer C.free(cdata) cleans up the malloc-backed buffer when main returns. Without that line, the program would leak 29 bytes per call, which is harmless in a one-off but catastrophic in a loop.

Going from C to Go uses C.GoBytes. It takes the same kind of pointer plus a length and copies into a fresh []byte on the Go heap.

The C function returns a pointer to a static buffer that it owns. C.GoBytes copies eight bytes from that buffer into a Go []byte. The Go side never reads from the original C buffer again, which means the lifetime of the C memory does not matter to the Go code beyond that single call.

Passing Structs

CGO maps each C struct to a generated Go type named with the C.struct_ prefix. A C declaration struct OrderHeader { ... } becomes C.struct_OrderHeader on the Go side. The generated type mirrors the C layout exactly: the same fields, in the same order, with the same alignment. You read and write fields with regular Go syntax: header.id, header.total, header.timestamp.

The struct literal looks like ordinary Go, and the field accesses look like ordinary Go. Two things are different. The field types are CGO types, so the assignments need explicit conversions (C.longlong(10042)). And the field names follow C, not Go: item_count stays snake_case on both sides because that is how it was declared in C.

The pointer case is straightforward. Take the address of the Go-side struct value and CGO turns it into the matching C pointer type.

The C function multiplies h->total by 0.95 and the Go side sees the updated value because both sides are looking at the same memory. The struct lives on the Go stack (or heap, if escape analysis decides so), and the C function writes into it through the pointer for the duration of the call.

Pointers and Arrays

C functions often take a pointer and a length to describe an array. The Go side builds a slice, takes a pointer to its first element, and passes that pointer plus the length. The CGO pointer-passing rules (covered below) constrain what you can do with the pointer once it crosses, but for plain numeric arrays the usual recipe is short.

&prices[0] is a *float64. Casting it through unsafe.Pointer to *C.double is safe here because float64 and C.double have identical representation on every platform Go supports. The C side walks the array using normal pointer arithmetic and returns the sum.

For arrays of CGO types, the same pattern works without the cast through *float64 because the slice's element type already matches. The next section uses that pattern for structs.

The slice is already []C.int, so &quantities[0] is a *C.int directly, with no cast needed. This is the cleanest shape when the C function expects a typed pointer and you are happy to work with CGO types on the Go side.

A Worked End-to-End Example with Structs and Arrays

The earlier pieces combine in a typical CGO workload: a slice of structs on the Go side, walked by a C function that fills in a result. The example below computes the total of a shopping cart and the count of items that qualify for free shipping (price above $25). The C side does both in a single pass.

The cart is a Go-side slice of CGO structs. &cart[0] is a *C.struct_CartItem, the C function walks it, and the returned C.struct_CartSummary is just another struct value copied across the boundary. Nothing on the Go side has to outlive the call, and nothing on the C side has to be freed.

The diagram traces a single call. The Go-side slice supplies a pointer to its first element, the C function walks the underlying memory, and the result returns as a small struct copied back into Go space. The arrangement keeps the data in one place (the Go heap) for the duration of the call, which is what the pointer-passing rules want.

Calling Go from C with //export

The other direction is //export. A Go function marked with the //export Name directive (with no space between // and export) becomes callable from C under that name. The CGO build generates a header file _cgo_export.h that declares the exported functions, and the C preamble in any of your files can #include "_cgo_export.h" to call them.

There is one structural rule worth knowing up front. The file that contains //export functions can only have a comment-only preamble (declarations, no function definitions). Function definitions for the C side must live in a separate .c file in the same package. The reason is mechanical: the preamble in the same file as //export would generate duplicate declarations of the exported functions.

The cleanest layout is two files in the same package: a Go file with the export and a small C source file with the C function that calls back. The example below tracks discounts as they get applied to cart items, calling back into Go every time a discount triggers.

The Go file:

The companion header discount.h in the same directory:

The companion source discount.c in the same directory:

The header declares the struct and the function. The C source includes both the header and the CGO-generated _cgo_export.h so it can see the declaration of OnDiscountApplied. The Go file marks OnDiscountApplied for export, and CGO generates the bridge that lets C call it.

The diagram shows the round trip. Go calls into C, C walks the items, every qualifying item triggers a callback into Go through the generated bridge, and after the loop finishes the total returns to Go. Each callback is a real cross-boundary call with its own setup cost.

Memory Ownership and the Pointer-Passing Rules

The hardest part of CGO is not types or strings. It is memory ownership. Go has a moving garbage collector that may reorganize the heap at any time. C assumes its pointers stay valid until the program frees them. The two models do not naturally agree, and the cgo runtime enforces a set of rules that keep them from corrupting each other.

The rules in brief:

  1. Go memory passed to C must not contain Go pointers. A *int is a Go pointer. A *C.int is not. A struct that holds a string or a slice contains Go pointers inside its layout. A struct that holds only numeric CGO types does not.
  2. C must not store a Go pointer past the duration of the call. The Go runtime may move or reclaim that memory the moment the call returns. If C needs to remember a value, it must copy it into C-allocated memory first.
  3. C-allocated memory (from malloc, C.CString, C.CBytes) is fine to keep as long as you want. You free it yourself.

The diagram sorts the common patterns into safe and unsafe. Passing a pointer to Go memory for the duration of a single C call is fine; the runtime arranges things so the value will not move during the call. Storing that pointer somewhere C-owned and reading it later is not fine, because by the time you read it, the Go runtime may have moved the value to a different address.

The runtime checks a subset of these rules. The environment variable GODEBUG=cgocheck=1 (the default) does a fast scan of arguments at the call site. GODEBUG=cgocheck=2 does a more thorough check, including following nested pointers, which is helpful during development but expensive enough that you usually do not run it in production. Violations show up as runtime panics with a clear message.

The pointer-passing rules also explain why most CGO APIs in practice look like "pass a buffer, get a buffer back" rather than "give me a pointer, I'll call you later". Synchronous, scoped calls fit the rules naturally. Async patterns that hold onto Go memory across calls do not.

Errors and errno

Many C functions report errors through errno, a thread-local integer that the function sets on failure. CGO has a special form for retrieving it: the two-result call. When you write n, err := C.someFunc(...), CGO captures errno into err automatically (only when the C function returns a value, and only when you write the call in this two-result form).

C.find_product returns one value in C, but written as n, err := C.find_product(...) it returns two on the Go side. CGO reads errno after the call and wraps it in a Go error (one of the standard syscall.Errno values). When the C function succeeds and leaves errno alone, err is nil.

The two-result form only works for C functions that return a value. For void functions, capture errno yourself by exposing it through a small wrapper, since CGO has no return value to attach an error to.

The wrapper get_errno reads the thread-local errno after the void function returns. Wrapping it in syscall.Errno produces a Go error value with the usual Error() text. It is a small extra step, but the pattern is reliable for void functions and for any code path where the two-result form does not fit.

Quiz

Calling C from Go Quiz

10 quizzes