What Is the Difference Between staticlib and cdylib in Rust?

staticlib creates a static archive for direct linking, while cdylib creates a dynamic shared library with a C-compatible ABI for external use.

The library that refuses to load

You've written a blazing-fast image processing algorithm in Rust. Now you need to ship it to a Python data science team, or plug it into a legacy C++ game engine. You run cargo build, get a binary, and realize nobody else can use it. You need a library.

You add crate-type = ["lib"] to your Cargo.toml. Cargo produces libmy_lib.rlib. You hand it to the Python team. Their script crashes with a ModuleNotFoundError. You try C. The linker screams about undefined symbols. You switch to staticlib. You get a .a file. The C project links, but the program segfaults the moment your Rust function tries to print a debug message. You switch to cdylib. Suddenly everything works.

staticlib and cdylib both produce libraries, but they solve completely different problems. staticlib is an archive of object files meant to be baked into a final binary at build time. cdylib is a dynamic shared library with a C-compatible ABI, designed to be loaded at runtime by other languages. Pick the wrong one, and you get linker errors, missing symbols, or runtime crashes.

Static archives versus dynamic bridges

Think of staticlib like a photocopy of a chapter stapled into every book that needs it. The code lives inside the final executable. There is no runtime lookup. The binary is larger, but it is self-contained. If you distribute the executable, you don't need to ship any extra files. The Rust code is fused into the binary during the link step.

cdylib is like a reference binder on a shelf. The code lives in a separate file. When a program needs it, the operating system loads that file into memory and patches the program to point to the functions inside. The final binary is smaller, but it depends on the shared library being present at runtime. This is the standard way to expose Rust to Python, Node.js, C, and WebAssembly.

The distinction matters for two reasons. First, the file format differs. staticlib produces an archive (.a on Linux/macOS, .lib on Windows). cdylib produces a shared object (.so, .dylib, .dll). Second, and more subtly, cdylib initializes the Rust runtime. staticlib does not. If your code relies on static initializers, allocators, or panic handling, staticlib can leave you hanging unless the consuming C program takes extra steps to run Rust's initialization code.

The code that makes C happy

Rust functions have a default ABI that is opaque and unstable. C compilers cannot guess how Rust passes arguments or returns values. You must explicitly opt into the C calling convention. You also need to prevent Rust from mangling the function name.

// lib.rs

/// Adds two integers and returns the sum.
///
/// This function uses the C calling convention and exposes a stable name
/// so external languages can call it reliably.
#[no_mangle]
pub extern "C" fn add(a: i32, b: i32) -> i32 {
    a + b
}

The extern "C" attribute tells the compiler to use C rules for the stack frame, register usage, and name decoration. Without it, the compiler might pass arguments in registers that C doesn't expect, or return a struct in a hidden pointer, causing stack corruption.

The #[no_mangle] attribute stops the compiler from encoding type information into the symbol name. Rust normally turns add into something like _ZN4my_lib3add17h8f9a2b3c4d5e6f7gE. C linkers don't understand that encoding. They look for add. If you forget #[no_mangle], the linker rejects you with an "undefined symbol" error because the name in the object file doesn't match what C expects.

Convention aside: the community treats #[no_mangle] and extern "C" as a pair for FFI. You rarely see one without the other. If you see a function exported for C without extern "C", assume it's a bug waiting to happen.

What happens under the hood

When you build with staticlib, the compiler produces object files and archives them. No dynamic loader interaction occurs. The archive is just a bag of compiled code. The final executable links against it, and the linker merges the object files into the binary. The Rust runtime code is included only if the final binary links it. If the C side doesn't link the Rust runtime, static variables won't initialize, and println! might crash.

When you build with cdylib, the compiler produces object files and links them into a shared library. The linker adds a symbol table, resolves relocations, and emits initialization sections. The resulting file contains code to run static initializers before any exported function is called. It also handles thread-local storage setup and allocator initialization. This is why cdylib works out of the box for FFI: the shared library takes care of the Rust housekeeping.

Ah-ha reveal: cdylib is not just a different file format. It is a different initialization strategy. staticlib assumes the consumer handles initialization. cdylib handles it itself. This is the hidden reason staticlib often segfaults when used for FFI. The C program calls your function, your function tries to allocate, but the allocator never ran its setup code.

