Skip to content

Property Propagation

In legacy C++ build systems (Makefiles, Visual Studio Solutions), build settings like “Include Directories” and “Preprocessor Definitions” were often global or applied loosely to directory Scopes. This led to “Include Hell,” where a consumer relied on a transitive dependency”s headers, Causing build breakages when the dependency graph changed.

Modern CMake (3.0+) resolves this via the Target-Centric Model. Every library or executable is An object (Target) that encapsulates two distinct sets of data:

  1. Build Requirements: What the target needs to build itself.
  2. Usage Requirements: What a consumer needs to link against the target.

This module analyzes how these properties propagate through the dependency graph using Visibility Scopes.

When declaring properties (target_include_directories``target_compile_definitions) or Dependencies (target_link_libraries), CMake requires a scope keyword.

  • Semantics: The property is required only to build the target itself. It is not propagated to consumers.
  • Mechanism: Populates the target’s INCLUDE_DIRECTORIES (or relevant property) but not the INTERFACE_ variants.
  • Use Case: Implementation details. If LibA uses Boost.Asio internally in its .cpp files but does not expose Asio headers in its public .h files, the dependency is PRIVATE.
  • Semantics: The property is not required to build the target (or the target has no source), but it is required by consumers.
  • Mechanism: Populates the target’s INTERFACE_ properties but not the build properties.
  • Use Case:
  • Header-Only Libraries: There is no binary to compile, so PRIVATE build requirements do not exist.
  • Forward Declarations: LibA forward declares a type in its header, but the consumer must include the full definition.
  • Semantics: The property is required for both building the target and by consumers.
  • Mechanism: Populates both the build properties and the INTERFACE_ properties.
  • Use Case: Public API dependencies. If LibA includes <fmt/core.h> in its public header liba.hThen any consumer of LibA must also have access to fmt’s include path.

Claim: When target B links against target A with scope SThe INTERFACE_ properties of A propagate to B if and only if S is PUBLIC or INTERFACE.

Proof:

CMake’s property propagation operates on the directed graph G=(V,E)G = (V, E) where vertices are targets And edges are target_link_libraries relationships. Each edge has a scope label {PRIVATE,INTERFACE,PUBLIC}\{PRIVATE, INTERFACE, PUBLIC\}.

Define the propagation function P(T,S)P(T, S) where TT is a target and SS is a scope:

  • If S=PRIVATES = PRIVATE: PP sets the build properties of the dependent target but does not modify INTERFACE_ properties. No propagation to downstream consumers.
  • If S=INTERFACES = INTERFACE: PP sets only the INTERFACE_ properties of the dependent target. The dependent target’s build properties are unchanged, but downstream consumers will see these INTERFACE_ properties.
  • If S=PUBLICS = PUBLIC: PP sets both the build properties and the INTERFACE_ properties. The dependent target gets the property for its own build, and all downstream consumers inherit it.

The INTERFACE_LINK_LIBRARIES of a target TT is the transitive closure of all PUBLIC and INTERFACE edges reachable from TT. When consumer CC links against TT with scope SCMake Merges T’s INTERFACE_LINK_LIBRARIES into CC‘s link list if S{PUBLIC,INTERFACE}S \in \{PUBLIC, INTERFACE\}.

\therefore A PRIVATE dependency creates a dead end in the propagation graph. Only PUBLIC and INTERFACE edges allow information flow to downstream consumers. \blacksquare

Corollary: The transitive closure of INTERFACE properties from target TT to consumer CC is The set of all targets reachable from TT via paths consisting entirely of PUBLIC or INTERFACE Edges.

Consider a linear dependency chain: App \to Engine \to Core.

Scenario A: Strict Encapsulation (Private)

Section titled “Scenario A: Strict Encapsulation (Private)”
Core/CMakeLists.txt
add_library(Core src/core.cpp)
# Core uses OpenSSL internally.
target_link_libraries(Core PRIVATE OpenSSL::Crypto)
  • Core: Links against OpenSSL. Has OpenSSL include paths.
  • Engine: Links against Core. Does not inherit OpenSSL include paths.
  • App: Links against Engine. Does not see OpenSSL.

