How to Use Callbacks in Go

Export Go functions to C using the //export directive to enable C code to call back into your Go program.

The callback trap

You're integrating a C library into your Go program. The library needs a callback to report events. You write a Go function, take its address, and pass it to C. The program compiles. It runs. Then it segfaults, or the compiler rejects the code with a complaint about unsafe conversions.

The problem isn't your logic. It's the shape of a function. In C, a function is a memory address. In Go, a function is a value that can carry hidden state. C expects a simple pointer. Go hands over a struct. When C calls that pointer, it misses the state. The result is garbage data or a crash. You need a bridge that translates between the two worlds.

Functions are values, not addresses

In C, a function pointer is exactly that: a pointer to code. The CPU jumps to that address and executes. There is no extra data.

Go functions are different. A Go function value is a struct containing two fields. The first field is the code pointer. The second field is the closure environment. If your function captures variables from its surrounding scope, those variables live in a heap allocation attached to the function value. Even if your function captures nothing, the second field exists; it's just nil.

When you pass a Go function to C, you are passing a struct where C expects a pointer. C reads the first field as the address and calls it. It ignores the second field. If the function is a closure, the environment is lost. The function executes with missing context. If the function relies on captured variables, it reads garbage or panics.

You cannot pass a Go function value directly to C. You must export a Go function so CGo generates a C-compatible shim, or you must route the call through a dispatcher that understands Go's function layout.

The export shim

The //export directive tells CGo to make a Go function visible to C. CGo generates a C function with the same name. That C function acts as a shim. It handles the transition: it sets up the Go runtime, calls your Go code, and cleans up. When you pass the exported symbol to C, you're passing a pointer to the shim, not to the Go function itself.

The exported function must follow strict rules. It cannot be a closure. It cannot capture variables. Its signature must use C-compatible types.

Here's the simplest bridge. You mark a Go function with //export, give it a C-compatible signature, and pass the generated C symbol to the library.

package main

/*
#include <stdio.h>

// C function that accepts a callback.
// The callback takes an int and returns void.
void c_library_function(void (*callback)(int)) {
    callback(42);
}
*/
import "C"

import "fmt"

//export goCallback
// The //export directive must be immediately above the function.
// No blank lines allowed between the directive and the func keyword.
func goCallback(n C.int) {
    // This function runs on a goroutine managed by the CGo shim.
    // It can safely call other Go code.
    fmt.Printf("Received from C: %d\n", n)
}

func main() {
    // C.goCallback is the C symbol generated by //export.
    // Pass it to the C library as the callback.
    C.c_library_function(C.goCallback)
}

The //export directive works only for top-level functions. If you try to export a function defined inside another function, or a method, the compiler rejects the program with //export: function must be top-level. The function must be defined at package level.

The parameter types must match C types. Use C.int, C.char, C.double, or unsafe.Pointer. You cannot use Go slices, maps, channels, or interfaces in an exported function. The compiler complains with //export: invalid type if you use a Go-specific type.

Walking through the shim

When you compile with CGo, the tool generates a C header and a C source file. The //export goCallback directive causes CGo to emit a C function named goCallback. That C function does not contain your logic. It contains a call to a runtime helper. The helper switches execution from the C thread to the Go runtime, invokes your Go function, and returns.

This shim is essential. It ensures the Go runtime is active when your code runs. It handles stack switching. It guarantees that your Go function executes in a safe environment. Without the shim, calling Go code from C would corrupt the runtime state.

The shim also enforces the type rules. CGo checks the signature at compile time. If the Go function signature doesn't match what C expects, you get a type mismatch error before the program runs. This prevents silent corruption.

Routing per-instance callbacks

Real C libraries often support multiple callbacks. They might create several objects, each with its own callback. Or the callback signature might include a void* user data pointer. You can't export a unique Go function for every instance. Exported functions must be top-level and static.

The solution is the map-and-token pattern. You store Go functions in a map. You generate a unique token for each function. You pass the token to C as the user data pointer. You export a single dispatcher function. When C calls the dispatcher, it passes the token. The dispatcher looks up the token in the map and calls the corresponding Go function.

This pattern lets you pass different Go closures to different C objects. The dispatcher routes the call based on the token.

Here's the realistic pattern. The C library expects a callback with a void* argument. You register Go closures, get tokens, and set the dispatcher as the callback.

package main