A Python script calls Rust

Here is a realistic scenario. You want to expose a Rust function to Python using ctypes. Python expects a shared library with C symbols. cdylib is the correct choice.

# Cargo.toml
[lib]
name = "my_library"
crate-type = ["cdylib"]
// lib.rs

/// Multiplies two integers.
///
/// Exposed to Python via ctypes.
#[no_mangle]
pub extern "C" fn multiply(a: i32, b: i32) -> i32 {
    a * b
}

Build the library:

cargo build --release
# Output: target/release/libmy_library.so

Python loads it:

import ctypes
import os

# Load the shared library.
# ctypes finds the symbol 'multiply' because of #[no_mangle].
lib = ctypes.CDLL(os.path.join("target", "release", "libmy_library.so"))

# Call the function.
result = lib.multiply(6, 7)
print(result)  # 42

The cdylib crate type ensures the library has the right structure for ctypes to load it. The extern "C" ensures Python passes arguments correctly. The #[no_mangle] ensures Python finds the symbol.

Convention aside: tools like pyo3 or maturin automate this process, but they still produce a cdylib under the hood. Understanding the raw cdylib helps you debug when the high-level tools fail. If pyo3 gives you a symbol lookup error, check your #[no_mangle] and extern "C" usage in any manual FFI helpers.

The traps that catch everyone

Forgetting the C ABI

If you omit extern "C", the compiler uses Rust's default ABI. C callers pass arguments according to C rules. Rust expects Rust rules. The stack gets misaligned. Registers get clobbered. The function returns garbage or crashes. The error is often a segfault deep inside the function, making it hard to trace back to the ABI mismatch.

Name mangling surprises

If you forget #[no_mangle], the symbol name changes. C linkers look for add. The library exports _ZN4my_lib3add17h.... The linker fails with "undefined symbol: add". This error is common when copying Rust code from a binary crate to a library crate. Binary crates don't need #[no_mangle] because the names are internal. Library crates exporting to C do.

Staticlib initialization crashes

If you use staticlib for FFI and your function calls println!, Vec::new, or any code that relies on static initialization, you might get a segfault. The staticlib archive does not emit initialization code. The C linker might not run Rust ctors. The allocator is uninitialized. The program crashes.

Fix this by switching to cdylib. If you must use staticlib, ensure the C build system links the Rust runtime and runs initialization sections. This is fragile and rarely worth the effort. cdylib is the safe path for FFI.

Dylib versus cdylib

Cargo has a dylib crate type. Do not confuse it with cdylib. dylib produces a dynamic library for Rust-to-Rust linking. It uses Rust's ABI. C cannot call it. cdylib produces a dynamic library for C interop. Use cdylib when crossing language boundaries. Use dylib only when you are building a Rust plugin system that loads other Rust crates.

Thread safety and re-entrancy

cdylib functions are shared across threads. If your function uses global mutable state, you need synchronization. staticlib code is baked into the binary, so it shares the same thread context as the host. The rules are the same, but cdylib makes the boundary explicit. Treat every exported function as if it can be called from any thread at any time.

Which crate type wins

Use staticlib when you are building a library that gets linked directly into a final binary by a C build system, and you want the Rust code baked in without runtime dependencies. Use staticlib when the consumer is a C project using cmake or make, and you want to avoid shipping extra files. Use staticlib when you are embedding Rust in a larger C application and the build system handles linking correctly.

Use cdylib when you need to expose functions to C, Python, or other languages via dynamic loading, or when you are building a plugin that gets loaded at runtime. Use cdylib when you are writing bindings for Python, Node.js, or WebAssembly, because these environments expect a shared library with a C-compatible interface. Use cdylib when your Rust code relies on static initializers, allocators, or panic handling, because cdylib initializes the runtime automatically.

Reach for rlib (the default) when you are building a library for other Rust crates to depend on. staticlib and cdylib are for crossing language boundaries. rlib is for Rust-to-Rust dependencies.

Static libraries are archives, not bridges. If you need a bridge, build a cdylib. The #[no_mangle] attribute is your handshake. Without it, C is knocking on a door that doesn't exist. Trust the cdylib initialization. It saves you from runtime crashes that take hours to debug.

Where to go next