Stop leaking internal dependencies in CMake with target visibility
Use PUBLIC, PRIVATE, and INTERFACE in target_link_libraries to control which dependencies reach consumers and avoid unnecessary rebuilds.
29 Jul 2026, 06:32 UTC

The problem: a private change rebuilds everything
You have a static library that uses an external logging header internally. Nothing in the library’s public header mentions that logger. Still, when you update the logger, every executable and test that links to your library rebuilds. Worse, a consumer might fail to compile because it never received an include path that only mattered inside your library.
This happens when CMake dependency visibility is wrong. Modern CMake uses target-based dependency management: instead of global commands like include_directories(), you attach dependencies to specific targets with target_link_libraries() and a visibility keyword. The keyword decides whether a dependency is an implementation detail or part of the target’s public interface.
How PUBLIC, PRIVATE, and INTERFACE differ
target_link_libraries(target visibility dependency) accepts three modifiers:
- PUBLIC: the dependency is needed to build the target and to use it. Consumers inherit include paths, compile definitions, and linked libraries.
- PRIVATE: the dependency is needed only to build the target. Consumers do not see it.
- INTERFACE: the dependency is not needed to build the target itself, but consumers need it. Typical for header-only libraries or interface libraries that produce no compiled object.
A dependency declared PUBLIC leaks into the transitive dependency graph. A PRIVATE dependency stays inside the target’s build boundary. This distinction directly affects rebuild scope: changing a PRIVATE dependency should not force consumers to recompile or relink.
Worked example: an internal logging dependency
Suppose math_utils is a static library. Its public header declares double add(double, double), but its implementation logs to a file using the external spdlog library. Consumers of math_utils never include spdlog headers.
Create a project directory, then write CMakeLists.txt:
cmake_minimum_required(VERSION 3.14)
project(math_demo LANGUAGES CXX)
add_library(math_utils STATIC
src/math_utils.cpp
include/math_utils.h
)
# spdlog is an implementation detail. Consumers do not need it.
target_link_libraries(math_utils PRIVATE spdlog::spdlog)
add_executable(calculator src/calculator.cpp)
target_link_libraries(calculator PRIVATE math_utils)
Run these commands in a terminal from the project root. No special permissions are required; CMake only reads and writes files inside the build directory.
cmake -S . -B build
cmake --build build
Expected check: calculator should link against math_utils but not directly against spdlog. If you change spdlog headers, only math_utils objects should rebuild, not calculator.
Risk: if you accidentally declare spdlog as PUBLIC on math_utils, every consumer inherits spdlog include paths. That couples consumers to a dependency they do not use and expands the rebuild graph.
Verify the dependency graph
CMake can emit a Graphviz file to inspect relationships:
cmake --graphviz=build/deps.dot build
Open deps.dot or render it with Graphviz. Look for an edge from calculator to math_utils and confirm there is no direct edge from calculator to spdlog. This works with Ninja, Makefiles, and Visual Studio generators, though edge labels may vary by generator.
Limitations and when to use INTERFACE
INTERFACE libraries do not compile source files. They exist only to collect requirements and hand them to consumers. If math_utils were header-only, you would use an INTERFACE library instead of a static target.
Mixing modern target-based commands with legacy directory commands such as include_directories() creates global state and can override or obscure target-specific visibility. Prefer one style per project, and migrate legacy directories gradually rather than mixing them in the same target.
Visibility errors are silent at configure time. A missing PUBLIC declaration produces compile errors in consumers; an unnecessary PUBLIC declaration produces coupling you only notice during refactoring. There is no compiler warning for “too public.”
Practical closing rule
- Default new dependencies to
PRIVATE. - Promote to
PUBLIConly when a consumer must include the dependency’s headers or link against it directly. - Use
INTERFACEfor pure header collections or interface libraries. - Verify with
cmake --graphvizbefore merging changes to shared libraries.
Correct visibility keeps build graphs small, compile times predictable, and consumers free from dependencies they never asked for.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.