How to Build a Shared Library (.so/.dll/.dylib) in Rust

Build a Rust shared library by setting crate-type to cdylib in Cargo.toml and running cargo build --release.

The universal adapter for your Rust code

You've written a blazing-fast image processor in Rust. Your Python data science team wants to use it. Or your C++ game engine needs a physics plugin. Or a JavaScript tool needs to offload heavy crypto work. You can't just import rust_module. You need a shared library. A .so, .dll, or .dylib that the host language can load and call.

Rust makes this straightforward, but the boundary between Rust and the rest of the world is unforgiving. One missing attribute and the linker screams. One wrong type and memory corrupts. Get it right, and your Rust code runs anywhere with zero overhead.

What is a shared library really?

A shared library is a blob of compiled machine code that other programs can load at runtime. It exposes a set of functions that external code can call. The key is the interface. Rust has its own calling convention and name mangling scheme. C has a different one. Most languages (Python, C++, Go, JS via Node-API) expect a C-compatible interface.

Rust provides cdylib for this. The c stands for C. It tells the compiler to produce a shared library that speaks the C ABI. There is also dylib, which produces a shared library for Rust-to-Rust linking. If you are talking to anything outside Rust, you want cdylib.

Convention aside: on Linux and macOS, the output file gets a lib prefix and an extension like .so or .dylib. On Windows, you get mylib.dll. Cargo handles the naming. You just specify the crate type.

The minimal setup

Start with a library crate. Add cdylib to Cargo.toml. Write a function with two attributes: #[no_mangle] and pub extern "C".

// Cargo.toml
[lib]
crate-type = ["cdylib"]
// src/lib.rs
/// Adds two integers. Exposed to C and other languages.
#[no_mangle]
pub extern "C" fn rust_add(a: i32, b: i32) -> i32 {
    a + b
}

Run cargo build --release. The binary appears in target/release.

cargo build --release

That's it for the skeleton. The attributes do the heavy lifting.

Why two attributes?

Rust compilers mangle function names. They encode type information and module paths into the symbol name. A function add in module math might become _ZN4math3add17h1234567890abcdefE. This allows overloading and keeps namespaces clean. C does not mangle names. C expects the symbol to be exactly add.

The #[no_mangle] attribute tells the Rust compiler to skip mangling. The symbol in the library will be exactly rust_add. Without it, the linker in your host program will fail with "undefined reference to rust_add". The compiler won't catch this. The linker will.

The extern "C" attribute sets the ABI. ABI defines how arguments are passed, which registers hold values, and how the stack is managed. Rust's default ABI is optimized for Rust. It might pass structs in registers or reorder arguments. C has a strict rulebook. extern "C" forces Rust to follow the C rulebook. If you skip this, the caller and callee disagree on where to find arguments. You get stack corruption or garbage values. The compiler might not warn you. The crash happens at runtime.

Don't treat extern "C" as optional. It is the handshake protocol. Skip it and the handshake fails.

Passing data across the border

Simple integers and floats cross the boundary safely. Structs require care. Rust reorders struct fields to optimize memory alignment. C does not. If you pass a Rust struct to C, the C code might read the wrong field because the layout differs.

Use #[repr(C)] to force Rust to lay out the struct exactly like C would. This disables Rust's field reordering.

use std::os::raw::c_double;

/// A 2D point with C-compatible memory layout.
#[repr(C)]
pub struct Point {
    pub x: c_double,
    pub y: c_double,
}

/// Calculates the distance between two points.
/// Expects valid pointers to Point structs.
#[no_mangle]
pub extern "C" fn point_distance(p1: *const Point, p2: *const Point) -> c_double {
    // SAFETY: Caller must provide valid, aligned pointers.
    // 1. p1 and p2 must be non-null.
    // 2. Pointers must point to valid Point data.
    // 3. Pointers must remain valid for the duration of this call.
    unsafe {
        let p1 = &*p1;
        let p2 = &*p2;
        let dx = p1.x - p2.x;
        let dy = p1.y - p2.y;
        (dx * dx + dy * dy).sqrt()
    }
}

The function takes raw pointers, not references. Rust references imply ownership and lifetime rules that C cannot satisfy. C passes pointers. You must accept pointers and verify them inside an unsafe block. The // SAFETY comment documents the invariants. The caller promises the pointers are valid. You dereference them. If the caller lies, you get undefined behavior.

