Rust Linking With cc Failed — Fix Fast
Fix Rust linking with cc failed: install the C toolchain, add -dev libraries, repair pkg-config paths, and set cross linkers..
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
- ✓Rust installed via rustup with cargo available
- ✓A crate with a -sys dependency (e.g. reqwest) to reproduce
- ✓Linux (or macOS) shell with sudo or Docker access
- linking with cc failed means rustc compiled fine but the system C linker couldn't finish — don't reinstall Rust
- No cc in PATH is the commonest cause on fresh machines: install build-essential (Linux) or Xcode CLT (macOS)
- -sys crates need system headers: libssl-dev / libpq-dev, or point with OPENSSL_DIR, or use the vendored feature
- Read the first undefined reference line — those symbol lines are the real error, the cc summary is just the messenger
- Cross builds need the target's linker pinned in .cargo/config.toml, not the host cc
Think of building a Rust program as printing a book: rustc writes every chapter, then a bindery (the cc linker) stitches chapters plus C libraries like OpenSSL into one volume. Linking with cc failed means the bindery couldn't work — its stitching machine is missing (no toolchain), an appendix never arrived (no dev headers), its catalog lost the address (pkg-config), or you ordered a binding it can't do (wrong cross linker). The chapters are fine; fix the bindery.
You run cargo build on a fresh machine and instead of a binary you get an essay ending in error: linking with cc failed: exit status: 1. Above it, hundreds of lines about undefined references, missing libraries, or a cc that was never found. Your code compiled — rustc did its job — and then the final step died.
That final step is the system linker, invoked as cc, stitching your compiled Rust plus any C code (from -sys crates like openssl-sys or pq-sys) into an executable. Rust doesn't ship this toolchain; it borrows your system's. When the toolchain is absent, a library's headers are missing, pkg-config can't find paths, or a cross target needs a different linker, the link fails and cargo reports the messenger: cc.
The error is infamous because the output misdirects. The last line names cc, so teams reinstall Rust — which changes nothing, since rustc was never broken. The real diagnosis sits lines above: cannot find -lssl, undefined reference to PQconnectdb, or simply cc: command not found. Each points at a different layer with a different fix.
This guide teaches the layered read: confirming the toolchain, satisfying -sys crates, fixing pkg-config, handling the undefined-symbol lines as the true error, and configuring cross linkers. You'll stop reinstalling Rust and start fixing environments.
Two Phases: rustc Compiles, cc Links
Cargo splits building into two phases owned by two tools: rustc compiles every Rust crate (plus build-script output) into object files, then the platform linker — invoked as cc — merges those objects with system C libraries into your binary. Pure-Rust crates often link quietly with the default driver, so developers can use Rust for months without knowing cc exists. The moment a dependency includes C code — openssl-sys, pq-sys, sqlite's bundled build, ring's assembly — cc becomes load-bearing.
The error text mirrors the split: error: linking with cc failed: exit status: 1 names the failing phase and the exit code, while the cause hides in the invoked-command dump above (the exact cc command line cargo ran) and the undefined-reference or cannot-find lines it produced. Exit status 1 from ld-family linkers means unresolved inputs, not a crashed tool — the linker ran correctly and reported that pieces are missing.
Reproduce loudly before fixing: cargo build -vv prints the full cc invocation plus environment, and piping to a file preserves it (cargo build -vv 2>&1 | tee /tmp/build.log). That log is the incident artifact — it records compiler paths, library search dirs (-L flags), and linked libs (-l flags) exactly as cargo saw them. Every fix below starts from reading it.
Internalize the mental model: rustc errors are code bugs, cc errors are environment bugs. Reinstalling toolchains at random confuses the two; reading the log respects them.
No cc in PATH: Install the Toolchain
The commonest cc failure on fresh machines is the simplest: no C toolchain installed. Rustup ships rustc, cargo, and standard libraries — not gcc, not glibc headers, not a linker driver. Minimal Docker images (rust:slim, alpine), new laptops, and bare CI runners all lack cc until someone installs it, and every crate with a build script or C dependency fails identically while pure-Rust crates sail through.
Install per platform and verify the same way everywhere: Debian/Ubuntu get sudo apt install build-essential (gcc, make, libc6-dev in one metapackage); Fedora/RHEL get Development Tools via dnf; macOS gets xcode-select --install for the Command Line Tools; Windows gets the MSVC Build Tools with the C++ workload. Then confirm with cc --version and gcc --version — both should print banners, since some build scripts probe gcc directly while cargo invokes cc.
Alpine and musl deserve a callout because they pair a different libc with extra needs: musl-tools plus the musl-gcc wrapper, and -sys crates often requiring vendored builds against musl headers. If your production image is Alpine but your dev machine is glibc Ubuntu, expect link differences and standardize on one libc for builds.
Bake the toolchain into every base: devcontainer Dockerfiles, CI base images, and onboarding scripts should install the compiler set before Rust code is ever fetched. A three-line install in the image saves every future hire the same lost afternoon.
-sys Crates Need -dev Headers
With a toolchain present, the next layer is -sys crates: Rust wrappers around system C libraries (openssl-sys for TLS, pq-sys for Postgres, libsqlite3-sys, libz-sys). Their build scripts probe for headers and libraries at compile time, and the link step needs the actual binaries. Installing only the runtime package (libssl3) leaves headers (ssl.h) and pkg-config files (*.pc) absent — the classic 'library installed but build fails' paradox.
Fix it at the right layer with three options in order. First, install the development package: libssl-dev on Debian/Ubuntu, openssl-devel on Fedora, openssl plus postgres libpq via Homebrew on macOS. Second, point the probe explicitly when libraries live in odd prefixes: OPENSSL_DIR, OPENSSL_LIB_DIR plus OPENSSL_INCLUDE_DIR, or PQ_LIB_DIR for Postgres. Third, sidestep the system entirely with the crate's vendored feature (openssl with vendored, or bundled SQLite), which compiles C from source — slower builds, zero system drift.
After changing anything, force the probe to rerun: cargo clean -p openssl-sys (or the failing -sys crate) then cargo build. Build scripts cache aggressively, so installing headers without cleaning replays the stale failure and convinces teams the fix didn't work. Watch the fresh probe output in -vv logs to confirm it found the new paths.
Record system deps as code: a README section, a build-deps script, or apt lines in the Dockerfile beside Cargo.toml. Unrecorded deps are future incidents wearing invisibility cloaks.
Undefined Symbols Are the Real Error
Undefined-reference output terrifies because of volume: hundreds of lines like undefined reference to SSL_new' or PQconnectdb' scrolling past. But volume is an illusion — one missing library produces one error per symbol it should have provided. The diagnosis is always the first undefined line: it names the missing provider, and everything below is the same absence repeated per symbol.
Map symbol to library with pattern recognition. SSL_, TLS_, BIO_, EVP_ mean OpenSSL (libssl/libcrypto). PQ means Postgres client (libpq). sqlite3_ means SQLite. z_, inflate, deflate mean zlib. mysql_ mean the MySQL client. When unsure, ask the system: nm -D /usr/lib/x86_64-linux-gnu/libssl.so | grep SSL_new confirms a library provides the symbol, and ldd on a working binary shows which provider a healthy link chose.
Two subtleties trap experienced developers. Static-versus-shared mismatches: a -sys crate expecting static libs while only .so files exist (or the reverse behind musl) fails with undefined lines despite files being present — check for the right archive flavor. Versioned symbols: a crate needing OpenSSL 3 APIs against OpenSSL 1.1 headers fails on the new symbols only — openssl version versus the crate's documented minimum resolves it in one comparison.
Treat the symbol lines as the real error and the cc summary as its messenger. Fix the provider, clean the -sys crate, rebuild, and confirm the first symbol resolves — the cascade follows.
pkg-config: Repair the Probe Path
pkg-config is the phone book between build scripts and system libraries: given a name like openssl, it returns the -I include paths and -L/-l link flags from .pc files. When the phone book is missing (pkg-config uninstalled in slim images), out of date, or searching the wrong directories (Homebrew's prefix isn't in the default path), probes fail with library-not-found errors while the library sits installed and innocent.
Diagnose the probe standalone, outside cargo: pkg-config --libs --cflags openssl should print flags; an empty result or Package openssl was not found error reproduces the build failure in one line. Then locate the real .pc file (find /usr /opt/homebrew -name 'openssl.pc') and compare its directory against echo $PKG_CONFIG_PATH. A mismatch is the whole bug — export the corrected path and the probe passes.
macOS Homebrew is the canonical case: brew install openssl puts .pc files under $(brew --prefix openssl)/lib/pkgconfig, which no default PKG_CONFIG_PATH includes. The permanent fix is exporting that path in the shell profile, the project's .envrc, and the CI image together — one layer fixed while others stay broken is how the error survives 'fixes'.
Some crates bypass pkg-config via explicit *_DIR variables (OPENSSL_DIR) or vcpkg on Windows. Those are valid escapes when .pc files are genuinely absent, but prefer repairing the phone book first — DIR overrides hardcode machine-specific paths that rot, while a correct pkg-config setup travels.
Cross Builds Need Their Own Linker
Cross-compilation fails at link whenever the host cc is asked to emit a foreign binary: building on x86_64 for aarch64 with the default linker produces architecture-mismatch or linker-not-found lines after a flawless compile. Rust's rustc handles codegen per target, but the final link needs a cross-capable linker plus target libraries — pieces rustup doesn't install.
Assemble the three parts. First, the std component: rustup target add aarch64-unknown-linux-gnu (or x86_64-unknown-linux-musl for static builds). Second, the linker binary: gcc-aarch64-linux-gnu on Debian/Ubuntu, musl-tools plus musl-gcc for musl targets, or the appropriate LLVM mingw for Windows targets. Third, the pin: a [target.aarch64-unknown-linux-gnu] section in .cargo/config.toml setting linker = "aarch64-linux-gnu-gcc", checked into the repo so every machine and CI runner agrees.
Then chase host-path leakage, the chronic follow-up failure. Build scripts and pkg-config probes run on the host and happily return host include/lib paths that the cross linker can't use. Scope PKG_CONFIG_PATH (or _DIR overrides) per target with TARGET_ prefixed env vars, or run the build inside the documented cross image (rustembedded/cross or a custom builder) where host and target sysroots are separated by construction.
Verify deliberately: file target/debug/app reports the target architecture, and ldd (or its musl/static equivalent) shows the expected linkage. A binary that links but targets the wrong arch is a runtime mystery — check the artifact, not just the exit code.
A Slim Image Bump Dropped the C Toolchain and Broke Every Build
- Slim images are guilty until proven innocent — assert cc --version in CI before any build, not after a failure.
- Blame the environment diff, not the compiler version — Dockerfile history answers faster than changelogs.
- Check system deps into the repo — a packages list beside Cargo.toml survives image bumps that tribal knowledge doesn't.
| File | Command / Code | Purpose |
|---|---|---|
| cargo build -vv 2>&1 | tee /tmp/build.log | Two Phases | |
| sudo apt update && sudo apt install -y build-essential pkg-config | No cc in PATH | |
| src | fn main() { | -sys Crates Need -dev Headers |
| src | use openssl::ssl::{SslConnector, SslMethod}; | Undefined Symbols Are the Real Error |
| pkg-config --libs --cflags openssl || echo "PROBE FAILED" | pkg-config | |
| rustup target add aarch64-unknown-linux-gnu | Cross Builds Need Their Own Linker |
Key takeaways
Common mistakes to avoid
5 patternsReading the panic instead of the linker line
Assuming Rust needs no C toolchain
Installing the runtime library but not the dev headers
Ignoring pkg-config failures and PATH
Cross-compiling with the host linker
Interview Questions on This Topic
What does linking with cc failed actually mean?
Frequently Asked Questions
20+ years shipping production backend systems. Notes here come from systems that actually shipped.
That's Core. Mark it forged?
6 min read · try the examples if you haven't