Result: Clean build graph. Changing OpenSSL versions only triggers a rebuild of Core.

Core/CMakeLists.txt
add_library(Core src/core.cpp)
# Core exposes OpenSSL types in core.h
target_link_libraries(Core PUBLIC OpenSSL::Crypto)
# Engine/CMakeLists.txt
target_link_libraries(Engine PUBLIC Core)
  • Core: Links OpenSSL.
  • Engine: Links Core and inherits OpenSSL requirements automatically.
  • App: Links Engine. Inherits Core requirements and OpenSSL requirements.

Result: App can include <openssl/ssl.h> without explicitly asking for it. This indicates a Tightly coupled architecture.

The following table enumerates every CMake property that supports visibility scopes and documents The propagation behavior for each.

PropertyPRIVATE (build only)INTERFACE (consumers only)PUBLIC (both)Target command
Include directoriesYesYesYestarget_include_directories
Compile definitionsYesYesYestarget_compile_definitions
Compile featuresYesYesYestarget_compile_features
Compile optionsYesYesYestarget_compile_options
Link librariesYesYesYestarget_link_libraries
Link optionsYesYesYestarget_link_options
Link directoriesYesYesYestarget_link_directories
Precompiled headersYesNoNotarget_precompile_headers
SourcesYesNoNotarget_sources
Position independent codeYesNoNoset_target_properties(PIC)
C++ standard levelYesNoNotarget_compile_features
System include directoriesYesYesYestarget_include_directories(SYSTEM)
Autogen include directoriesYesNoNotarget_include_directories (Qt)
  1. Not all properties propagate. target_sources``target_precompile_headersAnd position independent code settings are always PRIVATE and cannot be made transitive.
  2. Include directories and compile definitions are the most commonly propagated properties. These are the properties that directly affect whether a consumer can compile against a library.
  3. Link libraries are the most dangerous property to propagate. Every PUBLIC link library adds to the transitive closure, increasing compile times and binary size.

An INTERFACE library is a CMake target that produces no binary artifact (.a / .lib). It Functions purely as a container for Usage Requirements. This is an architectural pattern for Standardizing project configuration.

Instead of setting global flags (CMAKE_CXX_FLAGS), create a meta-target that defines the standard Compilation environment.

add_library(ProjectOptions INTERFACE)
target_compile_features(ProjectOptions INTERFACE cxx_std_23)
target_compile_options(ProjectOptions INTERFACE
$<$<CXX_COMPILER_ID:MSVC>:/W4 /permissive->
$<$<CXX_COMPILER_ID:Clang>:-Wall -Wextra -Wpedantic>
)
target_compile_definitions(ProjectOptions INTERFACE
PROJECT_ARCH_X64
)

Usage: Every target in the system links against this interface.

add_library(Physics src/physics.cpp)
target_link_libraries(Physics PRIVATE ProjectOptions)

When using a header-only library (like nlohmann_json or Eigen), the target definition is purely Structural.

add_library(JsonLib INTERFACE)
target_include_directories(JsonLib INTERFACE include/)

Pattern 3: Interface Alias for Installed Packages

Section titled “Pattern 3: Interface Alias for Installed Packages”

When consuming an installed package via find_packageYou can create an interface target to Standardize the consumption:

find_package(fmt REQUIRED)
# Create an alias with our project's preferred settings
add_library(ProjFmt INTERFACE)
target_link_libraries(ProjFmt INTERFACE fmt::fmt)
target_compile_definitions(ProjFmt INTERFACE FMT_HEADER_ONLY)

A critical nuance in property propagation is the difference between directory layouts during Development (Build Tree) versus after deployment (Install Tree).

  • Build Tree: Headers are located in ${CMAKE_CURRENT_SOURCE_DIR}/include.
  • Install Tree: Headers are located in ${CMAKE_INSTALL_PREFIX}/include.

If you hardcode the include path, installed consumers will fail to find headers. You must use Generator Expressions to distinguish these states.

target_include_directories(CoreSystem PUBLIC
# Used when building the project locally
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
# Used by consumers importing the installed package
$<INSTALL_INTERFACE:include>
)

