How to Debug Linking Errors in Rust

Fix Rust linking errors by building dependencies first and passing the correct library path to the test runner.

When the compiler finishes and the linker screams

You run cargo test. The progress bar fills. The terminal prints a wall of red text ending in ld: symbol not found or undefined reference to 'some_function'. You stare at the screen. Your code has no syntax errors. The types match. The borrow checker is happy. Yet the build refuses to produce an executable.

This is a linking error. The Rust compiler finished its job. The linker is the one failing. The compiler translates your code into object files. The linker stitches those object files together into a binary. If the linker cannot find a piece of code or data that your program references, the build dies. The compiler does not check for missing external symbols. It trusts you. The linker does not trust you. It demands proof.

What linking actually does

Rust builds software in two distinct stages. First, the compiler takes your source files and produces object files. An object file is a chunk of machine code with holes in it. Those holes are references to functions, variables, or constants defined in other files or libraries. The compiler fills in the holes it can resolve locally. It leaves the rest as placeholders.

The second stage is linking. The linker collects all the object files and the libraries you depend on. It scans the placeholders and matches them to actual definitions. It patches the machine code with the correct memory addresses. The result is a single executable or a shared library.

Think of it like a contractor building a house. The compiler checks the blueprints for structural integrity. It ensures the walls are straight and the materials are valid. The linker is the crew that actually assembles the house. If the blueprint calls for a custom window that never arrived at the site, the crew stops work. The blueprints are perfect. The house is incomplete. The linker error is the crew shouting that the window is missing.

Minimal example: a missing symbol

The simplest linking error happens when you declare a function that does not exist. Rust allows you to declare external functions using extern. This tells the compiler, "Trust me. This function exists somewhere else." The compiler accepts this and generates a placeholder. The linker later tries to find the real function. If it cannot, the build fails.

// src/main.rs

// Declare a function from an external C library.
// The compiler assumes this exists. It generates a placeholder.
extern "C" {
    fn missing_c_function();
}

fn main() {
    unsafe {
        // This call compiles fine.
        // The linker will look for 'missing_c_function' in the linked libraries.
        missing_c_function();
    }
}

If you run rustc src/main.rs, the compiler produces no errors. The linker runs immediately after and rejects the build:

error: linking with `cc` failed: exit status: 1
  |
  = note: ld: symbol(s) not found for architecture x86_64
  = note: clang: error: linker command failed with exit code 1 (use -v to see invocation)

The error message varies by platform. On Linux, you might see undefined reference to 'missing_c_function'. On macOS, you see symbol(s) not found for architecture. The core message is the same. The linker cannot find the symbol.

The compiler does not assign an E0nnn code to this error. Linker errors come from the external linker tool, not from rustc. You are dealing with ld, clang, or gcc at this point. Treat the error as a message from the assembler, not the compiler.

How cargo hides the complexity

When you use cargo, you rarely see linking errors. Cargo manages the dependency graph. It knows exactly where every crate lives. It passes the correct -L flags to the linker automatically. These flags tell the linker where to search for libraries. Cargo also handles the order of libraries, which matters for some linkers.

If you run cargo build, cargo invokes rustc with dozens of arguments. It points to target/debug/deps, it includes standard library paths, and it links every dependency in the correct order. The linker finds everything. The build succeeds.

Problems arise when you step outside cargo's control. You might be running rustc manually. You might be using a tool that invokes the compiler directly. You might be integrating Rust into a non-Rust build system. In these cases, cargo's magic disappears. You have to provide the paths and flags yourself.

Realistic scenario: mdbook and custom test harnesses

The mdbook tool lets you write documentation with embedded code examples. It can run tests on those examples. However, mdbook does not use cargo test. It invokes rustc directly to compile the examples found in the book. Because it bypasses cargo, it does not know about your crate's dependencies. It does not know where target/debug/deps lives. It cannot find the compiled libraries your examples need.

This is a common pain point for projects that use mdbook to document a library. The book examples import the library. mdbook tries to compile the examples. The linker fails because it cannot find the library.

The fix is to tell mdbook where to look. You must pass the library path explicitly.

# Navigate to the crate directory.
cd packages/trpl

# Build the crate first. This populates the deps directory.
cargo build

# Run mdbook tests with the library path.
# The --library-path flag points mdbook to the compiled dependencies.
mdbook test --library-path packages/trpl/target/debug/deps

The --library-path flag adds a search directory for the linker. Now mdbook can find the object files and static libraries produced by cargo build. The linker resolves the symbols. The tests run.

