How to create bindings with bindgen

Generate Rust FFI bindings from C headers using bindgen, either as a one-off CLI tool or as a Cargo build dependency, then wrap the unsafe output in safe Rust.

When you need to call into C from Rust

You've found a C library you want to use. Maybe it's libsqlite3, maybe it's a vendor SDK for some camera, maybe it's a numerical library your team has been using for fifteen years. The functions are documented in a header file: a wall of typedefs, struct definitions, and function prototypes. You want to call them from Rust without rewriting the whole thing.

You could read every prototype and write the Rust extern "C" declaration by hand. People did that for years. It's tedious, easy to get wrong, and impossible to keep in sync when the C library updates. The function int foo(unsigned long *p) becomes the Rust unsafe extern "C" fn foo(p: *mut c_ulong) -> c_int, but you have to know that, and you have to write it correctly for every single function.

bindgen does the boring part. You point it at a C header, it parses the header (using the actual Clang compiler, so it understands real C, not a toy subset), and it emits a Rust file full of equivalent declarations. You then build a safe wrapper on top.

How bindgen actually works

There's a single library underneath: libclang. When you run bindgen, it asks Clang to parse your header just as it would parse it for a C build. Clang gives back an AST. Bindgen walks that AST and translates each declaration: struct Foo becomes #[repr(C)] struct Foo { ... }, int main(void) becomes extern "C" fn main() -> c_int, #define PI 3.14 becomes pub const PI: f64 = 3.14;.

Because it uses real Clang, anything Clang understands, bindgen handles. Conditional compilation with #ifdef, included headers via #include, even some macros. The cost is that you need libclang installed on the build machine.

The two ways to use bindgen

Option 1: as a CLI tool, generated once, checked in. You install bindgen with cargo install bindgen-cli, run it on your header, and commit the output bindings.rs to your repo. Simple, reproducible, but stale: if the C library updates, you have to regenerate.

# Install the command-line tool once.
cargo install bindgen-cli

# Generate Rust bindings from a header. Output goes to bindings.rs.
bindgen path/to/header.h -o bindings.rs

# The flags below are common. --allowlist-function limits output to functions
# whose names match the regex, so you don't get a 30,000-line file.
bindgen wrapper.h \
    --allowlist-function 'sqlite3_.*' \
    --allowlist-type 'sqlite3.*' \
    -o src/bindings.rs

Option 2: as a build dependency, regenerated on every build. This is the standard pattern for *-sys crates on crates.io. You add bindgen to [build-dependencies] and write a build.rs that runs it. The bindings get regenerated whenever you build, so they always match the headers Cargo found.

# Cargo.toml
[build-dependencies]
bindgen = "0.69"
// build.rs runs before your crate compiles.
use std::env;
use std::path::PathBuf;

fn main() {
    // Re-run this build script if wrapper.h changes. Without this, Cargo
    // would never rebuild the bindings even if the header was edited.
    println!("cargo:rerun-if-changed=wrapper.h");

    // Tell the linker to link against the system library.
    // For libfoo.so / foo.lib, the link name is "foo".
    println!("cargo:rustc-link-lib=foo");

    let bindings = bindgen::Builder::default()
        // wrapper.h is a tiny file that just #include's the real headers.
        // Keeping it small makes it easy to control what bindgen sees.
        .header("wrapper.h")
        // Don't generate bindings for everything libc has ever included;
        // restrict to the symbols this crate actually wraps.
        .allowlist_function("foo_.*")
        .allowlist_type("foo_.*")
        // Tell Cargo to invalidate when included headers change.
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()))
        .generate()
        .expect("bindgen failed to generate bindings");

    // Cargo gives every build script an OUT_DIR to drop generated files.
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("could not write bindings");
}

Then in your Rust source:

// Pull in the generated file. include! pastes its contents at this point.
// The allow attributes silence the warnings bindgen emits about C-style names.
#[allow(non_upper_case_globals, non_camel_case_types, non_snake_case, dead_code)]
mod ffi {
    include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
}

fn main() {
    unsafe {
        // Direct call into the C function. Note the `unsafe` block: anything
        // crossing the FFI boundary is unsafe by definition.
        let value = ffi::foo_compute(42);
        println!("{value}");
    }
}

What the wrapper.h file is for

You'll see wrapper.h in nearly every example. It's just a small C file that does the includes for you:

// wrapper.h
#include <stdio.h>
#include <foo/foo.h>

Why bother? Because letting bindgen process every header in /usr/include directly produces an enormous, slow-to-compile, mostly-useless bindings file. Pointing bindgen at wrapper.h lets you control exactly which headers come in. You can also do per-project preprocessor work in there, like #define FOO_INTERNAL_API 1 before including the header.

Reading the generated output

Open bindings.rs after a successful run. You'll see Rust definitions like:

extern "C" {
    pub fn foo_compute(input: ::std::os::raw::c_int) -> ::std::os::raw::c_int;
}

#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct foo_config {
    pub timeout: ::std::os::raw::c_int,
    pub buffer: *mut ::std::os::raw::c_char,
}

A few things worth noticing. extern "C" says these functions follow the C ABI: parameters in registers in the conventional order, no name mangling. #[repr(C)] on the struct guarantees the field layout matches what C's compiler produces. The integer types come from std::os::raw (or you can ask bindgen for core::ffi), so they line up with C's int / long on whatever platform you're building for.

These are raw, unsafe declarations. Calling them is unsafe { foo_compute(...) }. They don't enforce nullability, lifetimes, or thread safety. Your job, on top of bindgen's output, is to wrap them with safe Rust code.

When things go wrong

The two errors you'll hit most often:

thread 'main' panicked at 'Unable to find libclang: ...'

Bindgen needs libclang at runtime. On Debian/Ubuntu, apt install libclang-dev. On macOS with Xcode installed, it's already there. On Windows, install LLVM and set LIBCLANG_PATH.

error: header 'foo/foo.h' not found

Bindgen uses Clang's default include paths plus whatever you give it. Add .clang_arg("-I/path/to/headers") in the builder, or -I to the CLI form, to point at non-standard locations.

Where it fits in the broader picture

bindgen is one half of the FFI story. The other half is hand-writing a safe wrapper crate that takes the raw *-sys bindings and turns them into idiomatic Rust types: replace raw pointers with & references or Box, replace null returns with Option, hide the unsafe from your users. The split-crate convention in the ecosystem (foo-sys for raw bindings, foo for the safe wrapper) exists because of this.

For the reverse direction (calling Rust from C) you don't need bindgen. You write extern "C" functions in Rust and use cbindgen to generate a C header. Bindgen and cbindgen are complementary, not interchangeable.

Where to go next