During development, the build tree has a different directory layout than the install tree. Consider A library Core with headers in Core/include/. During an in-tree build, the include path is /path/to/project/Core/include. After installation with DESTINATION includeThe path is /usr/local/include.

If you write:

# WRONG: hardcoded source path
target_include_directories(Core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include)

The installed package will embed an absolute path to the build machine’s source directory. Any Consumer on a different machine will fail to find headers. The $<BUILD_INTERFACE> and $<INSTALL_INTERFACE> generator expressions solve this by conditionally selecting the correct path Based on the context.

install(TARGETS Core
EXPORT CoreTargets
LIBRARY DESTINATION lib
ARCHIVE DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES DESTINATION include
)
install(EXPORT CoreTargets
FILE CoreTargets.cmake
NAMESPACE Core::
DESTINATION lib/cmake/Core
)

Consumers then use:

find_package(Core REQUIRED)
target_link_libraries(MyApp PRIVATE Core::Core)

When linking third-party dependencies, you often want to inherit their Include Directories but Suppress compiler warnings generated by their code.

CMake provides the SYSTEM attribute for this purpose.

# Using explicit SYSTEM property
target_include_directories(CoreSystem SYSTEM PUBLIC ${Boost_INCLUDE_DIRS})

When checking targets, dependencies marked as SYSTEM are passed to the compiler via -isystem (Clang/GCC) or /external:I (MSVC). This instructs the compiler to treat them as system headers, Disabling warnings for those specific paths while maintaining visibility.

  1. Default to PRIVATE: Start with PRIVATE visibility for all dependencies. Only escalate to PUBLIC if a type from the dependency appears in your public headers.
  2. Avoid Global State: Never use include_directories() or link_libraries(). These apply to the directory scope and break the encapsulation of the dependency graph.
  3. Use Interface Targets: Centralize warning levels, sanitizers, and architecture flags into an INTERFACE target rather than repeating variables across multiple CMakeLists.txt files.

CMake classifies build properties into several categories, each with distinct propagation semantics. Understanding these categories is essential for controlling the dependency graph precisely.

Compile definitions (-D flags) propagate through target_compile_definitions with visibility Scopes.

# Core library defines NDEBUG in release builds
add_library(Core src/core.cpp)
target_compile_definitions(Core PUBLIC
CORE_VERSION="2.1.0" # Visible to Core and all consumers
)
target_compile_definitions(Core PRIVATE
CORE_INTERNAL_LOGGING # Visible only to Core
)

Propagation result:

  • Core sees both CORE_VERSION and CORE_INTERNAL_LOGGING.
  • Any target linking Core (e.g., Engine) sees CORE_VERSION but not CORE_INTERNAL_LOGGING.

Include directories control where the compiler searches for headers (-I / -isystem flags).

add_library(Engine src/engine.cpp)
target_include_directories(Engine
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> # Consumers need this
$<INSTALL_INTERFACE:include>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src # Only Engine needs this
)

Key rule: If a header is #includeD in your public .h file, the directory containing that Header must be a PUBLIC include directory. If a header is only #includeD in .cpp files, it Should be PRIVATE.

Compiler flags (-Wall``-O2``/W4) propagate similarly.

add_library(Physics src/physics.cpp)
target_compile_options(Physics PRIVATE
$<$<CONFIG:Debug>:-fno-omit-frame-pointer>
$<$<CONFIG:Release>:-O3 -march=native>
)

Important: Compiler optimization flags should almost always be PRIVATE. Propagating -march=native to a consumer building on a different CPU would cause illegal instruction errors at Runtime.

target_link_libraries is the most complex propagation mechanism because it triggers transitive Property propagation from the linked target.

add_library(Network src/network.cpp)
target_link_libraries(Network PUBLIC OpenSSL::SSL)
target_link_libraries(Network PRIVATE zstd::zstd_static)
add_library(Server src/server.cpp)
target_link_libraries(Server PUBLIC Network)

Resulting propagation for Server:

  • Links against Network (direct).
  • Inherently links against OpenSSL::SSL (transitive, because Network declared it PUBLIC).
  • Does not link against zstd::zstd_static (transitive blocked by PRIVATE on Network).

How Transitive Dependencies Affect Binary Size

