Fetching External Dependencies
Learn how to create self-contained, reproducible builds by fetching dependencies from source using FetchContent()
and ExternalProject_Add()
.
In the last lesson, we mastered find_package()
, the command that lets our project find and use libraries that are already installed on a developer's machine.
This puts the burden of dependency management on the user, which is not always appropriate. They must find, install, and correctly configure every library your project needs before they can even run cmake
. And, if they don't configure things in the way we expected when we wrote our build script, this can lead to the classic "works on my machine" problem, where builds fail because of subtle environmental differences.
In an ideal world, we want our project to be as self sufficient as possible. Someone can just download our project, run cmake
, and have all the necessary dependencies downloaded and built automatically. This is the goal of fetching dependencies.
In this lesson, we'll explore the two primary tools CMake provides for this: the powerful but complex ExternalProject_Add()
, and its modern, user-friendly wrapper, FetchContent()
.
Installing Git
To use commands like FetchContent
and ExternalProject_Add
, CMake often needs an underlying tool to do the actual downloading. The most common way libraries are shared is through Git repositories, typically hosted on sites like GitHub.
Therefore, for the commands in this lesson to work, you need to have Git installed on your system and available in the environment where you run your cmake
commands.
You may already have it installed, as it comes bundled with many developer toolsets (like Visual Studio or Xcode Command Line Tools) and is a standard utility on most Linux distributions. You can check by opening your terminal and running:
git --version
If you see a version number (e.g., git version 2.45.1
), you're all set. If you get a "command not found" error, you'll need to install it.
Installing Git on Windows
- Official Installer (Recommended): Download and run the installer from the official Git website. During installation, accept the default options, especially the one that adds Git to your system PATH ("Git from the command line and also from 3rd-party software").
- MSYS2 / UCRT64: If you're using this environment, you can install Git with
pacman
:pacman -S git
Installing Git on macOS
- Homebrew (Recommended): The easiest way is with Homebrew:
brew install git
- Xcode Command Line Tools: If you haven't already, running
xcode-select --install
will install a suite of developer tools, including Git.
Installing Git on Linux (Debian/Ubuntu)
Use your distribution's package manager. For Debian-based systems like Ubuntu, the command is:
sudo apt update
sudo apt install git
After installing, be sure to close and reopen your terminal before running git --version
again to confirm the installation was successful.
Our Starting Point
To focus on the new concepts, we'll simplify our Greeter
project back to its core structure, removing the packaging and installation logic from the previous lessons.
Files
Our goal for this lesson will be to add the popular spdlog
library to our GreeterApp
. spdlog
is a logging library, incorporating many common requirements such as automated timestamps, formatting, and message types (info, warning, error, critical, etc) in much the same way as CMake's message()
command.
The Original Method: ExternalProject_Add()
ExternalProject_Add()
is the classic, low-level tool for managing dependencies that are built alongside your project. It's powerful and flexible, capable of handling dependencies that don't even use CMake.
The core concept of ExternalProject_Add()
is that it creates a special custom target. When this target is built, it triggers a series of steps: downloading the source code, configuring it, building it, and optionally installing it.
A key point to note is that all of this happens during the build phase (when you run cmake --build .
), not the configure phase.
A First Attempt with ExternalProject_Add()
Let's try to add spdlog
to our GreeterApp
using this command. We'll add it to app/CMakeLists.txt
.
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
# Include the module that provides the command
include(ExternalProject)
ExternalProject_Add(spdlog_external
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.15.3
CMAKE_ARGS
-DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/spdlog_install
)
add_executable(GreeterApp src/main.cpp)
target_link_libraries(GreeterApp GreeterLib)
Let's break down the ExternalProject_Add()
call:
include(ExternalProject)
: The command is defined in a standard CMake module, so we must include it first.ExternalProject_Add(spdlog_external ...)
: We're defining an external project and giving it the target namespdlog_external
.GIT_REPOSITORY
: The most common source of libraries are Git repositories, usually hosted on GitHub. TheExternalProject_Add()
command specifically supports this. We just need to provide theGIT_REPOSITORY
argument, followed by the URL to the repository. This URL will be provided by the site hosting the repository. In GitHub's case, it's simply the URL of the repository's web page with.git
appended.GIT_TAG
: A "tag" is essentially Git's equivalent of a "version". Specifying the tag we want to use is like specifying the version of the library we want to use. For a repository hosted on GitHub, the tags are listed in the "Tags" page. For example, sdplog's tags are listed here. Note that authors can call their tags whatever they want. In spdlog's case, it is the letterv
followed by a version number.CMAKE_ARGS
: A list of command-line arguments to pass to the dependency's own CMake configuration step, if the dependency uses CMake. Thesdplog
package does, and in this example we're telling it to install itself into the/spdlog_install
subdirectory of our/build
folder.
If we build our project now, we'll see it takes quite a bit longer as it now also downloads and compiles spdlog
:
cmake --build .
The downloading and installation will usually only happen the first time - subsequent builds will not repeat that work, unless we intentionally delete our /build
folder to create a clean installation.
Either way, if we look in our /build
folder, we should now see our new library available in the /spdlog_install
directory.
We aren't actually using the library yet - we've just downloaded and compiled it. Let's try to integrate it into our project in the next section.
The Configure-Time Problem
Let's update our main.cpp
to use spdlog
, replacing our std::cout
usage with a call to spdlog::info()
:
app/src/main.cpp
#include <iostream>
#include <greeter/Greeter.h>
#include <spdlog/spdlog.h> // We want to use spdlog
int main() {
spdlog::info(get_greeting()); // Use the logger
return 0;
}
Now, how do we link GreeterApp
to spdlog
? The spdlog
library provides a modern CMake target called spdlog::spdlog
. The natural thing to do would be to simply link against it:
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
add_executable(GreeterApp src/main.cpp)
include(ExternalProject)
ExternalProject_Add(spdlog_external
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.15.3
CMAKE_ARGS
-DCMAKE_INSTALL_PREFIX=${CMAKE_BINARY_DIR}/spdlog_install
)
target_link_libraries(GreeterApp
GreeterLib
spdlog::spdlog
)
Alas, this won't work:
CMake Error at app/CMakeLists.txt:13 (target_link_libraries):
Target "GreeterApp" links to:
spdlog::spdlog
but the target was not found.
This error exposes the fundamental limitation of ExternalProject_Add()
. The spdlog_external
target only downloads and builds spdlog
when we run cmake --build .
(the build step). But target_link_libraries()
is evaluated when we run cmake ..
(the configure step).
At configure time, the spdlog::spdlog
target simply does not exist yet. CMake has no knowledge of it, so it can't create the link, view the target's properties, or perform any similar action that might be useful for our build script.
The Old Workarounds
Before FetchContent
existed, developers used complex and brittle workarounds for this, broadly involving referencing files and paths that may not exist yet (in the configure step) but we think will in the future (in the build step, after it has downloaded and built the package).
The techniques varied on a case-by-case basis depending on how each dependency worked, but the simplest cases involve:
- Manually addding the include directory using the hardcoded path where
ExternalProject_Add()
will buildspdlog
(eg${CMAKE_BINARY_DIR}/spdlog_install/include
). - Manually adding the path to the compiled library file (eg
${CMAKE_BINARY_DIR}/spdlog_install/lib/libspdlog.a
). - Manually intervening in the dependency graph construction using commands like
add_dependencies()
to ensure thespdlog
was built before theGreeterApp
target that requires it. We introduceadd_dependencies()
and other practical use cases for it in the next chapter.
A working implementation of this approach for spdlog
is provided below for reference but, where possible, we'd rather move to the more modern, target-based dependency management.
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
include(ExternalProject)
add_executable(GreeterApp src/main.cpp)
# Set where spdlog will be installed in our build directory
set(SPDLOG_INSTALL_DIR ${CMAKE_BINARY_DIR}/spdlog_install)
ExternalProject_Add(spdlog_external
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.15.3
# Updated to use our new variable
CMAKE_ARGS
-DCMAKE_INSTALL_PREFIX=${SPDLOG_INSTALL_DIR}
)
# Create an IMPORTED proxy target to represent spdlog
add_library(spdlog_proxy STATIC IMPORTED GLOBAL)
# Construct the expected library file name portably
set(SPDLOG_LIB_FILENAME
# This is "lib" on Linux, "" on Windows
"${CMAKE_STATIC_LIBRARY_PREFIX}"
"spdlog"
# This is ".a" on Linux, ".lib" on Windows
"${CMAKE_STATIC_LIBRARY_SUFFIX}"
)
# Set paths we assume will exist after spdlog is built
set_target_properties(spdlog_proxy PROPERTIES
IMPORTED_LOCATION
"${SPDLOG_INSTALL_DIR}/lib/${SPDLOG_LIB_FILENAME}"
INTERFACE_INCLUDE_DIRECTORIES
"${SPDLOG_INSTALL_DIR}/include"
)
# Manually enforce the build order
add_dependencies(GreeterApp spdlog_external)
target_link_libraries(GreeterApp
GreeterLib
# Link against our proxy, not the real target
spdlog_proxy
)
This is a significant amount of complex, non-portable boilerplate.
Most notably, it requires us know internal details about the library, such as its installation layout (/lib
, /include
). If spdlog ever changes something we rely on, this script would break.
This pattern is a maintenance nightmare at scale, and it's the primary problem that FetchContent()
was designed to solve.
A Modern Convenience: FetchContent()
The CMake developers recognized this problem and created the FetchContent()
module. FetchContent()
is a modern wrapper around ExternalProject_Add()
designed to solve the configure-time visibility problem.
It works by splitting the process into distinct steps, and it performs the download (the "population") step during configuration. This allows it to call add_subdirectory()
behind the scenes to load the CMakeLists.txt
of what we just downloaded, making all of the dependency's targets available to the rest of your CMakeLists.txt
.
The FetchContent()
Workflow
Let's refactor our app/CMakeLists.txt
to use the modern FetchContent()
pattern.
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
add_executable(GreeterApp src/main.cpp)
# Step 1: Replace ExternalProject with FetchContent
include(ExternalProject)
include(FetchContent)
# Step 2: Declare the dependency
FetchContent_Declare(spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.15.3
)
# Step 3: Make the content available
FetchContent_MakeAvailable(spdlog)
# No changes needed
target_link_libraries(GreeterApp
GreeterLib
spdlog::spdlog
)
Let's walk through this new, cleaner script:
include(FetchContent)
: We include the module that provides the new commands.FetchContent_Declare(spdlog ...)
: This step registers our dependency. We give it a name (spdlog
) and provide the same source code details (GIT_REPOSITORY
,GIT_TAG
) as before. This command doesn't actually download anything yet; it just records the information.FetchContent_MakeAvailable(spdlog)
: This command checks if thespdlog
content has already been populated. If not, it downloads it into the build directory. Then, behind the scenes, it callsadd_subdirectory()
on the the directory it just downloaded.
Because add_subdirectory()
is called, CMake immediately processes spdlog
's own CMakeLists.txt
file. This creates the spdlog::spdlog
target, making it visible and available right away.
Now, when our script reaches the target_link_libraries()
command, spdlog::spdlog
is a known target. CMake can create the link, and all of spdlog
's usage requirements (like its public include directories) are automatically propagated to GreeterApp
.
If we configure and build this version, everything should work seamlessly.
cmake ..
cmake --build .
If we run our new GreeterApp
(or GreeterApp.exe
) we should now see our logs augmented with a timestamp and verbosity indicator (info
, in this example):
./app/GreeterApp
[2025-08-22 10:30:00.000] [info] Hello from the modular greeter library!
Many more examples of what spdlog can do, as well as links to documentation, are available on their GitHub page.
Fetched vs. System Dependencies
We now have two primary ways to handle external dependencies: finding them on the system with find_package()
, or fetching them from source. Each approach has trade-offs.
Feature | find_package() (System) | FetchContent() (Fetched) |
---|---|---|
Reproducibility | Lower. Depends on the version installed on the user's machine. | Higher. The exact version is pinned in the CMakeLists.txt , ensuring everyone builds with the same source. |
User Experience | More difficult. Requires the user to manually install dependencies before running CMake. | Easier. The user just needs a compiler and CMake. Dependencies are handled automatically. |
Build Time | Faster. Uses pre-compiled binaries already on the system. | Slower. The dependency is downloaded and compiled from source on every clean build. |
Control | Lower. You are limited to the version and build options provided by the system package. | Higher. You have full control over the dependency's source and can pass custom CMAKE_ARGS to configure it. |
Offline Builds | Excellent. No network connection needed. | Difficult. Requires a network connection for the initial download during configuration. |
Combining Approaches: The Find-or-Fetch Pattern
We have now covered two tools for managing dependencies, each with its own strengths:
find_package()
is fast and uses system-native libraries, but requires the user to pre-install them.ExternalProject_Add()
orFetchContent()
is reproducible and requires no setup from the user, but can be slow as it compiles from source.
Can we get the best of both worlds? A very common and robust pattern in modern CMake is to try to find the package first, and only fetch it if it's not found.
This gives users the option to use a fast, pre-compiled system library if they have one, while guaranteeing that the build will still work for anyone by falling back to fetching from source.
A Practical Example
Let's implement this "find-or-fetch" pattern for our spdlog
dependency. We'll modify app/CMakeLists.txt
to incorporate this logic.
The key is to use find_package()
without the REQUIRED
keyword, which prevents it from halting the build on failure. We can then check the spdlog_FOUND
variable it sets to decide our next move.
app/CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
include(FetchContent)
# Step 1: Try to find an existing spdlog installation.
# The QUIET keyword prevents it from outputting a warning
find_package(spdlog 1.1 QUIET)
# Step 2: If it wasn't found, fetch it from source.
if(spdlog_FOUND)
message(STATUS "Found spdlog version ${spdlog_VERSION}")
else()
message(STATUS "spdlog not found. Fetching from source")
FetchContent_Declare(spdlog
GIT_REPOSITORY https://github.com/gabime/spdlog.git
GIT_TAG v1.15.3
)
FetchContent_MakeAvailable(spdlog)
endif()
add_executable(GreeterApp src/main.cpp)
target_link_libraries(GreeterApp
GreeterLib
# This works regardless of where spdlog came from
spdlog::spdlog
)
Notice that the target_link_libraries()
call is completely unchanged and sits outside the conditional logic. This is the beauty of target-based dependency management.
The consumer, GreeterApp
, doesn't care how the spdlog::spdlog
target was created - whether it was an IMPORTED
target from find_package
or a STATIC
/SHARED
target from FetchContent
. It just needs the target to exist, and this script almost guarantees that it will.
Why Are The Versions Different?
Note that the versions we specify in find_package()
and in FetchContent_Declare()
need not be the same. Again, this is because the version we set has different implications between these two commands.
- In the find case, we specify the minimum version we can work with - e.g.
find_package(spdlog 1.1)
. - If find fails and we must fetch, we can choose the exact version we want to ship with. This is often newer than the minimum - eg
v1.15.3
.
If the user or project already has a compatible library installed, you don't want to force-upgrade it unnecessarily; that can cause conflicts.
But if we're fetching anyway, there's no risk of breaking other projects, so we can pull in a newer, stable version with the latest bugfixes and improvements.
Summary
In this lesson, we've learned how to create self-sufficient projects that automatically fetching their dependencies from source.
ExternalProject_Add()
: The classic, powerful tool that downloads and builds dependencies at build time. Its main drawback is that its targets are not visible at configure time, making integration complex.FetchContent()
: The modern, preferred tool that downloads dependencies at configure time. By usingadd_subdirectory()
, it makes the dependency's targets immediately available, allowing for seamless integration withtarget_link_libraries()
.- The Workflow: Use
FetchContent_Declare()
to register a dependency andFetchContent_MakeAvailable()
to download and integrate it. - Trade-offs: Fetching dependencies provides excellent reproducibility and a simpler user experience but results in longer build times compared to using pre-compiled system libraries with
find_package()
.