Modern CMake: Managing Transitive Dependencies with Targets
Use PUBLIC, PRIVATE, and INTERFACE on targets instead of global commands so CMake propagates include paths and link dependencies transitively and correctly.
09 Jul 2025, 19:12 UTC

Stop using global commands like include_directories() and link_libraries(). In modern CMake, you define dependencies as properties of specific targets. By using the PUBLIC, PRIVATE, and INTERFACE keywords, you ensure that consumers of your library automatically inherit exactly the include paths, compile definitions, and link dependencies they need—no manual configuration required.
The Mechanism of Usage Requirements
The core concept of modern CMake is the target: an object that carries its own build and usage requirements. Instead of setting directory-wide state, you attach usage requirements to a target. When one target links to another, CMake propagates those requirements transitively through the link graph, filtered by the visibility keyword you chose:
- PRIVATE: The requirement is needed to build the target itself, but not by its consumers. Use this for implementation details that never appear in your public headers.
- INTERFACE: The requirement is not needed to build the target itself, only by its consumers. This is the standard pattern for header-only libraries (
add_library(x INTERFACE)), which carry only usage requirements and produce no build artifact. - PUBLIC: The requirement is needed both to build the target and by anyone who links to it.
For example, if library A links PUBLIC to library B, then any target linking A also receives B's INTERFACE include directories, compile definitions, and link flags. If A links B as PRIVATE, B is used only while compiling A itself.
Worked Example: Library and Application
Consider a project with a library math_utils that depends on an internal helper library, plus an application that consumes math_utils. This example assumes CMake 3.15 or newer; target-based commands exist since 2.8.12, but policies and default behaviors have shifted across 3.x releases, so set cmake_minimum_required deliberately.
cmake_minimum_required(VERSION 3.15)
project(MathProject)
# Internal helper: used only inside math_utils .cpp files
add_library(internal_logic src/internal.cpp)
# The public library
add_library(math_utils src/math.cpp)
# PUBLIC because math_utils.h lives in include/ and consumers
# must see it. Generator expressions give different paths for
# the build tree vs. an installed package.
target_include_directories(math_utils PUBLIC
$
$
)
# PRIVATE: consumers of math_utils never see internal_logic
target_link_libraries(math_utils PRIVATE internal_logic)
# The application
add_executable(app main.cpp)
target_link_libraries(app PRIVATE math_utils)Here, app compiles with math_utils's include path and links against it, but nothing linking app would re-expose math_utils (because the link is PRIVATE), and app never sees internal_logic. The generator expressions $ and $ make the include path absolute during development but relative to the install prefix when the target is exported and installed—essential for installable packages.
Verifying Propagation
You can confirm exactly what each target inherits by inspecting the real compiler invocations. From your project root (no special permissions needed; this only generates build files):
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON cmake --build build
Open build/compile_commands.json and find the entry for main.cpp. Its -I flags should include the math_utils include directory but not anything belonging to internal_logic. A stronger check: temporarily change the internal_logic link to PUBLIC, reconfigure, and confirm the include path now leaks into app's compile command—then revert. This demonstrates the propagation boundary directly. You can also run cmake --help-command target_link_libraries to confirm keyword semantics for your installed version.
Limits and Common Mistakes
- Wrong visibility for header-exposed dependencies: If a dependency's headers appear in your public API, it must be linked
PUBLIC. Marking itPRIVATEmay let your library build today, but downstream consumers will fail to compile because they never receive the include path. - Hidden transitive dependencies: Propagation can mask missing direct dependencies. A target may compile only because an include path leaked through an intermediate library; when that intermediate library changes its linkage, your build breaks unexpectedly. Declare direct dependencies directly.
- Mixing old and new styles: Combining
include_directories(),link_libraries(), oradd_definitions()with target-based commands leaks settings into every target defined afterward in that directory and breaks transitive propagation. Pick the target-based model and use it consistently. - Version assumptions: Policy behavior and some generator-expression details vary across CMake 3.x releases. Behavior observed on one version is not guaranteed on another; verify with
cmake --help-policyon the version you actually deploy.
The practical takeaway: every dependency decision reduces to one question—does this requirement appear in my public interface? Answer it with PUBLIC, PRIVATE, or INTERFACE, and CMake handles the transitive bookkeeping for you.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.