Section titled “How Transitive Dependencies Affect Binary Size”

Every PUBLIC or INTERFACE link library adds to the transitive closure of dependencies that the Final executable must link against. This has direct consequences for binary size.

In a statically linked application, the transitive closure determines which object code is included In the final binary.

App (static)
+-- Server
| +-- Network (PUBLIC)
| | +-- OpenSSL::SSL (PUBLIC) <- ~2MB
| | +-- zstd (PRIVATE) <- ~500KB (NOT linked into App)
| +-- Logger (PUBLIC)
| +-- spdlog (PUBLIC) <- ~200KB
+-- Framework
+-- Core (PUBLIC)
+-- fmt (PUBLIC) <- ~150KB

Total binary size contribution from transitive deps: ~2.35MB (OpenSSL dominates).

If OpenSSL::SSL were changed to PRIVATE on NetworkThe binary would shrink by ~2MB, but Server could no longer expose OpenSSL types in its public headers.

Use nm or size to inspect the contribution of each library:

Terminal window
# Show symbol sizes in the final binary
nm --size-sort -S build/app | tail -20
# Show section sizes
size build/app
# text data bss dec hex filename
# 1234567 89012 34567 1358146 14bc82 app

With shared linking, transitive dependencies do not increase the executable’s size (they are in .so/.dll files). However, they increase the runtime dependency count, which affects Deployment complexity.

Terminal window
# List shared library dependencies of a binary
ldd build/app
# linux-vdso.so.1
# libssl.so.3 => /usr/lib/libssl.so.3
# libcrypto.so.3 => /usr/lib/libcrypto.so.3
# libstdc++.so.6 => /usr/lib/libstdc++.so.6
# libc.so.6 => /usr/lib/libc.so.6

Each .so listed is a transitive dependency that must be present on the deployment target.

Example Walkthrough: A Complete Dependency Chain

Section titled “Example Walkthrough: A Complete Dependency Chain”

Consider a realistic project with a multi-level dependency chain.

my-app
+-- http-client (our library)
+-- libcurl (system/vcpkg)
| +-- OpenSSL (transitive from libcurl)
| +-- zlib (transitive from libcurl)
+-- nlohmann-json (direct)

File: http-client/CMakeLists.txt

add_library(http-client
src/http_client.cpp
)
target_include_directories(http-client
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/detail
)
target_link_libraries(http-client
PUBLIC
nlohmann_json::nlohmann_json # Exposed in http_client.h
PRIVATE
CURL::libcurl # Used only in .cpp
)

File: app/CMakeLists.txt

add_executable(my-app src/main.cpp)
target_link_libraries(my-app PRIVATE http-client)
PropertyPropagated to my-app?Reason
http-client include dirYesPUBLIC on http-client
nlohmann_json::nlohmann_jsonYesPUBLIC on http-client
nlohmann_json include dirYesTransitive via PUBLIC link
CURL::libcurlNoPRIVATE on http-client
OpenSSLNoPRIVATE on libcurl (if set correctly by vcpkg)
zlibNoPRIVATE on libcurl (if set correctly by vcpkg)

Result: my-app can include <nlohmann/json.hpp> and <http_client.h> but cannot directly Include <curl/curl.h>. If my-app needs curl directly, it must add CURL::libcurl to its own target_link_libraries.

If http-client should expose a curl_easy_setopt wrapper in its public header, the design must Change:

target_link_libraries(http-client
PUBLIC
nlohmann_json::nlohmann_json
CURL::libcurl # Now PUBLIC because http_client.h uses curl types
)

Now my-app inherits CURL::libcurlAnd with it, the transitive OpenSSL and zlib Dependencies. This is the correct behavior: the compiler needs OpenSSL headers to compile code that Includes http_client.h.

Generator Expressions for Conditional Propagation

Section titled “Generator Expressions for Conditional Propagation”

CMake generator expressions allow fine-grained control over when properties propagate.

