Switching from Global Includes to Target‑Scoped CMake: Prevent Include Leaks and Enforce Transitive Dependencies
Global include_directories() can silently leak paths into unrelated targets. Switching to target‑scoped usage requirements (target_include_directories, target_compile_definitions, target_link_libraries) keeps builds clean, enforces transitive dependencies, and mirrors find_package behavior.
28 Jul 2026, 19:45 UTC

Why the Global Include Approach is a Silent Saboteur
When a project grows beyond a handful of files, the old CMake idiom of include_directories(), add_definitions(), and add_compile_options() at the directory level starts to bite. These commands mutate the *current directory scope* and every target defined afterwards – in that directory or any of its sub‑directories – inherits the settings automatically. The result is a build that compiles only because a sibling target happened to expose a header path or macro that the target needed. Move a file, rename a target, or reorganise the tree and the build silently breaks.
The Target‑Scoped Replacement
Modern CMake offers a cleaner, more explicit way: attach usage requirements directly to the target that needs them. The commands are:
target_include_directories(target PRIVATE|PUBLIC|INTERFACE …)target_compile_definitions(target PRIVATE|PUBLIC|INTERFACE …)target_compile_options(target PRIVATE|PUBLIC|INTERFACE …)target_link_libraries(target PRIVATE|PUBLIC|INTERFACE …)
PUBLIC or INTERFACE, the requirement propagates transitively to every consumer. This mirrors the behaviour of find_package‑provided IMPORTED targets and eliminates accidental leaks.
Concrete Example: A Static Library and an Executable
Consider a small project that contains a static library mathlib and an executable app. The library has public headers in include/ and source files in src/. The executable uses mathlib and also depends on fmtlib – a third‑party formatting library that is only needed by mathlib’s public headers.
# CMakeLists.txt (root)
cmake_minimum_required(VERSION 3.12)
project(MathExample)
add_subdirectory(mathlib)
add_executable(app src/main.cpp)
# Link the library – it is PRIVATE to the executable
# (the executable does not need to see mathlib’s internals)
# PUBLIC would expose mathlib’s public interface to app’s consumers
# which is unnecessary here.
target_link_libraries(app PRIVATE mathlib)
Inside mathlib/CMakeLists.txt we declare the library and its usage requirements:
# mathlib/CMakeLists.txt
add_library(mathlib STATIC src/matrix.cpp src/vector.cpp)
# Public headers – needed by anyone that includes mathlib
# Use BUILD_INTERFACE/INSTALL_INTERFACE to distinguish build‑tree and install paths
# The SYSTEM keyword tells the compiler to suppress warnings from these headers
# (useful for third‑party headers that are not under our control)
#
# PUBLIC: used by mathlib itself and by consumers of mathlib
# PRIVATE: used only by mathlib’s implementation files
# Public include path
target_include_directories(mathlib
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
)
# Private include path for internal implementation files
# (e.g. a helper header that should not leak out)
target_include_directories(mathlib PRIVATE src)
# Compile definitions used in public headers
# (e.g. a macro that toggles a feature flag)
#
# PUBLIC ensures consumers can also use the macro
# PRIVATE would keep it hidden
# target_compile_definitions(mathlib PUBLIC USE_MATRIX)
# Link dependency that is required by mathlib’s public headers
# fmtlib is only needed for formatting the public API
# PUBLIC propagates the link to consumers of mathlib
# PRIVATE would keep it internal
find_package(fmt REQUIRED)
# Link fmtlib publicly – any consumer of mathlib gets the dependency
# This is the same semantics as a header‑only library that declares
# a dependency on fmt
# PUBLIC ensures the consumer’s link line includes fmt
# PRIVATE would keep fmt out of the consumer’s link line
# Note: target_link_libraries uses the same keyword semantics
# If fmtlib is a header‑only library, we can use PUBLIC
# If it is a static or shared library, we also use PUBLIC
# The dependency is added as PUBLIC
# (the syntax is the same for both static and shared libs)
# This line is the key: it attaches fmt to mathlib’s PUBLIC interface
# target_link_libraries(mathlib PUBLIC fmt::fmt)
# If fmtlib is a header‑only library, the target name might be
# fmt::fmt-header-only
# For the sake of this example, we assume fmt::fmt is the imported target
# Finally, we export the library for downstream consumers
install(TARGETS mathlib
EXPORT MathExampleTargets
ARCHIVE DESTINATION lib
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin
INCLUDES DESTINATION include)
install(DIRECTORY include/ DESTINATION include)
Key take‑aways from the snippet:
- All include paths, compile definitions, and link dependencies are attached to
mathlibwith the appropriate visibility keyword. - The
PUBLICkeyword propagates the requirement toappbecauseapplinksmathlib(even though it is declared PRIVATE, the transitive PUBLIC interface ofmathlibis still inherited). - No
include_directories()oradd_definitions()calls are needed at the root or inapp– the build is self‑contained.
Transitivity in Action
Suppose mathlib also depends on a header‑only library json::json that is only used in its public headers. Declaring that dependency as PUBLIC ensures that any downstream target – for example, a future statistics library that links mathlib – automatically inherits the json include path and link requirement without any extra configuration.
PUBLIC vs. PRIVATE – The Trade‑Off
The visibility keyword determines *who* sees the requirement:
- PRIVATE – the requirement is used only when building the target itself. Consumers of the target do not inherit the setting.
- PUBLIC – the requirement is used both when building the target and for any target that links to it.
- INTERFACE – the requirement is used only by consumers; the target itself does not need it. This is ideal for header‑only libraries.
A common pitfall is marking a dependency as PUBLIC when it is only needed in the target’s implementation files. Doing so forces every consumer to link against the dependency, increasing coupling and potentially pulling in unnecessary libraries. The rule of thumb: use PUBLIC only when the dependency is exposed through the target’s public headers.
Additional Tips
- SYSTEM keyword – add
SYSTEMtotarget_include_directoriesfor third‑party headers to silence compiler warnings that originate inside them. - ALIAS targets – expose an internal library as an external‑looking target with
add_library(myproj::mathlib ALIAS mathlib). Downstream code can thenfind_package(myproj)and linkmyproj::mathlibjust like any third‑party library. - Always use the *keyword* form of
target_link_libraries(e.g.target_link_libraries(myTarget PUBLIC fmt::fmt)) rather than the legacy comma‑separated form. Mixing the two on the same target causes CMake to reject the configuration.
Verifying the Switch
After refactoring, you can confirm that the build behaves as expected with the following checks:
- Compile‑commands inspection
The output should list include paths and definitions only for the targets that declared them.cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON . cat build/compile_commands.json | grep -e "-I" -e "-D" | sort | uniq - Verbose build output
Look at the compiler and linker lines forcmake --build build --verboseapp– the include paths frommathlibshould appear, but not the private includes ofmathlib. - Dependency graph
Rendercmake --graphviz=deps.dot .deps.dotwith Graphviz and verify that edges reflect the PUBLIC/PRIVATE semantics you intended. - Install‑and‑consume test
The consumer should be able tocmake --install build --prefix /tmp/install cd /tmp/consumer cmake -DCMAKE_PREFIX_PATH=/tmp/install .. cmake --build .find_package(MathExample)and linkappwithout manually adding any include or link flags.
Practical Next Steps
- Run a grep for
include_directories,add_definitions, andadd_compile_optionsin your repository. Prioritize targets that are large or have many consumers. - For each target, replace the directory‑scoped calls with the corresponding
target_…command. Keep the visibility keyword in mind – start withPRIVATEand adjust toPUBLIConly if the target’s public headers use the dependency. - After each change, rebuild the target and run the verification steps above. This incremental approach keeps the risk low.
- Once all targets are target‑scoped, consider adding
ALIAStargets for internal libraries that you want to expose as part of the public API. - Update your
READMEor documentation to explain the new usage requirements to downstream developers.
By moving from global to target‑scoped usage requirements, you eliminate accidental include leaks, make dependency propagation explicit, and align your project with modern CMake best practices. The transition is mechanical but highly rewarding – the build becomes more robust, easier to reason about, and future‑proof against directory reorganisations.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.