Stop Leaking Includes: Use PUBLIC PRIVATE INTERFACE in Modern CMake
Use target_include_directories and target_link_libraries with PUBLIC, PRIVATE and INTERFACE to encapsulate what a CMake target needs to build and what its consumers need to use it, preventing leaking includes and hidden coupling.
30 Nov 2025, 08:43 UTC

The build breaks when you add a new consumer to a library, even though the library itself compiles fine. The executable can’t find headers that were visible when you built the library in isolation. That is the classic symptom of global include paths and link flags leaking in the wrong direction.
The useful takeaway is to stop describing the project with directory-scoped variables and describe each target with its own usage requirements. In CMake 3.0+ that means target_include_directories and target_link_libraries with the PUBLIC, PRIVATE, INTERFACE keywords. The target then encapsulates what it needs to build and what its consumers need to use it.
Why global commands create hidden coupling
Commands like INCLUDE_DIRECTORIES and LINK_LIBRARIES set state for the current directory and all subdirectories. Changing a variable in one CMakeLists.txt silently changes compile commands for unrelated targets. It is hard to reason about which include path comes from where, and refactoring a library often breaks consumers unexpectedly.
Target-based properties attach requirements to a specific target. When another target links to it, CMake propagates only the parts marked for propagation. This makes the dependency graph explicit.
Propagation keywords in practice
target_include_directories and target_link_libraries accept a visibility keyword per usage:
- PRIVATE: used only while building the target itself. Not propagated to consumers.
- INTERFACE: not used by the target itself, but required by consumers. Useful for header-only libraries.
- PUBLIC: used by the target and propagated to consumers. Equivalent to PRIVATE + INTERFACE.
For example, a library that includes its own internal headers from src/ and exposes public headers from include/ should mark src/ as PRIVATE and include/ as PUBLIC. A consumer linking to the library will get include/ automatically, but not src/.
Worked example: library and executable
Assume a project layout with a static library mathlib and an executable app that uses it.
# CMakeLists.txt project root
cmake_minimum_required(VERSION 3.15)
project(Example LANGUAGES CXX)
add_subdirectory(mathlib)
add_subdirectory(app)
# mathlib/CMakeLists.txt
add_library(mathlib STATIC
src/add.cpp
src/mul.cpp
)
target_include_directories(mathlib
PUBLIC
<PROJECT_SOURCE_DIR>/mathlib/include
PRIVATE
<PROJECT_SOURCE_DIR>/mathlib/src
)
# internal implementation detail, not exposed
target_compile_definitions(mathlib PRIVATE MATHLIB_INTERNAL)
# app/CMakeLists.txt
add_executable(app src/main.cpp)
target_link_libraries(app PRIVATE mathlib)
Run configuration from the project root with write permission to the build directory:
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
Risk: mixing legacy INCLUDE_DIRECTORIES with target_include_directories can cause duplicate or missing -I flags because directory scope and target scope are merged unpredictably. Avoid both in the same project.
To check propagation, inspect compile_commands.json in build/. Look for the compile command for app/src/main.cpp. It should contain -I pointing to mathlib/include but not mathlib/src. If mathlib’s include directory was marked PRIVATE, the -I would be absent and compilation would fail because main.cpp includes mathlib/public headers.
Trade‑off and limitation
Overusing PUBLIC is the most common mistake. Marking an internal dependency PUBLIC forces every downstream target to inherit its include paths and link flags, even if they never use it. This bloats compile lines and can introduce symbol conflicts.
Conversely, marking a header that is part of the public API as INTERFACE or PRIVATE will cause compilation errors in the target itself or in consumers. INTERFACE is not used by the defining target, so internal headers must not be INTERFACE only.
Limitations also appear with transitive system libraries. If a library links PRIVATE to a system library, consumers do not link to it automatically, which is usually correct. If the library’s public headers require symbols from that system library, the dependency must be PUBLIC, accepting the broader exposure.
A practical check is to temporarily change a dependency from PUBLIC to PRIVATE and reconfigure. Consumers should still build if the dependency is truly internal. If they break, the dependency was part of the public usage requirement and should be PUBLIC.
Encapsulate each target’s needs with target_include_directories and target_link_libraries, use PRIVATE by default, and promote to PUBLIC only when the requirement is visible in the public headers. That keeps builds modular and propagation predictable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.