Propagating Compile Options with Modern CMake: A Practical Guide to PUBLIC/PRIVATE/INTERFACE
Modern CMake’s target‑based API replaces global include/definition commands with per‑target visibility keywords (PUBLIC/PRIVATE/INTERFACE). Learn how to propagate compile options correctly, avoid legacy pitfalls, and verify propagation with a concrete example.
10 Mar 2026, 17:25 UTC

Problem: Global Include & Definition Pollution in Legacy CMake
Before CMake 3.0, most projects relied on directory‑scope commands such as include_directories(), add_definitions(), and link_libraries(). These commands silently modify the global state of the current directory and all its sub‑directories, making it hard to reason about which compile flags reach which target. A change in one place can unintentionally affect unrelated targets, leading to hard‑to‑track build failures and fragile cross‑platform code.
Modern Target‑Based API: The Three Visibility Keywords
Starting with CMake 3.0, the target_*() family of commands models each build artifact as a node in a directed acyclic graph. Every target carries its own set of “usage requirements” – compile options, include directories, preprocessor definitions, and link libraries. When a target links to another, the visibility of those requirements is controlled by one of three keywords:
- PRIVATE – Apply only when building the target itself.
- PUBLIC – Apply to the target and propagate to anything that links it.
- INTERFACE – Never apply to the target itself, but propagate to consumers.
This model mirrors real‑world API boundaries: implementation details stay private, public API surface is exported, and interface‑only libraries simply carry usage data.
Concrete Worked Example
Below is a minimal two‑target project that demonstrates how PUBLIC and PRIVATE propagate compile options automatically.
# CMakeLists.txt in the project root
cmake_minimum_required(VERSION 3.20)
project(MathDemo LANGUAGES CXX)
# --- Library ------------------------------------------------------------
add_library(mathlib STATIC src/math.cpp)
# Public include directory and a compile definition that the library’s API
# requires the consumer to know.
# These will be added to the consumer’s compile command when it links
# mathlib with PUBLIC.
target_include_directories(mathlib PUBLIC $ $)
target_compile_definitions(mathlib PUBLIC USE_SIMD=1)
# Internal implementation detail – only used when building mathlib.
# This flag is *not* propagated to consumers.
target_compile_definitions(mathlib PRIVATE LOG_INTERNALS=1)
# --- Executable -----------------------------------------------------------
add_executable(app src/main.cpp)
# Link the library as PUBLIC – app gets USE_SIMD and the include path.
# The LOG_INTERNALS flag does *not* leak into app.
target_link_libraries(app PUBLIC mathlib)
# --- Test executable (PRIVATE link) ---------------------------------------
add_executable(test src/test.cpp)
# PRIVATE link: test gets the same build‑time requirements but does not export
# them to its consumers.
target_link_libraries(test PRIVATE mathlib)
Build the project with a generator that produces a compile_commands.json (e.g. Ninja). Inspect the entry for app:
{
"directory": "/path/to/build",
"command": "g++ -I/path/to/project/include -DUSE_SIMD=1 -DUSE_SIMD=1 -o app src/main.cpp",
"file": "src/main.cpp"
}
You will see the -I include path and the -DUSE_SIMD=1 definition automatically applied, while LOG_INTERNALS is absent. Running the same for test shows the same flags, confirming that PRIVATE propagation works as expected.
Trade‑offs & Limitations
- Mixing Legacy and Modern Commands – If a sub‑directory still uses
include_directories(), the global state will interfere with the target‑based graph. The safest approach is to migrate an entire sub‑tree at once. - INTERFACE Libraries –
add_library(name INTERFACE)cannot have source files. Attempts to calltarget_sources()on them will fail during configuration. - Generator Expressions – Expressions like
$<CONFIG:Debug>are evaluated at generate time, not configure time. Therefore, conditionals that depend on generator expressions cannot be written inif()statements. - Policy Sensitivity – Certain behaviors changed across CMake versions (e.g.
target_include_directorieson INTERFACE libs was disallowed before 3.13). Always verify the current policy withcmake --help-policybefore using a feature. - Cyclic Dependencies – Cycles are caught by CMake, but if a cycle is introduced via a generator expression in an INTERFACE link list, the error may surface only at build time.
Actionable Take‑away
To avoid global state surprises and to fully leverage CMake’s dependency graph:
- Use
target_include_directories,target_compile_definitions, andtarget_link_librariesfor every target. - Choose the visibility keyword that matches the API surface:
PRIVATEfor implementation details,PUBLICfor API‑visible requirements,INTERFACEfor pure usage data. - Verify propagation by generating
compile_commands.jsonor runningcmake --graphviz=deps.dotto inspect the dependency graph. - When packaging, use generator expressions like
$<BUILD_INTERFACE:…>and$<INSTALL_INTERFACE:…>to differentiate build‑time from install‑time paths. - Keep the entire target subtree in sync with the same CMake version to avoid policy drift.
Adopting the modern, per‑target API not only makes your CMakeLists easier to read, but also gives you fine‑grained control over what each consumer sees, leading to more robust, maintainable, and portable builds.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.