add_library(PlatformIO src/platform_io.cpp)
target_compile_definitions(PlatformIO PUBLIC
$<$<PLATFORM_ID:Windows>:PLATFORM_WINDOWS>
$<$<PLATFORM_ID:Linux>:PLATFORM_LINUX>
$<$<PLATFORM_ID:Darwin>:PLATFORM_MACOS>
)
target_link_libraries(http-client
PUBLIC nlohmann_json::nlohmann_json
PRIVATE
CURL::libcurl
$<$<CONFIG:Debug>:address-sanitizer> # Only linked in debug builds
)
add_library(Portable target src/portable.cpp)
target_compile_options(Portable PUBLIC
$<$<CXX_COMPILER_ID:GNU>:-fno-keep-inline-dllexport>
$<$<CXX_COMPILER_ID:Clang>:-fvisibility-inlines-hidden>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
target_compile_definitions(Portable PUBLIC
$<$<BOOL:${USE_FEATURE_X}>:FEATURE_X_ENABLED>
)
Section titled “Interface Link Libraries (Head-Only Forwarding)”

When a header-only library wraps another library, use INTERFACE_LINK_LIBRARIES to propagate the Dependency without creating a binary:

add_library(json-wrapper INTERFACE)
target_include_directories(json-wrapper INTERFACE include/)
target_link_libraries(json-wrapper INTERFACE nlohmann_json::nlohmann_json)
# Consumers link json-wrapper and automatically get nlohmann_json
target_link_libraries(my-app PRIVATE json-wrapper)

Transitive Usage Requirements and INTERFACE_* Properties

Section titled “Transitive Usage Requirements and INTERFACE_* Properties”

Every target_link_libraries call with PUBLIC or INTERFACE scope triggers the propagation of All INTERFACE_* properties from the linked target. The complete list of propagated properties:

Propagated PropertySet byEffect on consumer
INTERFACE_INCLUDE_DIRECTORIEStarget_include_directories(... PUBLIC)Adds to consumer’s -I paths
INTERFACE_COMPILE_DEFINITIONStarget_compile_definitions(... PUBLIC)Adds to consumer’s -D flags
INTERFACE_COMPILE_OPTIONStarget_compile_options(... PUBLIC)Adds to consumer’s compiler flags
INTERFACE_LINK_LIBRARIEStarget_link_libraries(... PUBLIC)Links consumer against transitive deps
INTERFACE_LINK_OPTIONStarget_link_options(... PUBLIC)Adds to consumer’s linker flags
INTERFACE_LINK_DIRECTORIEStarget_link_directories(... PUBLIC)Adds to consumer’s -L paths
INTERFACE_COMPILE_FEATUREStarget_compile_features(... PUBLIC)Enables C++ features for consumer
INTERFACE_POSITION_INDEPENDENT_CODEset_target_properties(PIC)Enables PIC for consumer

This is why a single PUBLIC link can cause a cascade of changes: it propagates not just the link Dependency, but all of the linked target’s own transitive usage requirements.

  1. Overusing PUBLIC: The most common mistake is making every dependency PUBLIC “just in case.” This bloats the transitive closure, increases compile times (more headers to parse), and creates fragile coupling. Start with PRIVATE and escalate only when needed.
  2. Header-only libraries as PRIVATE: A header-only library linked as PRIVATE means its include directories are not propagated. If any header in the target’s public interface includes the header-only library’s headers, consumers will fail to compile.
  3. Mismatched INTERFACE and PUBLIC in installed packages: When exporting a CMake package via install(EXPORT ...)The PUBLIC and INTERFACE properties must be correctly specified. If a dependency is PUBLIC in the build tree but not exported in the install tree, installed consumers will get linker errors.
  4. SYSTEM include directories: Using SYSTEM on your own project’s headers (not third-party) can mask legitimate warnings. Reserve SYSTEM for external dependencies only.
  5. Generator expressions in wrong scope: A generator expression like $<CONFIG:Debug> in a PUBLIC compile option propagates to consumers. If the consumer has a different configuration, the option may be silently ignored or incorrectly applied. Use PRIVATE for configuration-specific options.
  6. Circular transitive dependencies: If A links B PUBLIC and B links A PUBLIC, CMake detects a cycle and errors. This indicates a design flaw: the two libraries should be merged or the dependency should be made PRIVATE on at least one side.
  7. Forgetting that target_link_libraries propagates more than just linking. When you write target_link_libraries(MyLib PUBLIC OtherLib)You are not just adding a linker dependency. You are also propagating all of OtherLib’s include directories, compile definitions, compile options, and link options to every consumer of MyLib. This can cause unexpected side effects like inheriting warning suppression flags or optimization flags from transitive dependencies.

When builds fail due to incorrect property propagation, use the following diagnostic techniques.

# Print all properties of a target
get_target_property(INCLUDE_DIRS MyLib INCLUDE_DIRECTORIES)
message(STATUS "MyLib INCLUDE_DIRECTORIES: ${INCLUDE_DIRS}")
get_target_property(INTERFACE_LINK_LIBS MyLib INTERFACE_LINK_LIBRARIES)
message(STATUS "MyLib INTERFACE_LINK_LIBRARIES: ${INTERFACE_LINK_LIBS}")
get_target_property(COMPILE_DEFS MyLib COMPILE_DEFINITIONS)
message(STATUS "MyLib COMPILE_DEFINITIONS: ${COMPILE_DEFS}")

CMake does not provide a built-in command to trace the full transitive closure of properties, but You can use generator expressions to inspect what a target sees:

# Show the effective link libraries (including transitive)
target_link_libraries(MyApp PRIVATE
$<$<TARGET_EXISTS:MyLib>:MyLib>
)
# Show the include directories that would propagate to MyApp
add_custom_target(debug_props
COMMAND ${CMAKE_COMMAND}
--install-prefix ${CMAKE_BINARY_DIR}/debug
-P ${CMAKE_SOURCE_DIR}/cmake/debug_props.cmake
)
  1. “Cannot find -lfoo” at link time: The library is linked with PUBLIC but the include directory is PRIVATESo the consumer cannot find the headers. Or the library is linked as PRIVATE but the consumer’s header includes it.
  2. “Undeclared identifier” in consumer: A PUBLIC include directory is missing. The dependency is linked but its include paths are not propagated.
  3. Unexpected compile flags in consumer: A dependency sets target_compile_options(PUBLIC ...) on a third-party library’s target, which propagates unwanted flags. Fix by wrapping the third-party library in an INTERFACE target with only the necessary properties.

The following table lists commonly used generator expressions for property propagation control.

ExpressionEvaluates toUse case
$<CONFIG:Debug>1 in Debug, 0 otherwiseConfiguration-specific options
$<PLATFORM_ID:Linux>1 on Linux, 0 otherwisePlatform-specific options
$<CXX_COMPILER_ID:Clang>1 for Clang, 0 otherwiseCompiler-specific options
$<BUILD_INTERFACE:...>Value in build tree onlyBuild-tree-only include paths
$<INSTALL_INTERFACE:...>Value in install tree onlyInstall-tree-only include paths
$<TARGET_EXISTS:foo>1 if target foo existsConditional linking
$<LINK_ONLY:foo>Link foo but do not add include dirsHeader-only library separation
$<STREQUAL:${CMAKE_BUILD_TYPE},Release>1 in Release buildsBuild-type comparison
$<BOOL:${ENABLE_FEATURE}>1 if option is truthyOption-based conditional
$<COMPILE_LANGUAGE:CXX>1 for C++ sources onlyC++-only compile options
$<COMPILE_LANGUAGE:C>1 for C sources onlyC-only compile options
$<AND:$<CONFIG:Debug>,$<PLATFORM_ID:Linux>>Logical ANDComplex conditions
$<OR:$<CONFIG:Debug>,$<CONFIG:RelWithDebInfo>>Logical ORMultiple config conditions
  • Dependency Resolution — How the dependency graph is constructed
  • vcpkg — How vcpkg manages property propagation for installed packages
  • CPM.cmake — How add_subdirectory merges dependency properties
  • Binary Caching — Binary artifacts and their dependency metadata

This topic covers the essential concepts and techniques related to property propagation, including key principles and practical applications.

Key concepts include:

  • core concepts and definitions
  • key principles and frameworks
  • practical applications
  • common techniques and methods
  • evaluation and critical analysis

A thorough understanding of these concepts, combined with regular practice and review, is essential for mastery of this topic.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.