Stop Leaking Includes: How CMake Target Visibility Fixes Dependency Hell
CMake's target-based API with PUBLIC/PRIVATE/INTERFACE visibility stops private headers from leaking into downstream consumers. A three-target worked example shows how compile_commands.json reflects correct propagation, and generator expressions enable relocatable installs.
05 Oct 2026, 12:20 UTC

The Problem: Include Paths That Travel Too Far
You add a dependency to your library, and suddenly every downstream consumer picks up its private headers. A change to an internal detail triggers rebuilds across the repository. This is the classic symptom of variable-based CMake: include_directories() and link_libraries() apply globally, so implementation details leak into the transitive closure whether you want them to or not.
The fix isn't a new command—it's a discipline. Modern CMake's target-based API (target_link_libraries, target_include_directories, target_compile_features) attaches usage requirements to targets and propagates them only along the link graph, with PUBLIC, PRIVATE, and INTERFACE controlling the scope. Adopting this model eliminates whole classes of fragile builds.
Visibility Keywords Control Propagation
Each call to target_include_directories or target_compile_definitions takes a visibility specifier:
- PUBLIC – the requirement is needed to compile this target and any target that links to it. Use for headers that appear in your public API.
- PRIVATE – the requirement is only for building this target's own sources. Internal implementation headers, generated files, or private macros belong here.
- INTERFACE – the target itself doesn't compile sources (common for header-only libraries), but consumers need the requirement.
CMake builds a directed graph from target_link_libraries calls. When target B links PUBLIC to A, A's PUBLIC and INTERFACE requirements flow to B. PRIVATE requirements stop at A. This is transitive by design: if C links B, it receives A's public requirements automatically.
Generator Expressions Separate Build and Install Trees
A library's include path differs between the build directory (${CMAKE_CURRENT_BINARY_DIR}/include) and the install prefix (include). Generator expressions let you encode both in one declaration:
target_include_directories(mylib
PUBLIC
$
$
)
$ expands only when generating the build system; $ expands only in the installed config file. The result is a relocatable package: consumers find headers at /include without hardcoded absolute paths.
Common mistake: wrapping a generator expression in an if() condition. Generator expressions evaluate at generation time, not configure time, so if($) is always false. Use them directly in target commands or in target_compile_definitions with $.
Worked Example: Three-Target Chain
Consider a minimal project: core (static library), utils (static library depending on core), and app (executable depending on utils).
# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(Demo LANGUAGES CXX)
add_library(core STATIC core.cpp)
target_include_directories(core
PUBLIC include # public API headers
PRIVATE src # internal headers
)
target_compile_features(core PUBLIC cxx_std_20)
add_library(utils STATIC utils.cpp)
target_link_libraries(utils PUBLIC core) # propagate core's PUBLIC/INTERFACE
# utils adds its own public headers
target_include_directories(utils PUBLIC include)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE utils) # app doesn't propagate further
Run this in a fresh build directory:
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build
Inspect build/compile_commands.json. The core compile entry shows -I.../include -I.../src. The utils entry shows -I.../include -I.../core/include (core's public path only). The app entry shows -I.../include -I.../utils/include -I.../core/include. core/src never appears in utils or app—the private include is contained.
To verify the install-time view, install to a staging prefix and examine the generated config:
cmake --install build --prefix /tmp/stage
cat /tmp/stage/lib/cmake/Demo/DemoTargets.cmake
You'll see INTERFACE_INCLUDE_DIRECTORIES populated with $ paths, confirming relocatability.
Trade-offs and Pitfalls
- Mixing old and new: Calling
include_directories()anywhere in the project poisons the target model. Global includes apply to every target, bypassing visibility. Migrate fully or isolate legacy code in a separate subdirectory with its ownCMakeLists.txtthat doesn't leak. - PUBLIC overuse: Marking internal headers
PUBLICcouples consumers to implementation details. Every change to that header triggers downstream recompiles. Default toPRIVATE; promote only when a header is part of the documented API. - Header-only dependencies must be linked: Transitive propagation follows
target_link_librariesedges, not#includeedges. A header-only library must be anINTERFACEtarget and appear intarget_link_librariesfor its requirements to propagate. - Generator expressions in conditions: As noted,
if($)evaluates to empty string. Usetarget_compile_definitions(foo PRIVATE $<$:DEBUG_BUILD>)instead.
Actionable Checklist
- Audit your top-level
CMakeLists.txtforinclude_directories,link_directories,add_definitions, orlink_libraries. Replace each with target-scoped equivalents. - For every
target_include_directoriescall, decide: does a consumer need this header to compile? If yes,PUBLIC; otherwisePRIVATE. - Add
target_compile_features(mylib PUBLIC cxx_std_20)(or your required standard) instead of settingCMAKE_CXX_STANDARDglobally. The requirement propagates transitively. - Run
cmake --graphviz=graph.dotand render withdot -Tpng graph.dot -o graph.pngto visualize the link graph and confirm propagation direction. - Test a clean install:
cmake --install build --prefix /tmp/test && cmake -S /tmp/test -B /tmp/test-buildto verify config-mode packages work.
The target model isn't just syntax—it's a contract between library authors and consumers. Honor the visibility boundaries, and your dependency graph stays maintainable as the project grows.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.