Mastering Dependency Propagation in Modern CMake with Target Properties
Modern CMake solves configuration leakage by using target-based properties instead of global variables. Learn how PUBLIC, PRIVATE, and INTERFACE keywords control dependency propagation with a worked example.
31 Oct 2025, 13:53 UTC

The most common failure in complex CMake projects is configuration leakage, where global flags or header paths intended for one library bleed into others, causing compilation errors or bloated builds. To solve this, you must move away from directory-level commands and adopt a target-based paradigm. In Modern CMake, you treat libraries as objects that encapsulate their own build requirements and the requirements their consumers need.
The Shift from Variables to Targets
In legacy CMake, commands like include_directories() and add_compile_options() applied to every target defined in the current directory and its subdirectories. If Library A required a specific header path, every executable in the project was forced to use that path.
Modern CMake (version 3.0+) uses targets—executables and libraries—as independent units. Instead of setting global variables, you attach properties to a specific target. When another target links to it, CMake automatically propagates the necessary requirements based on defined visibility keywords.
The Three Visibility Keywords: Private, Public, and Interface
The core of dependency management lies in three keywords used with target_link_libraries, target_include_directories, and target_compile_options:
- PRIVATE: The dependency is needed only to build the target itself. Consumers linking to this target do not see or inherit it.
- INTERFACE: The dependency is not needed to build the target, but it is required by anything that links to it (common for header-only libraries).
- PUBLIC: The dependency is required both to build the target and by all consumers. This is a combination of PRIVATE and INTERFACE.
Practical Example: A Library-Consumer Relationship
Consider a project with a library named MathUtils that uses an internal helper library for complex calculations, but exposes a public API that requires a specific include directory for any user of the library.
cmake_minimum_required(VERSION 3.10)
project(DependencyExample)
# Create the static library
add_library(MathUtils STATIC src/math_utils.cpp)
# Define include directories
# We use CMAKE_CURRENT_SOURCE_DIR for building
# and INTERFACE_INCLUDE for when the library is installed
target_include_directories(MathUtils PUBLIC
$
$
)
# Link an internal dependency privately
# The consumer of MathUtils doesn't need to know about InternalMath
target_link_libraries(MathUtils PRIVATE InternalMath)
# Create the executable (consumer)
add_executable(App main.cpp)
# Linking App to MathUtils
# App automatically gets the include paths defined as PUBLIC in MathUtils
target_link_libraries(App PRIVATE MathUtils)
In this example, when App links to MathUtils, CMake automatically adds the include directory to App's compiler flags. However, it does not add InternalMath because that was marked as PRIVATE.
Diagnostic Steps: Verifying Flag Propagation
If you are unsure whether your flags are propagating correctly, you can inspect the build graph or the generated files. The most reliable way is to use the CMake file API to query the target properties:
# Generate the build files first cmake -B build -S . # Inspect the properties of the target cmake --graphviz=graph.dot build cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -B build -S . # Then check build/compile_commands.json for your target
Alternatively, check the generated compile_commands.json (if enabled) in your build directory. Search for your specific target name to ensure the correct -I flags are present in the compiler command for that object file.
Common Pitfalls and Limitations
- Mixing Paradigms: Avoid using
include_directories()alongsidetarget_include_directories(). The global command will apply flags that bypass target-based logic, making the build difficult to debug. - Over-using PUBLIC: Marking every dependency as PUBLIC creates bloated dependency chains and increases compilation times. Use PRIVATE by default; only promote to PUBLIC when a consumer genuinely needs the header or flag.
- Ordering Bugs: In legacy CMake, variable order mattered. In Modern CMake, target commands can appear in any order as long as the target exists, but mixing both styles often leads to subtle ordering bugs in the build graph.
- Generator Expressions: The
$<BUILD_INTERFACE:...>and$<INSTALL_INTERFACE:...>generator expressions are essential for libraries that will be installed, but they add complexity. Verify they work correctly by testing both build-tree and install-tree consumption.
Verification Checklist
- Run
cmake --versionto confirm 3.0+ support. - Create a minimal project with one static library and one executable to test PUBLIC vs PRIVATE propagation.
- Inspect generated build files (Makefile, ninja.build, or compile_commands.json) to verify flags apply only to intended targets.
- Ensure no directory-level commands (
include_directories,add_compile_options,link_libraries) remain in your CMakeLists.txt.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.