Treat the extern "C" boundary as a firewall. Nothing gets through without a passport. References, String, and Vec are not passports. Pointers and primitive types are.

The memory trap: returning data

The hardest part of FFI is returning data. Rust manages memory automatically. When a value goes out of scope, it drops. C has no idea about Rust scopes. If you return a Rust String to C, C gets a pointer to memory that Rust might free the moment the function returns. Or C holds a pointer to memory it cannot free because it doesn't know the Rust allocator.

Never return a String or Vec across FFI. You will leak memory or crash.

The solution is to allocate memory that C can understand, hand the pointer to C, and provide a function for C to free it. Use CString for strings. CString owns a null-terminated buffer. Call into_raw() to leak the buffer and get a raw pointer. C can read it. C must call your free function to reclaim it.

use std::ffi::CString;
use std::os::raw::c_char;

/// Returns a greeting string owned by the caller.
/// The caller must call free_greeting to avoid a leak.
#[no_mangle]
pub extern "C" fn get_greeting() -> *mut c_char {
    // Create a C-compatible string.
    let c_string = CString::new("Hello from Rust!").unwrap();
    // Transfer ownership to the caller. Rust no longer frees this.
    c_string.into_raw()
}

/// Frees a string returned by get_greeting.
/// Pass the pointer back to Rust for cleanup.
#[no_mangle]
pub extern "C" fn free_greeting(ptr: *mut c_char) {
    if ptr.is_null() {
        return;
    }
    // SAFETY: ptr must be a valid pointer returned by get_greeting.
    // 1. ptr must not be null.
    // 2. ptr must not be used after this call.
    // 3. ptr must not be freed twice.
    unsafe {
        // Reconstruct the CString to take ownership back.
        // Dropping the CString frees the memory.
        let _ = CString::from_raw(ptr);
    }
}

This pattern applies to any complex data. Allocate in Rust, return a pointer, export a free function. The caller is responsible for calling free. Document this clearly. If the caller forgets, you leak. If the caller frees twice, you crash.

Convention aside: name your free functions explicitly. free_greeting, destroy_point, release_result. Don't rely on the host language's garbage collector. C has no GC. Python's GC doesn't know how to call Rust drop glue. You must provide the cleanup hook.

Common pitfalls

Forgetting #[no_mangle] is the most common mistake. The code compiles. The library builds. The host program fails to link. The error is a linker error, not a compiler error. Check the symbol table if you're stuck. On Linux, run nm -D libmylib.so | grep rust_add. If you see a mangled name, you forgot no_mangle.

Forgetting extern "C" is subtler. On some platforms, the ABI matches by accident. On others, it doesn't. The compiler might reject a mismatch with E0308 if types are involved, but often it just lets it slide. Test on multiple platforms. Don't assume it works because it worked on your laptop.

Dereferencing raw pointers without unsafe triggers E0133 (dereference of raw pointer requires unsafe). The compiler forces you to acknowledge the risk. Wrap the dereference in unsafe { ... } and write the safety proof.

Returning Rust types like Result or Option breaks the ABI. C doesn't know these types. Encode errors as return codes or use a struct with an error field.

Don't fight the compiler here. Reach for repr(C) and extern "C" early. They save hours of debugging.

Choosing your crate type

Rust offers several crate types. Pick the right one for your goal.

Use cdylib when you're building a shared library for C, Python, C++, or any language that expects a C ABI. This is the standard for plugins and extensions.

Use dylib when you're making a Rust crate that other Rust crates depend on. This produces a shared library that links via Rust's metadata. Other Rust code can use your crate normally.

Use staticlib when you want a static archive (.a or .lib) to be bundled into the final executable. The host program links against it at build time. No runtime loading.

Use rlib when you're building a standard library crate for Rust dependencies. This is the default for cargo new --lib. It produces a Rust library archive with metadata.

Use proc-macro when you're writing a procedural macro crate. This is a special case for compile-time code generation.

Reach for cdylib for FFI. Reach for dylib for Rust shared dependencies. Reach for staticlib for bundling. The choice determines how the host consumes your code.

Where to go next