Stop Using Global Variables: Transitioning to Target-Based CMake
Stop polluting your build with global include paths. Learn how to use Target-Based CMake to encapsulate dependencies and simplify project modularity.
13 Mar 2026, 02:24 UTC

The Problem with Global Build State
Many legacy CMake projects rely on directory-level commands like include_directories() or add_definitions(). These commands apply settings to every target defined in the current directory and all subdirectories. As a project grows, this creates a "pollution" problem: a utility library might accidentally inherit compiler flags or include paths intended only for the main executable, leading to fragile builds and hidden dependencies.
The solution is Target-Based CMake (often called "Modern CMake"). Instead of treating the build file as a script that sets global state, you treat it as a collection of objects (targets) that encapsulate their own requirements and export them to whoever needs them.
Understanding Property Propagation
In a target-based workflow, you define properties on a specific target using target_* commands. The critical distinction is how these properties propagate using three keywords: PRIVATE, INTERFACE, and PUBLIC.
- PRIVATE: The property is used to build this target, but is not passed to anyone linking to it. Use this for internal
.cppimplementation details. - INTERFACE: The property is not used to build this target, but is required by anyone who links to it. This is essential for header-only libraries.
- PUBLIC: A combination of both. The property is used to build the target and is also passed to all consumers.
Worked Example: Modular Library Setup
Assume a project structure with a library math_utils and an executable app. We want app to automatically know where math_utils headers are located without manually calling include_directories in the top-level file.
# In libs/math_utils/CMakeLists.txt
add_library(math_utils STATIC src/math_utils.cpp)
# Define where the headers are.
# PUBLIC means: "I need these to build, and you need them to use me."
target_include_directories(math_utils PUBLIC <${CMAKE_CURRENT_SOURCE_DIR}/include>)
# In src/CMakeLists.txt
add_executable(app main.cpp)
# This single command pulls in the library AND the include paths defined above
target_link_libraries(app PRIVATE math_utils)
Verification: To verify this is working, run the build with a verbose flag (e.g., make VERBOSE=1 or ninja -v). You should see the -I include flag for math_utils appearing during the compilation of main.cpp, even though app never explicitly called an include directory command.
Handling Header-Only Libraries
A common struggle in legacy CMake is managing libraries that have no source files. Using add_library(... STATIC) without sources triggers an error. Modern CMake solves this with INTERFACE libraries.
# Create a target that doesn't produce a binary file
add_library(json_parser INTERFACE)
# Only provide the interface; no private build steps exist
target_include_directories(json_parser INTERFACE <${CMAKE_CURRENT_SOURCE_DIR}/include>)
Trade-offs and Limitations
While target-based CMake is cleaner, it requires discipline. A common pitfall is the "Public Bloat": marking every dependency as PUBLIC. When you do this, you create a massive chain of inherited flags. If Target A links Target B PUBLICly, and Target C links Target A, then Target C inherits everything from both. This can significantly increase compile times and lead to naming collisions in large-scale projects.
Additionally, mixing legacy commands (like link_libraries()) with target-based commands can lead to unpredictable behavior, as global settings may override target-specific ones in ways that are difficult to debug.
Actionable Transition Path
If you are migrating a legacy project, do not rewrite the entire build system at once. Follow this sequence:
- Identify a leaf-node library (one that depends on nothing else).
- Replace its
include_directories()withtarget_include_directories(). - Update the targets that link to it to use
target_link_libraries(). - Verify the build output to ensure no global flags are still leaking into the target.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.