You have a Go service that’s doing something CPU-bound — image processing, crypto, tight numerical work — and it’s hitting a wall. Goroutines help with concurrency, but they can’t save you from a hot loop that’s just slow. Meanwhile you’ve got Rust, which will happily eat that workload and ask for more. The obvious move: write the hot path in Rust and call it from Go.
The concept is simple. The execution is not. cgo works, but it has a reputation for being a footgun in a category of its own, and FFI (Foreign Function Interface) adds a second layer of sharp edges on top. This article is a walkthrough of the whole pipeline — from building a Rust shared library to calling it safely from Go — with every gotcha I’ve hit documented so you don’t repeat the same mistakes.
The official Rust FFI docs live at https://doc.rust-lang.org/nomicon/ffi.html. Read them, then come back here for the parts they gloss over.
Why Not Just Use a Subprocess or gRPC?
Fair question. If your Rust workload is coarse-grained (you call it once every few hundred milliseconds), a subprocess or a local gRPC/Unix socket is cleaner. No cgo, no shared memory, no FFI headaches.
But if you need microsecond-range latency, need to pass large buffers without serialization overhead, or need to share memory between the two runtimes, you need the shared library route. That’s when cgo earns its keep.
The Setup
We’ll build a minimal but realistic example: a Rust library that exposes a function to compute a SHA-256 hash of a byte slice, and a Go caller that uses it. The reason for this exact example: it touches byte buffer passing, length handling, return types, and memory ownership — all the interesting edge cases in a small surface area.
Requirements:
- Rust 1.70+ (
rustup toolchain install stable) - Go 1.21+
- A Linux host (the linker flags differ on macOS/Windows; I’ll call those out)
- GCC or Clang (cgo needs a C compiler)
Part 1: The Rust Library
Create a new library crate:
cargo new --lib rustlib
cd rustlib
Edit Cargo.toml to produce a C-compatible dynamic library:
[package]
name = "rustlib"
version = "0.1.0"
edition = "2021"
[lib]
# cdylib = C-compatible dynamic library (.so on Linux, .dylib on macOS, .dll on Windows)
crate-type = ["cdylib"]
[dependencies]
sha2 = "0.10"
hex = "0.4"
Now src/lib.rs. Every function you want to export must be #[no_mangle] and extern "C":
use sha2::{Digest, Sha256};
use std::slice;
/// Compute SHA-256 of `input` (len bytes) and write the hex result into `out_buf`.
/// `out_buf` must point to at least 65 bytes (64 hex chars + null terminator).
/// Returns 0 on success, non-zero on error.
///
/// SAFETY: caller is responsible for valid pointers and correct lengths.
#[no_mangle]
pub extern "C" fn sha256_hex(
input: *const u8,
input_len: usize,
out_buf: *mut u8,
out_buf_len: usize,
) -> i32 {
// Validate pointers — never trust the caller
if input.is_null() || out_buf.is_null() {
return -1;
}
if out_buf_len < 65 {
return -2; // buffer too small
}
// SAFETY: Go guarantees input points to a valid slice for the call duration
let data = unsafe { slice::from_raw_parts(input, input_len) };
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
let hex_str = hex::encode(result);
// Copy result into caller-provided buffer
let hex_bytes = hex_str.as_bytes();
unsafe {
std::ptr::copy_nonoverlapping(hex_bytes.as_ptr(), out_buf, hex_bytes.len());
// Null-terminate
*out_buf.add(hex_bytes.len()) = 0;
}
0
}
/// Free a string that was allocated by Rust (see Part 2 for the pattern).
/// Only call this on pointers returned by Rust allocation functions.
#[no_mangle]
pub extern "C" fn rust_free_string(ptr: *mut i8) {
if ptr.is_null() {
return;
}
// Reconstruct the CString and let it drop, freeing the memory
unsafe {
drop(std::ffi::CString::from_raw(ptr));
}
}
Build it:
cargo build --release
# Output: target/release/librustlib.so (Linux) or librustlib.dylib (macOS)
Part 2: The C Header
cgo needs a C header to understand the function signatures. Write rustlib.h:
#ifndef RUSTLIB_H
#define RUSTLIB_H
#include <stdint.h>
#include <stddef.h>
// Compute SHA-256 hex digest. Returns 0 on success.
int sha256_hex(const uint8_t *input, size_t input_len,
uint8_t *out_buf, size_t out_buf_len);
// Free a string allocated by Rust
void rust_free_string(char *ptr);
#endif
Copy (or symlink) both the .so and the header somewhere accessible to your Go module:
cp target/release/librustlib.so ../goapp/lib/
cp rustlib.h ../goapp/lib/
Part 3: The Go Caller
goapp/
├── lib/
│ ├── librustlib.so
│ └── rustlib.h
├── main.go
└── go.mod
main.go:
package main
/*
#cgo LDFLAGS: -L${SRCDIR}/lib -lrustlib -Wl,-rpath,${SRCDIR}/lib
#cgo CFLAGS: -I${SRCDIR}/lib
#include "rustlib.h"
#include <stdlib.h>
*/
import "C"
import (
"fmt"
"unsafe"
)
// SHA256Hex calls Rust to compute the SHA-256 hex digest of data.
func SHA256Hex(data []byte) (string, error) {
if len(data) == 0 {
return "", fmt.Errorf("empty input")
}
// Allocate a 65-byte output buffer on the C heap.
// Do NOT use a Go slice here — the Go GC can move it.
outBuf := (*C.uint8_t)(C.malloc(65))
if outBuf == nil {
return "", fmt.Errorf("malloc failed")
}
defer C.free(unsafe.Pointer(outBuf))
// unsafe.Pointer(&data[0]) pins the slice for the duration of the cgo call.
// Go guarantees the pointer stays valid across a single cgo call.
ret := C.sha256_hex(
(*C.uint8_t)(unsafe.Pointer(&data[0])),
C.size_t(len(data)),
outBuf,
65,
)
if ret != 0 {
return "", fmt.Errorf("rust sha256_hex returned error code %d", ret)
}
// C.GoString converts the null-terminated C string to a Go string (copies the bytes)
return C.GoString((*C.char)(unsafe.Pointer(outBuf))), nil
}
func main() {
msg := []byte("hello from go")
hash, err := SHA256Hex(msg)
if err != nil {
panic(err)
}
fmt.Printf("SHA256(%q) = %s\n", msg, hash)
}
Run it:
cd goapp
go run main.go
# SHA256("hello from go") = 3b535b7a...
The Gotchas — This Is Where It Gets Interesting
1. The Go GC and Pointer Pinning
The Go GC is a moving collector. Between Go 1.17 and now it doesn’t actually move objects (it’s not a compacting GC yet), but the spec does not guarantee this. If you pass a pointer to Go-managed memory across the cgo boundary and hold it in Rust for longer than the call, you’re in undefined territory.
The rule: Go pointers passed to C/Rust are only valid for the duration of the single cgo call. If your Rust function stores the pointer, you have a bug that won’t manifest until it does.
For short-lived calls (like our hash function), passing &data[0] is fine. For anything long-lived, copy the data into C-heap memory with C.malloc first and pass that.
2. Passing Slices
Never pass a Go slice header (reflect.SliceHeader) across the boundary. The slice header is three words (pointer, length, capacity), and Rust doesn’t know that structure. Always decompose it into a pointer and a length manually, as shown above.
3. Memory Ownership Must Be Explicit
If Rust allocates memory and returns a pointer to Go, Go can’t free it with C.free. The allocator in Rust’s jemalloc or system allocator and the C allocator used by free() may be different, and on some platforms they absolutely are different. You must call back into Rust to free Rust-allocated memory — hence rust_free_string in the example.
Conversely, don’t call drop or any Rust destructor on memory that Go allocated and passed in. One side allocates, the same side frees. Always.
4. Panics Don’t Cross the FFI Boundary
A Rust panic caught inside extern "C" function is undefined behavior. Before Rust 1.73, it would unwind across the C ABI and corrupt the Go runtime’s stack. Since Rust 1.73, the default for extern "C" is panic = abort — it terminates the whole process. Neither outcome is what you want.
The fix: wrap your Rust logic in std::panic::catch_unwind and return an error code instead:
use std::panic;
#[no_mangle]
pub extern "C" fn sha256_hex(
input: *const u8,
input_len: usize,
out_buf: *mut u8,
out_buf_len: usize,
) -> i32 {
let result = panic::catch_unwind(|| {
// ... actual logic here ...
0i32
});
match result {
Ok(code) => code,
Err(_) => -99, // panic caught, return sentinel error code
}
}
5. cgo Call Overhead Is Real
Every cgo call has overhead: roughly 60-200ns on modern hardware. That’s because Go has to switch the goroutine’s stack to a C-compatible one. If you’re calling a Rust function in a tight inner loop millions of times per second, this adds up fast.
The fix: batch your work. Don’t call Rust per-element; pass arrays of elements and let Rust loop internally. This is the same principle as minimizing syscalls.
6. Thread Local Storage and Rust’s Runtime
Rust has no runtime in the traditional sense, but it does use thread-local storage (TLS) for some internals (std::thread::LocalKey). The Go runtime schedules goroutines across OS threads (GOMAXPROCS threads). If your Rust library stores per-thread state in TLS, and Go moves the goroutine to a different OS thread between calls, you’ll get the wrong TLS slot.
Lock OS thread to goroutine with runtime.LockOSThread() when you need consistent TLS:
import "runtime"
func callStatefulRust() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
// cgo calls here
}
7. rpath vs LD_LIBRARY_PATH in Production
In the example we used -Wl,-rpath,${SRCDIR}/lib. That embeds the library search path in the binary, which is great during development. In production you want either:
- Copy the
.soto/usr/local/liband runldconfig, or - Set
LD_LIBRARY_PATHin your systemd unit / container environment, or - Statically link the Rust code (see next section)
Embedding a dev path like /home/user/goapp/lib into a production binary is the kind of thing that works fine locally and mysteriously breaks in a Docker container.
8. Symbols Colliding With Other Libraries
If you have multiple Rust .so files loaded in the same process (or a Go binary that links multiple C libraries), you can get symbol collisions if two Rust runtimes try to register the same global allocator or panic handler. The symptom is mysterious segfaults or double-free errors at shutdown.
Mitigation: keep one Rust .so per process if possible, or compile multiple Rust crates into a single .so by making one a workspace that re-exports everything.
Production-Ready Pattern: Static Linking
Dynamic libraries are flexible but fragile to deploy. If your Rust code doesn’t need to be updated independently from the Go binary, just statically link it:
Change Cargo.toml:
[lib]
crate-type = ["staticlib"] # produces librustlib.a
Change the cgo directive in Go:
/*
#cgo LDFLAGS: -L${SRCDIR}/lib -lrustlib -ldl -lpthread -lm
*/
The extra -ldl -lpthread -lm flags are needed because a Rust staticlib pulls in those system libs by default. On Linux you might also need -lgcc_s or -lunwind depending on your distro.
The big win: your Go binary ships as a single file. No .so deployment headaches, no ldconfig, no LD_LIBRARY_PATH in production.
The trade-off: linking time increases, and if the Rust library is large, binary size grows. For most workloads this is the right call anyway.
CGO_ENABLED=0 and Cross-Compilation
Go’s killer feature is dead-simple cross-compilation: GOOS=linux GOARCH=arm64 go build. The moment you introduce cgo, that’s gone.
With CGO_ENABLED=1 (the default when cgo is used), you need a cross-compiling C toolchain for the target architecture. For most teams this means:
- Build inside a Docker container that matches your target platform
- Use
zig ccas a drop-in cross-compiler (surprisingly good —CC="zig cc -target aarch64-linux-musl") - Use
cross(Rust’s cross-compilation tool) to build the Rust side, and a matching C cross-toolchain for cgo
This complexity is real. If you’re targeting multiple architectures, budget time for cross-compilation setup. It’s not a weekend fix — it’s a CI infrastructure project.
A Minimal Docker Build
For reproducible builds, a two-stage Dockerfile that builds both sides:
FROM rust:1.78-slim AS rust-builder
WORKDIR /rustlib
COPY rustlib/ .
RUN cargo build --release
FROM golang:1.22-bookworm AS go-builder
WORKDIR /app
# Copy the Rust .so into the Go build context
COPY --from=rust-builder /rustlib/target/release/librustlib.so lib/
COPY goapp/ .
RUN go build -o /app/server .
FROM debian:bookworm-slim
WORKDIR /app
COPY --from=go-builder /app/server .
COPY --from=rust-builder /rustlib/target/release/librustlib.so lib/
# Tell the dynamic linker where to find our .so
ENV LD_LIBRARY_PATH=/app/lib
CMD ["/app/server"]
If you went the static linking route, the final stage is just:
FROM debian:bookworm-slim
COPY --from=go-builder /app/server /app/server
CMD ["/app/server"]
Much cleaner.
When to Actually Do This
Be honest about the trade-offs before you commit. cgo breaks go test -race (the race detector doesn’t instrument across the cgo boundary), breaks CGO_ENABLED=0 builds, complicates cross-compilation, and makes crash debugging harder because you now have two stack formats.
The cases where it’s worth it:
- The bottleneck is provably CPU-bound and profiling confirms Rust will help
- You’re wrapping an existing battle-tested Rust crate (cryptography, compression, parsing) rather than rewriting Go code
- The interface between Go and Rust is coarse (few calls, large buffers), minimizing cgo overhead
If you’re considering this to optimize a function that gets called 10 times per request in a service doing 100 RPS, the math probably doesn’t work out. Profile first. Always profile first.
Quick Reference: Type Mapping
| Go | C (cgo) | Rust extern "C" |
|---|---|---|
bool |
_Bool |
bool |
int8 |
int8_t |
i8 |
uint8 / byte |
uint8_t |
u8 |
int32 / rune |
int32_t |
i32 |
uint64 |
uint64_t |
u64 |
uintptr |
uintptr_t |
usize |
unsafe.Pointer |
void * |
*mut c_void |
*[N]T |
T * |
*mut T |
string |
(no direct mapping) | (use ptr+len pair) |
[]T |
(no direct mapping) | (use ptr+len pair) |
Go strings and slices have no direct C equivalent. Always decompose them manually.
The Rust–Go FFI isn’t magic, but it’s not a nightmare either if you go in knowing where the traps are. Rust handles the hot path without complaint, Go handles the orchestration and networking it’s good at, and cgo is the bridge you cross as infrequently as possible. Get the ownership semantics right, batch your calls, and static-link if you can — the rest is just engineering.