When one CPU isn't enough
You finish a slick command-line tool and run it on your M2 MacBook. It flies. You zip it up and send it to a friend who's still rocking a 2019 Intel iMac. They try to run it and hit a wall: "Bad CPU type in executable." Or you submit to the App Store and get a rejection email pointing out a missing architecture. macOS has two distinct CPU families in active use. Apple Silicon and Intel. A binary built for one won't run on the other. You need a universal binary.
The universal binary concept
A universal binary isn't magic code that runs on everything. It's a container. Inside that container, you pack two completely separate executables: one compiled for x86_64 (Intel) and one for aarch64 (Apple Silicon). When you double-click the file or run it in the terminal, macOS checks your hardware and pulls out the right executable. You don't write two codebases. You compile the same Rust code twice and stitch the results together.
The stitching tool is lipo. It comes pre-installed on macOS. Rust handles the compilation via targets. A target tells the compiler exactly what CPU and operating system you're aiming for. The target names follow the convention arch-vendor-os. For macOS, you'll see x86_64-apple-darwin and aarch64-apple-darwin.
Universal binaries are standard on macOS. The lipo tool has been part of the system since the transition from PowerPC to Intel. Apple reused the same mechanism for the transition to Apple Silicon. The operating system expects this format for distribution.
Minimal build workflow
You can build a universal binary with three commands. First, ensure your Rust toolchain knows how to speak both architectures. Then build each version. Finally, merge them.
# Add both targets to your toolchain.
# This downloads the cross-compilers for Intel and Apple Silicon.
rustup target add x86_64-apple-darwin aarch64-apple-darwin
# Build the Intel version.
# The --target flag forces compilation for x86_64.
cargo build --target x86_64-apple-darwin --release
# Build the Apple Silicon version.
# The --target flag forces compilation for aarch64.
cargo build --target aarch64-apple-darwin --release
# Merge them into a single universal binary.
# lipo reads both inputs and writes a fat binary.
lipo -create \
-output target/universal/release/my_binary \
target/x86_64-apple-darwin/release/my_binary \
target/aarch64-apple-darwin/release/my_binary
Replace my_binary with your actual crate name. If your crate is named my-cli-tool, the binary output will be my-cli-tool. The community convention is to keep the universal binary in a dedicated directory like target/universal. This keeps your build artifacts clean and prevents accidental overwrites of the native build.
You can inspect the result with lipo -info target/universal/release/my_binary. The output lists both architectures if the merge succeeded. If you see only one architecture, the merge failed or you pointed lipo at the wrong files.
What happens under the hood
When you run rustup target add, you're not installing a new Rust. You're downloading specification files and cross-compilers for those architectures. If you're on an Intel Mac, aarch64-apple-darwin pulls in a compiler that generates ARM instructions. If you're on Apple Silicon, x86_64-apple-darwin pulls in a compiler that generates x86 instructions.
cargo build --target tells rustc to use the cross-compiler. The output lands in a subdirectory named after the target. This prevents overwriting your native build. If you run cargo build without --target, you get a binary for your current machine. Running cargo build --target x86_64-apple-darwin puts the result in target/x86_64-apple-darwin/release/.
lipo takes those two files. It reads the Mach-O headers. Mach-O is the executable format on macOS. It creates a new Mach-O file with a "fat" header that points to both payloads. The file size grows, roughly doubling, because you're carrying both versions. The OS loader handles the selection at runtime. You pay the storage cost once. Users get a single file that works everywhere.
Cross-compilation works smoothly on macOS. If you're on Apple Silicon, building for x86_64 uses a cross-compiler. The linker may run under Rosetta 2 emulation, but the compilation itself is fast. If you're on Intel, building for aarch64 uses a pure cross-compiler. No emulation is needed. You can build universal binaries on either platform without special hardware.
Realistic release script
Manual commands work for a quick test. A release script handles errors, cleans artifacts, and makes the process repeatable. This script is safe to commit to your repository.
#!/usr/bin/env bash
# release.sh: Build universal binary for macOS.
# Exit immediately on error.
set -e
BINARY_NAME="my_cli_tool"
TARGETS=("x86_64-apple-darwin" "aarch64-apple-darwin")
OUTPUT_DIR="target/universal/release"
# Ensure targets are installed.
for target in "${TARGETS[@]}"; do
rustup target add "$target"
done
# Clean previous builds to avoid stale artifacts.
# This ensures dependencies are recompiled for the correct target.
cargo clean
# Build each target.
for target in "${TARGETS[@]}"; do
echo "Building for $target..."
cargo build --target "$target" --release
done
# Create output directory.
mkdir -p "$OUTPUT_DIR"
# Merge into universal binary.
lipo -create \
-output "$OUTPUT_DIR/$BINARY_NAME" \
"target/x86_64-apple-darwin/release/$BINARY_NAME" \
"target/aarch64-apple-darwin/release/$BINARY_NAME"
echo "Universal binary ready at $OUTPUT_DIR/$BINARY_NAME"
The set -e flag makes the script stop if any command fails. This prevents creating a broken universal binary if one architecture fails to compile. The loop ensures both targets are installed before building. cargo clean removes old artifacts. This is important because dependencies might have cached builds for the wrong architecture. The mkdir -p command creates the output directory if it doesn't exist.
Convention aside: If your binary name contains hyphens, cargo preserves them. lipo doesn't care about the name. Just ensure the paths in the script match the actual output filenames. You can verify the binary name by checking target/release/ after a native build.
Pitfalls and verification
Universal binaries introduce a few traps. If you forget to add a target, cargo rejects the build with a "no such target" error. If you mistype the path in lipo, you get a "can't open input file" error. These are easy to fix. The harder issues involve dependencies.
If you link against a C library that only exists for one architecture, the linker fails. You'll see an error like ld: library not found for -lfoo. Or worse, it links successfully, but the binary crashes at runtime on the other architecture. Always check that your native dependencies provide universal libraries or build them for both architectures.
Some build.rs scripts assume the host architecture. They might run a command that only works on the current CPU. When you cross-compile, those scripts can fail. Use cargo build --target to force cross-compilation of dependencies. This tells cargo to compile everything for the target, not just your crate. If a dependency has a broken build.rs, you may need to patch it or find an alternative.
Verify your binary before distribution. Run lipo -info to check architectures. Run file to see the format. file my_binary should output "Mach-O universal binary with 2 architectures". Run the binary on both architectures if possible. If you're on Apple Silicon, you can test the x86_64 slice by running arch -x86_64 ./my_binary. This forces Rosetta 2 to load the Intel slice. It's a quick sanity check.
Check your dependencies. A universal binary is only as universal as its linked libraries.
Decision: distribution strategies
Use universal binaries when you distribute a single file to users via GitHub releases or a website. Use universal binaries when submitting to the macOS App Store, which requires support for all supported architectures. Use separate binaries per architecture when you need to minimize download size for users who only need one platform. Use cargo universal or cargo xbuild crates when you want a single command to handle the build and merge without writing shell scripts. Use lipo manually when you need fine-grained control over the merge process or are debugging architecture-specific issues.
Pick the workflow that matches your distribution channel. A single file is convenient for users. Separate files save bandwidth.