This pattern applies to any tool that runs rustc without cargo. If a tool complains about missing symbols, check whether it needs a library path. Look for flags like --library-path, -L, or LIBRARY_PATH. Point the tool at target/debug/deps or target/release/deps.

Pitfalls and common traps

Linking errors often stem from environment confusion or FFI mismatches. Here are the traps that catch developers.

Runtime vs build time paths

Beginners often set LD_LIBRARY_PATH to fix a build error. This is wrong. LD_LIBRARY_PATH tells the dynamic linker where to find libraries when the program runs. It does not affect the build. If your build fails, LD_LIBRARY_PATH will not help. You need to configure the build tool to search the correct directories. Use RUSTFLAGS or build scripts for build-time paths. Use LD_LIBRARY_PATH only when the build succeeds but the executable crashes with library not found at runtime.

Name mangling in FFI

Rust mangles function names. It encodes type information into the symbol name. A function named my_func might become _ZN4core3foo17h1234567890abcdefE in the object file. C does not mangle names. If you try to call a C function without specifying the ABI, the linker looks for a mangled name. The C library provides a plain name. The names do not match. The linker fails.

Always use extern "C" when calling C functions. This tells Rust to use the C calling convention and to skip name mangling for those symbols.

// Correct: extern "C" tells rustc to use C linkage.
extern "C" {
    fn c_function();
}

// Wrong: default is extern "Rust". The linker looks for a mangled name.
extern {
    fn c_function();
}

If you are exporting Rust functions to C, you must prevent mangling. Use #[no_mangle].

#[no_mangle]
pub extern "C" fn rust_function_for_c() {
    // ...
}

Without #[no_mangle], the C code cannot find the function. The linker reports an undefined reference.

Static vs dynamic confusion

Some libraries come in static and dynamic forms. A static library (.a on Unix, .lib on Windows) is bundled into your binary. A dynamic library (.so, .dylib, .dll) is loaded at runtime. If you link against a static library, the linker copies the code into your binary. If you link against a dynamic library, the linker records a dependency.

If you accidentally link the wrong variant, you might get errors. For example, if you link a static library that depends on a dynamic library, the linker might complain about missing symbols from the dynamic library. You need to link both. The order matters on some platforms. List the library that depends on another before the library it depends on.

Stale object files

Sometimes the linker complains about symbols that you just added. You added the function. You compiled. The error persists. This happens when cargo or the build tool reuses old object files. The old files do not contain the new symbol. The linker sees the old files and fails.

Run cargo clean to delete the build directory. This forces a full rebuild. All object files are regenerated. The linker sees the new symbols. The build succeeds. Use cargo clean sparingly. It slows down your workflow. Use it only when the build state seems corrupted.

Decision: when to use which tool

Choose the right approach based on your build context.

Use cargo build when you are working inside a standard Rust project. Cargo handles the linker arguments automatically. It resolves dependencies and passes the correct paths. You rarely need to touch linker flags.

Use RUSTFLAGS when you need to pass custom linker flags to every build. Set RUSTFLAGS="-L /custom/lib/path" in your environment or in .cargo/config.toml. This adds a search path for the linker. It applies to all crates in the workspace.

Use LD_LIBRARY_PATH when the build succeeds but the executable crashes at runtime because it cannot find a dynamic library. This variable helps the runtime linker locate shared objects. It does not affect compilation.

Use pkg-config when you are linking to a C library that provides .pc files. The pkg-config tool discovers the correct compiler and linker flags for the library. Use the pkg-config crate in Rust to integrate this into your build script. It handles paths and version checks for you.

Use --library-path when you are running a tool like mdbook that invokes rustc directly. The tool does not know about cargo's dependency resolution. You must point it to the compiled dependencies manually.

Convention asides

The Rust community follows a few conventions around linking. Keep unsafe blocks small when dealing with FFI. Wrap the extern block and the calls in a safe interface. This isolates the linking risk. If the linker fails, the failure is contained. The rest of your code remains safe.

Enable rust-lld for faster builds. The LLVM linker is significantly faster than the system linker. Add this to your .cargo/config.toml:

[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=lld"]

This speeds up linking, especially for large projects. The community considers this a best practice for development.

Use let _ = ... when you call a function that returns a value you intend to ignore during linking checks. This signals to readers that you considered the return value. It prevents warnings and clarifies intent.

Where to go next

The linker is a dumb machine. It does exactly what you tell it. If it fails, you told it the wrong thing. Check your paths. Check your names. Check your flags. Fix the input. The linker will do the rest.