Stop Leaking Include Paths: Moving CMake from Global Flags to Target Properties
Global CMake commands like include_directories leak settings into unrelated targets. Switching to target properties with PRIVATE/INTERFACE/PUBLIC visibility fixes dependency management for good.
05 Jul 2025, 19:03 UTC

If you've ever added a new library to a CMake project and suddenly watched an unrelated executable fail to compile — or worse, compile against the wrong header — you've met the global-state problem. The fix isn't a clever flag; it's a different mental model. Modern CMake (3.0 and later) treats libraries and executables as objects with their own properties, and once you adopt that model, dependency problems mostly disappear.
The global-state trap
Older CMake style leans on directory-level commands:
include_directories(${CMAKE_SOURCE_DIR}/third_party/json)
add_definitions(-DUSE_FAST_MATH)
link_libraries(pthread)These commands apply to every target defined after them in that directory scope. That feels convenient in a small project. In a growing one it becomes pollution: your command-line tool inherits include paths meant only for the GUI app, a test binary picks up a define that changes its behavior, and removing a library means hunting down every place its settings leaked. The build works, but nobody can say why it works.
Targets as objects
The target-based paradigm flips this. You create a target, then attach everything it needs directly to it:
add_library(mathlib STATIC src/vector.cpp src/matrix.cpp)
target_include_directories(mathlib ...)
target_compile_definitions(mathlib ...)
target_link_libraries(mathlib ...)Each property travels with the target. If mathlib moves to a subdirectory, gets reused in another project via add_subdirectory or find_package, or is consumed by three different executables, its requirements come along automatically. Nothing leaks sideways into siblings.
PRIVATE, INTERFACE, PUBLIC: the part people skip
The visibility keywords are where the real dependency management happens:
- PRIVATE — needed to build the target itself, but consumers never see it. Implementation details live here.
- INTERFACE — the target doesn't need it, but consumers do. Header-only libraries are the classic case.
- PUBLIC — both. Your public headers reference a dependency's types, so consumers need it too.
Getting these right is what keeps downstream compile lines short and correct.
Worked example: MathLib and MainApp
Say mathlib has a public API in include/ and private helpers in src/detail/, and it uses Boost internally but not in its public headers:
add_library(mathlib STATIC
src/vector.cpp
src/matrix.cpp
)
# Consumers need include/; only mathlib needs src/detail/
target_include_directories(mathlib
PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src/detail
)
# Boost is an implementation detail
target_link_libraries(mathlib PRIVATE Boost::boost)
add_executable(mainapp app/main.cpp)
target_link_libraries(mainapp PRIVATE mathlib)Because include/ is PUBLIC, mainapp automatically gets -I.../include on its compile line — you never stated that explicitly. Because Boost is PRIVATE, mainapp does not get Boost include paths. If you later make Boost part of the public API, you change one keyword and every consumer updates correctly.
Verifying it actually worked
Don't trust the CMakeLists.txt; inspect the generated build. Configure with the Ninja or Makefile generator, then check what flags a consumer actually receives:
cmake -S . -B build -G Ninja
grep -r "mainapp" build/CMakeFiles/mainapp.dir/ -l # locate its build rules
ninja -C build -t commands mainapp | head # Ninja: show compile commandsRun these from the project root; no special permissions needed. Alternatively, configure with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON and inspect build/compile_commands.json. You should see mathlib/include on mainapp's compile line and src/detail absent from it. If a path you expected to be private shows up in a consumer's flags, some command is still setting it globally or with PUBLIC/INTERFACE visibility.
The honest trade-offs
Target-based CMake is more verbose up front. A two-file toy project genuinely is shorter with include_directories. The payoff scales with project size: each new target adds a constant amount of local configuration instead of an unpredictable amount of global coupling. Two cautions worth repeating: first, resist making everything PUBLIC "just in case" — bloated transitive include paths are the same pollution problem wearing a modern costume. Second, don't mix styles. A stray include_directories() at the top of a subdirectory silently overrides your careful visibility design, and the resulting behavior is confusing to debug. Pick the target-based model and apply it consistently; CMake 3.0+ has supported it for a decade, so unless you're maintaining something truly legacy, there's no version excuse left.
The actionable step: the next time you're tempted to type a directory-level command, ask which target actually owns that setting, and attach it there. Your future consumers — including future you — will inherit exactly what they need and nothing else.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.