Fetching External Dependencies

Learn how to create self-contained, reproducible builds by fetching dependencies from source using FetchContent() and ExternalProject_Add().

Greg Filak
Published

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

CMakeLists.txt
greeter
app
Select a file to view its content

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 name spdlog_external.
  • GIT_REPOSITORY: The most common source of libraries are Git repositories, usually hosted on GitHub. The ExternalProject_Add() command specifically supports this. We just need to provide the GIT_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 letter v 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. The sdplog 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:

  1. Manually addding the include directory using the hardcoded path where ExternalProject_Add() will build spdlog (eg ${CMAKE_BINARY_DIR}/spdlog_install/include).
  2. Manually adding the path to the compiled library file (eg ${CMAKE_BINARY_DIR}/spdlog_install/lib/libspdlog.a).
  3. Manually intervening in the dependency graph construction using commands like add_dependencies() to ensure the spdlog was built before the GreeterApp target that requires it. We introduce add_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:

  1. include(FetchContent): We include the module that provides the new commands.
  2. 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.
  3. FetchContent_MakeAvailable(spdlog): This command checks if the spdlog content has already been populated. If not, it downloads it into the build directory. Then, behind the scenes, it calls add_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.

Featurefind_package() (System)FetchContent() (Fetched)
ReproducibilityLower. 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 ExperienceMore 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 TimeFaster. Uses pre-compiled binaries already on the system.Slower. The dependency is downloaded and compiled from source on every clean build.
ControlLower. 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 BuildsExcellent. 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() or FetchContent() 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 using add_subdirectory(), it makes the dependency's targets immediately available, allowing for seamless integration with target_link_libraries().
  • The Workflow: Use FetchContent_Declare() to register a dependency and FetchContent_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().
Have a question about this lesson?
Answers are generated by AI models and may not be accurate