Go programs often need one function from an operating-system library, graphics stack, database client, audio engine, or proprietary shared object. The conventional answer is cgo: compile a C bridge, link against headers and libraries, and let the Go toolchain coordinate the boundary. That works, but it weakens one of Go’s strongest deployment properties: simple cross-compilation from a pure Go build.
Cgo-free dynamic calls take a different route. The program loads a shared library at runtime, resolves a symbol, and binds that symbol to a Go function value with a matching calling convention. The source project that motivated this page, purego, exposes that shape with Dlopen, Dlsym, RegisterFunc, RegisterLibFunc, SyscallN, and NewCallback. The result is still foreign-function interface work, but the build can run with CGO_ENABLED=0.
The pattern is useful when a Go binary should stay mostly portable while still integrating with platform libraries or optional plugins. It is not a blanket replacement for cgo. It shifts responsibility for ABI correctness, struct layout, pointer lifetimes, library availability, and platform-specific loader behavior onto the Go side. Used carefully, it is a powerful systems technique for plugin surfaces, native UI or game integrations, and optional acceleration paths.
Dynamic loading opens a shared object already present on the target machine, links it into the current process, and returns a handle that can be used for symbol lookup. On Unix-like systems this is the dlopen family; on Windows it maps to APIs such as LoadLibrary and GetProcAddress. The Go program is no longer linked to the library at build time, so missing libraries fail at startup or feature activation time instead of compile time.
A resolved symbol is just an address. To call it safely, the Go side must describe the function with the correct argument types, result type, and calling convention. Libraries such as purego patch a Go function variable so a normal Go call jumps through a foreign-call trampoline. The compiler cannot verify that the Go signature matches the C symbol, so tests must cover the exact target platforms.
The hardest bugs happen when memory crosses the boundary. A string copied for one call may not survive after the call returns. A pointer returned from C may need an explicit C-side free function. A Go slice passed as void* must not be retained by C after the call unless the program has a proven lifetime strategy. Cgo’s pointer rules are still the right mental model even when cgo is absent.
Cgo-free FFI keeps the Go build independent of a C compiler and C headers, then pays the native-integration cost at runtime through loader handles, symbol names, typed function bindings, and explicit lifetime rules.
The basic workflow is: choose the platform library path, load it, bind a symbol into a Go function variable, call it, and close the handle when the process no longer needs it. The example below mirrors the source project’s libc example while keeping the wrapper boundary explicit.
package native
import (
"fmt"
"runtime"
"github.com/ebitengine/purego"
)
type LibC struct {
handle uintptr
puts func(string)
}
func OpenLibC() (*LibC, error) {
path := ""
switch runtime.GOOS {
case "darwin":
path = "/usr/lib/libSystem.B.dylib"
case "linux":
path = "libc.so.6"
default:
return nil, fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
handle, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_GLOBAL)
if err != nil {
return nil, err
}
lib := &LibC{handle: handle}
purego.RegisterLibFunc(&lib.puts, handle, "puts")
return lib, nil
}
func (l *LibC) Print(line string) {
l.puts(line)
}
func (l *LibC) Close() error {
return purego.Dlclose(l.handle)
}
Production code should not scatter raw symbol registration throughout the application. Put loader paths, symbol binding, error handling, and platform differences behind a small package. The rest of the application should call ordinary Go methods and should not know whether the implementation came from cgo, pure Go, a dynamic library, or a fallback.
Callbacks reverse the direction: C keeps a function pointer that eventually invokes Go code. That requires a callback trampoline and strict rules about how many callbacks exist, whether callback memory can be released, what thread the callback runs on, and what Go runtime operations are safe inside it. Treat callback registration as a long-lived resource, not as an anonymous closure created in a hot path.
A Go service can expose an optional native feature only when a shared object exists on the host. For example, a desktop application might load platform notification APIs, an image pipeline might load a codec, or an inference service might load a vendor runtime. The same binary can still run without the feature by detecting load failure and using a pure Go fallback.
Dynamic loading can provide a plugin boundary when plugins are compiled into shared libraries with a stable C ABI. The Go host resolves a small set of entry points, validates version symbols, and calls through a narrow interface. This works best when data passed across the boundary is simple: integers, pointers to byte buffers, lengths, and explicit status codes.
Game engines and UI frameworks often need platform calls but also value cross-compilation. A cgo-free loader lets the project build for several targets without installing each target’s C compiler locally. The tradeoff is that platform-specific behavior must be tested on the target OS because the compiler is no longer type-checking against native headers.
Keep the foreign call signatures in one package and expose a stable Go interface. That facade should convert Go strings and slices, check result codes, and make ownership visible in method names.
type Decoder interface {
Decode(input []byte) ([]byte, error)
Close() error
}
type nativeDecoder struct {
handle uintptr
decode func(*byte, uintptr, *uintptr) uintptr
free func(uintptr)
}
func (d *nativeDecoder) Decode(input []byte) ([]byte, error) {
if len(input) == 0 {
return nil, nil
}
var outLen uintptr
out := d.decode(&input[0], uintptr(len(input)), &outLen)
if out == 0 {
return nil, fmt.Errorf("native decode failed")
}
defer d.free(out)
return copyFromNative(out, outLen), nil
}
Use build tags or small runtime switch blocks to separate Unix loader paths from Windows loader calls. Do not let Linux-specific library names, macOS framework paths, and Windows DLL behavior mix in a single untested branch. The source project itself documents different behavior and availability across target operating systems.
Write a tiny test shared library for each supported platform and call functions with strings, integers, floats, pointers, structs, and callbacks that match the shapes your production boundary needs. These tests catch incorrect padding, wrong result sizes, calling-convention mismatches, and callback assumptions before a real dependency crashes under load.
Dlsym calls in a hot path add avoidable latency and error handling.GOOS/GOARCH pair as its own compatibility target.