/*
#include <stdlib.h>

// C callback type includes a void* for user data.
typedef void (*Callback)(void* user_data);

// C struct holding a callback and its data.
typedef struct {
    Callback cb;
    void* data;
} Handler;

// C function to invoke the callback.
void invoke(Handler* h) {
    if (h->cb) {
        h->cb(h->data);
    }
}
*/
import "C"

import (
    "fmt"
    "sync"
    "unsafe"
)

// callbacks maps tokens to Go functions.
// Tokens are uintptr values derived from pointers.
var callbacks = make(map[uintptr]func())
var mu sync.Mutex
var nextToken = uintptr(1)

//export dispatchCallback
// The dispatcher receives the token from C.
// C passes the token as the void* argument.
func dispatchCallback(token unsafe.Pointer) {
    mu.Lock()
    // Convert pointer back to uintptr for map lookup.
    fn, ok := callbacks[uintptr(token)]
    mu.Unlock()

    if ok {
        // Call the Go function.
        // This executes the closure with its captured state.
        fn()
    }
}

// register stores a Go function and returns a token.
// The token is passed to C as user_data.
func register(fn func()) unsafe.Pointer {
    mu.Lock()
    defer mu.Unlock()

    token := nextToken
    nextToken++
    callbacks[token] = fn

    // Return pointer to the token.
    // C treats this as void*.
    // The pointer value is stable because it points to a static integer.
    return unsafe.Pointer(token)
}

// unregister removes a callback to prevent leaks.
// Call this when the C object is destroyed.
func unregister(token unsafe.Pointer) {
    mu.Lock()
    delete(callbacks, uintptr(token))
    mu.Unlock()
}

func main() {
    var h C.Handler

    // Register a Go closure that captures local state.
    h.data = register(func() {
        fmt.Println("Callback executed via map lookup!")
    })

    // Set the C callback to the dispatcher.
    h.cb = C.dispatchCallback

    // Invoke from C.
    C.invoke(&h)

    // Clean up the map entry.
    unregister(h.data)
}

The map must be protected by a mutex. C callbacks can run on any thread. Multiple C threads might invoke callbacks concurrently. The mutex ensures safe access to the map.

The token is an integer cast to a pointer. C treats it as void*. This is safe because the token value is small and fits in a pointer. The pointer doesn't point to valid memory; it's just a handle. The dispatcher converts it back to an integer for the map lookup.

You must clean up the map. If C holds a token forever, your map grows forever. The unregister function removes the entry. Call it when the C object is destroyed. Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Map leaks happen when entries never get deleted. Always have a cleanup path.

Pitfalls and compiler errors

Exported functions have restrictions. They cannot be variadic. They cannot return interfaces. They cannot use Go-specific types. The compiler enforces these rules. If you try to use a slice in an exported function, you get //export: invalid type. If you try to export a method, you get //export: function must be top-level.

The //export directive must be immediately above the function. No comments, no blank lines. If you put a blank line between the directive and the function, CGo ignores the directive. The function remains invisible to C. You'll get an undefined symbol error when C tries to call it.

Closures cannot be exported. If you try to //export a function that captures variables, the compiler rejects it. The function must be static. Use the map pattern if you need closures.

Memory management is your responsibility. CGo doesn't track Go objects passed to C. If you pass a pointer to a Go slice or string to C, and C stores that pointer, the Go garbage collector might reclaim the memory while C still holds the pointer. The result is a use-after-free bug. Only pass pointers to memory allocated by C, or memory that lives for the entire program duration.

Convention aside: gofmt does not format the C code inside the comment block. The C code is left as-is. You must format it yourself. Most editors run gofmt on save, but it skips the C block. Keep the C code readable.

Convention aside: unsafe.Pointer is the bridge between Go and C. Use it carefully. The community accepts unsafe only when necessary. Wrapping C is a valid use case. Keep the unsafe usage minimal and documented. Don't use unsafe for performance tricks. Use it for interoperability.

Decision matrix

Use //export with a direct function when the C library expects a single global callback and you don't need per-instance behavior.

Use the map-and-token pattern when the C callback signature includes a void* user data pointer and you need to route calls to different Go closures.

Use a dedicated wrapper package when the C interface is large; isolate CGo in one file to keep the rest of your code safe and portable.

Use a pure Go implementation when one exists; CGo adds build complexity and prevents cross-compilation without extra tooling.

C sees addresses. Go sees values. Build the bridge, don't force the fit.

Where to go next