Integrating Rust and Go with UniFFI: A Complete Guide
Rust and Go are both excellent languages, but they excel in different areas. Rust provides memory safety and zero-cost abstractions, while Go offers simplicity and fast compilation. What if you could combine the strengths of both? This post explores how we use UniFFI to create seamless Rust-Go integrations for the SLIM messaging library.
Why Integrate Rust and Go?
Before diving into the technical details, let’s understand when and why you might want to integrate Rust with Go:
Rust’s Strengths:
- Memory safety without garbage collection
- Zero-cost abstractions
- Excellent cryptography libraries
- Strong type system with powerful error handling
- Performance-critical operations
Go’s Strengths:
- Simple, readable syntax
- Fast compilation times
- Excellent concurrency model
- Great standard library for networking and HTTP
- Easy deployment (single binary)
When to Integrate:
- You have performance-critical code that benefits from Rust’s zero-cost abstractions
- You need cryptographic operations best handled by Rust’s mature ecosystem
- You want Go’s deployment simplicity but need Rust’s safety guarantees
- You’re building a library that needs to support multiple languages
The Challenge: Foreign Function Interface (FFI)
Calling Rust from Go requires crossing the FFI boundary through C. The traditional approach involves:
- Writing a C-compatible API in Rust using
extern "C" - Creating C header files manually
- Writing Go code with CGO to call the C functions
- Manual memory management at the boundary
- Custom error handling and type conversions
This is tedious, error-prone, and requires maintaining three separate codebases (Rust, C headers, and Go bindings).
Enter UniFFI: Automated Binding Generation
UniFFI (Unified Foreign Function Interface) is Mozilla’s solution to this problem. Instead of manually writing FFI code, you:
- Write your Rust library with normal Rust types
- Define an interface using UniFFI’s macro system
- UniFFI automatically generates:
- C-compatible FFI layer
- C header files
- Language bindings (Go, Python, Swift, Kotlin, etc.)
For Go specifically, we use uniffi-bindgen-go, a Go binding generator maintained by NordSecurity.
Our Implementation: SLIM Go Bindings
Let’s walk through how we integrated Rust and Go for the SLIM messaging library.
Step 1: Define the Rust API
First, we write our Rust library with normal Rust types and use UniFFI macros to expose the API:
// src/lib.rs
use uniffi;
#[uniffi::export]
pub fn create_identity(name: String) -> Result<Identity, SlimError> {
// Implementation using normal Rust code
Identity::new(name)
}
#[derive(uniffi::Object)]
pub struct Identity {
inner: Arc<IdentityInner>,
}
#[uniffi::export]
impl Identity {
pub fn public_key(&self) -> Vec<u8> {
self.inner.public_key.clone()
}
pub fn sign(&self, data: Vec<u8>) -> Result<Vec<u8>, SlimError> {
self.inner.sign(&data)
}
}
#[derive(uniffi::Error)]
pub enum SlimError {
InvalidInput { message: String },
CryptoError { message: String },
}
Key Points:
#[uniffi::export]marks functions for export#[derive(uniffi::Object)]for complex types that need methods#[derive(uniffi::Error)]for error enums- Use standard Rust types like
String,Vec<u8>,Result - UniFFI reads the Rust macros directly—no separate
.udlfile needed!
Step 2: Configure the Rust Crate to Use UniFFI
Add UniFFI to your Cargo.toml with the CLI feature:
[lib]
crate-type = ["staticlib"]
name = "slim_bindings"
[dependencies]
uniffi = { version = "0.28.3", features = ["cli"] }
[dev-dependencies]
uniffi = { version = "0.28.3", features = ["build"] }
Important: We use crate-type = ["staticlib"] to produce a static library (.a archive) rather than a dynamic library. This allows the Go compiler to statically link the native code into the final binary, ensuring there are no external runtime dependencies.
Then add the scaffolding macro to your lib.rs:
// src/lib.rs
uniffi::setup_scaffolding!();
The setup_scaffolding!() macro generates all the FFI glue code at compile time—no build script needed when using proc-macros!
Step 3: Build the Rust Library
Compile the Rust library to produce the static archive:
# Build in release mode for optimal performance
cargo build --release
This produces target/release/libslim_bindings.a - a static library archive that can be linked directly into Go binaries. Static linking means:
- At build time: The Go compiler embeds the native code into your binary
- At runtime: No external
.so,.dylib, or.dllfiles are needed - For end users: Just distribute a single, self-contained executable
Step 4: Generate Go Bindings
Use uniffi-bindgen-go to generate the Go code:
# Install uniffi-bindgen-go
cargo install uniffi-bindgen-go
# Generate Go bindings
uniffi-bindgen-go \
--library target/release/libslim_bindings.a \
--out-dir ./bindings/go
UniFFI reads the macros directly from your Rust code to generate the bindings—no separate definition file required!
This generates two files:
slim_bindings.go- Go wrapper codeslim_bindings.h- C header file
Step 5: Distribution Strategy
Now that you have the static library (.a file) and Go bindings, you need to distribute them to developers. This presents a challenge:
- The static library is ~150 MB per platform
- GitHub has a 100 MB file size limit (without Git LFS)
go getdoesn’t support Git LFSgo gethas a hard-coded 500 MB size limit
Solution: Use GitHub Releases to host the pre-compiled static libraries, and provide a setup tool that downloads the appropriate library for the developer’s platform.
We cover this approach in detail in our blog post: Distributing C Artifacts for Go Modules
Key points:
- Host static libraries on GitHub Releases (not in the Git repository)
- Provide a lightweight setup tool developers run once
- Cache libraries in
$GOPATH/.cgo-cache/for CGO to find - Configure CGO linker flags to reference the cached library
Step 6: Using the Go Bindings
The generated Go code provides an idiomatic Go API. Here’s a complete example showing SLIM message encryption:
package main
import (
"fmt"
slim "github.com/agntcy/slim-bindings-go"
)
func main() {
// Create two identities
alice, err := slim.CreateIdentity("Alice")
if err != nil {
panic(err)
}
defer alice.Destroy() // Important: cleanup Rust resources
bob, err := slim.CreateIdentity("Bob")
if err != nil {
panic(err)
}
defer bob.Destroy()
// Get public keys
alicePubKey := alice.PublicKey()
bobPubKey := bob.PublicKey()
fmt.Printf("Alice public key: %x\n", alicePubKey)
fmt.Printf("Bob public key: %x\n", bobPubKey)
// Alice creates an encrypted message for Bob
message := []byte("Secret message")
encrypted, err := alice.Encrypt(message, bobPubKey)
if err != nil {
panic(err)
}
// Bob decrypts the message
decrypted, err := bob.Decrypt(encrypted, alicePubKey)
if err != nil {
panic(err)
}
fmt.Printf("Original: %s\n", message)
fmt.Printf("Decrypted: %s\n", decrypted)
}
Key Features:
- Idiomatic Go error handling with the
errorinterface - Automatic type conversions (Rust
Vec<u8>↔ Go[]byte) - Memory management via
Destroy()method—call this to free Rust resources - High-level API that hides all FFI complexity
This high-level Go code is calling Rust crypto functions under the hood, with all the FFI complexity handled by UniFFI.
How UniFFI Works: Under the Hood
Let’s understand what UniFFI generates:
The Rust Side (Generated by UniFFI)
UniFFI generates a C-compatible FFI layer:
// Generated by UniFFI
#[no_mangle]
pub extern "C" fn slim_create_identity(
name: RustBuffer,
err: &mut RustCallStatus,
) -> *const Identity {
// Conversion logic from C types to Rust types
// Calls your actual Rust function
// Converts result back to C-compatible types
}
The Go Side (Generated by uniffi-bindgen-go)
The Go bindings wrap the C functions:
// Generated by uniffi-bindgen-go
// #include "slim_bindings.h"
// #cgo LDFLAGS: -lslim_bindings
import "C"
type Identity struct {
pointer unsafe.Pointer
}
func CreateIdentity(name string) (*Identity, error) {
// Convert Go string to C string
cName := C.CString(name)
defer C.free(unsafe.Pointer(cName))
// Call C function
var err C.RustCallStatus
ptr := C.slim_create_identity(cName, &err)
// Handle errors
if err.code != 0 {
return nil, convertError(err)
}
return &Identity{pointer: ptr}, nil
}
func (i *Identity) Destroy() {
C.slim_identity_free(i.pointer)
}
Memory Management
UniFFI handles the complex memory management at the boundary:
- Rust → Go: Rust allocates, Go receives pointer, Go calls
Destroy()to free - Go → Rust: Go data is copied into Rust-owned memory
- Return values: Rust allocates, transfers ownership to Go
The generated Destroy() method is crucial—it tells Rust to free its memory. Without it, you’d have memory leaks.
Advantages of the UniFFI Approach
- Type Safety: UniFFI generates type-safe bindings automatically
- Error Handling: Rust
Resulttypes map to Goerrornaturally - Multiple Languages: Same Rust code can target Go, Python, Swift, Kotlin
- Maintainability: Changes to Rust API automatically reflect in bindings
- Idiomatic APIs: Generated code feels natural in each target language
- Memory Safety: Automatic handling of ownership and lifetimes
Challenges and Limitations
- Learning Curve: Understanding UniFFI’s macro system takes time
- Build Complexity: Requires cargo, uniffi-bindgen-go, and CGO toolchain
- Memory Management: Developers must remember to call
Destroy() - Type Limitations: Not all Rust types can cross FFI boundary (e.g., traits, complex generics)
- Debugging: Stack traces cross language boundaries
- Binary Size: Static linking increases final binary size
Special Cases
Native Async Functions
UniFFI supports exposing Rust async functions through the FFI interface. The key thing to understand is that these async functions use the binding language’s async runtime, not Rust’s:
#[uniffi::export]
pub async fn fetch_data(url: String) -> Result<Vec<u8>, SlimError> {
// This async function will be translated to the target language
perform_http_request(url).await
}
How it translates across languages:
- Python: Directly becomes an awaitable function that integrates with Python’s
asynciodata = await fetch_data("https://example.com") - Go: Becomes a regular blocking function (Go doesn’t have native async/await)
data, err := FetchData("https://example.com") // Blocks until complete // To make it non-blocking, wrap it in a goroutine go func() { data, err := FetchData("https://example.com") // Handle result... }()
Important Note About Futures:
Rust Future types don’t translate well to Go. If you need to expose a custom future, you must provide a .Wait() method that handles the awaiting internally:
#[derive(uniffi::Object)]
pub struct DataFuture {
inner: /* your future implementation */
}
#[uniffi::export]
impl DataFuture {
// Provide an async wait method that UniFFI will translate
pub async fn wait(&self) -> Result<Vec<u8>, SlimError> {
self.inner.await
}
}
Then in Go:
future := CreateDataFuture(url)
defer future.Destroy()
result, err := future.Wait() // Blocks until complete
Tokio and External Async Runtimes
If your Rust code uses an external async runtime like Tokio, you need to handle it differently. The runtime must live on the Rust side of the boundary, and you need to explicitly spawn or block on tasks:
use tokio::runtime::Runtime;
use std::sync::Arc;
#[derive(uniffi::Object)]
pub struct AsyncClient {
runtime: Arc<Runtime>,
// other fields...
}
#[uniffi::export]
impl AsyncClient {
#[uniffi::constructor]
pub fn new() -> Result<Arc<Self>, SlimError> {
let runtime = Runtime::new()
.map_err(|e| SlimError::RuntimeError {
message: e.to_string()
})?;
Ok(Arc::new(Self {
runtime: Arc::new(runtime),
}))
}
// Blocking operation that uses the Tokio runtime
pub fn fetch_data(&self, url: String) -> Result<Vec<u8>, SlimError> {
// Use block_on to await the async operation
self.runtime.block_on(async {
perform_async_request(&url).await
})
}
// Fire-and-forget background task
pub fn start_background_task(&self, interval_ms: u64) -> Result<(), SlimError> {
let runtime = self.runtime.clone();
// Spawn a task on the Tokio runtime
runtime.spawn(async move {
loop {
tokio::time::sleep(
tokio::time::Duration::from_millis(interval_ms)
).await;
// Do background work...
}
});
Ok(())
}
}
Key points for Tokio integration:
- Create the runtime once: Store a reference to the Tokio runtime in your Rust object
- Use
block_on: For synchronous operations that need to await async code - Use
spawn: For background tasks that shouldn’t block the caller - Runtime lifecycle: The runtime lives as long as your Rust object exists
- Go integration: From Go’s perspective, these are just regular blocking functions
Example usage in Go:
client, err := NewAsyncClient()
if err != nil {
panic(err)
}
defer client.Destroy()
// This blocks until the async operation completes
data, err := client.FetchData("https://example.com")
// This starts a background task and returns immediately
err = client.StartBackgroundTask(1000)
The Tokio runtime handles all the async execution internally, while Go sees only synchronous function calls.
Performance Considerations
Crossing the FFI boundary through CGO has overhead. While the exact performance impact depends on your use case, there are some general guidelines:
Key Considerations:
- Function call overhead: Each FFI call has a cost beyond a regular function call
- Data copying: Data must be copied across the language boundary
- Avoid chatty APIs: Design your API to minimize the number of FFI calls
Optimization tips:
- Batch operations when possible instead of making many small calls
- Keep data structures simple at the boundary
- Use byte slices (
Vec<u8>) for efficient data transfer - Do heavy processing on one side of the boundary, not back-and-forth
For a detailed analysis of CGO performance overhead, see Shane O’Neill’s excellent post: CGO Performance in Go 1.21
Build and Distribution
For production use, you’ll need to:
- Cross-compile Rust libraries for target platforms
- Distribute static libraries (see our previous blog post)
- Configure CGO to link against the Rust library
- Handle platform-specific differences
We cover this in detail in our blog post about distributing C artifacts for Go modules.
Automation and CI/CD
Our release process automates the entire workflow for building Rust libraries, generating Go bindings, and distributing them across platforms. You can see our complete CI/CD setup in the SLIM repository’s release-bindings.yaml workflow.
This workflow handles:
- Cross-compilation for multiple platforms using Zig
- UniFFI binding generation
- Packaging and uploading to GitHub Releases
- Version tagging and coordination between repositories
Conclusion
UniFFI provides a powerful way to integrate Rust and Go, combining Rust’s safety and performance with Go’s simplicity and deployment model. While there’s complexity in the build process and memory management, the type safety and automated binding generation make it far superior to manual FFI code.
For the SLIM project, this approach allows us to:
- Write cryptographic code once in Rust
- Expose it to Go developers with an idiomatic API
- Maintain type safety across the language boundary
- Support multiple languages from the same Rust codebase
If you’re building performance-critical libraries or need Rust’s safety guarantees in your Go projects, UniFFI is worth the investment.
Additional Resources
- UniFFI Documentation
- uniffi-bindgen-go
- SLIM Go Bindings Repository
- SLIM Main Repository
- CGO Documentation
Have questions about Rust-Go integration or UniFFI? Join our Slack community or check out our GitHub.