CMake Targets: PUBLIC, PRIVATE and INTERFACE Visibility and Transitive Dependencies
Key takeaways
Modern CMake describes a build as targets with usage requirements: add_executable/add_library create them, target_* commands attach include paths, definitions and links, and PUBLIC/PRIVATE/INTERFACE decide what propagates to dependents. Covers OBJECT and INTERFACE libraries, ALIAS targets, the generator-expression pitfalls, and a multi-library layout.
What are CMake Targets? Why Target Based?
Problem Scenario: Confusion in global settings
Older CMake styles used global commands like include_directories() and link_libraries(). They apply to every target defined afterwards in the same directory and in its subdirectories. As a project grows, nobody can tell which target actually needs which header or library, and dependencies become accidental.
# ❌ Old school: global settings
include_directories(/usr/local/include)
link_libraries(boost_system)
add_executable(app1 main1.cpp)
add_executable(app2 main2.cpp)
# app1, app2 are both linked to boost_system (maybe unintentional)
Solution: Using target-based commands (target_*) makes the dependencies of each target explicit and removes unnecessary links.
# ✅ Modern: Target-specific settings
add_executable(app1 main1.cpp)
target_include_directories(app1 PRIVATE /usr/local/include)
target_link_libraries(app1 PRIVATE Boost::system)
add_executable(app2 main2.cpp)
# app2 is not linked to boost
The difference goes beyond tidiness. With global settings, removing a dependency from one executable means auditing every other target in the directory, and adding a new directory with its own include_directories can change which header another target picks up when two libraries ship a file with the same name. With targets, the dependency graph in CMakeLists.txt is the real dependency graph, and IDEs, cmake --graphviz and packaging all read the same information.
This article stays inside one project’s target graph: creating targets, the target_* commands, and how PUBLIC, PRIVATE and INTERFACE decide what propagates. Pulling in external dependencies (find_package, FetchContent), generator expressions, and making your library installable for other projects are covered in Advanced CMake: Multi-Target Projects, External Dependencies and install().
What is a target?
A target is something CMake builds (an executable or a library) or something it can link to (an imported library). You create targets with add_executable and add_library and attach properties to them with the target_* commands. Some of those properties are usage requirements: the include paths, compile definitions, compile options and link libraries that anything using the target also needs. That is the core idea of modern CMake: a library carries its own requirements, and linking it is enough to get them.
flowchart TD
subgraph targets[targets]
exe["add_executable(myapp)"]
lib["add_library(mylib)"]
end
subgraph properties[Target properties]
inc[target_include_directories]
link[target_link_libraries]
opt[target_compile_options]
def[target_compile_definitions]
end
exe --> inc
exe --> link
lib --> inc
lib --> opt
Create target
Executable
# single source
add_executable(myapp main.cpp)
# Multiple sources
add_executable(myapp
src/main.cpp
src/utils.cpp
src/config.cpp
)
# Use variables
set(APP_SOURCES
src/main.cpp
src/utils.cpp
)
add_executable(myapp ${APP_SOURCES})
List source files explicitly rather than with file(GLOB ...). A glob is evaluated when CMake configures, so a newly added .cpp file is not compiled until someone reruns CMake, and the build appears to “forget” code. CONFIGURE_DEPENDS makes globs re-check on every build, at some cost, and the CMake documentation still recommends explicit lists.
Static library
add_library(mylib STATIC
src/lib.cpp
src/helper.cpp
)
Dynamic library
add_library(mylib SHARED
src/lib.cpp
src/helper.cpp
)
Without STATIC or SHARED, add_library(mylib src/lib.cpp) follows the BUILD_SHARED_LIBS variable (static by default), which lets users choose. Shared libraries bring platform details that static ones do not: on Windows, symbols must be exported (__declspec(dllexport), or the WINDOWS_EXPORT_ALL_SYMBOLS property), otherwise no .lib import library is produced and linking fails with LNK1104: cannot open file 'mylib.lib'. On Linux, code going into a shared library must be position-independent, which CMake handles for the library itself but not for static libraries you link into it; set POSITION_INDEPENDENT_CODE ON on those, or the link fails with relocation R_X86_64_32 against ... can not be used when making a shared object; recompile with -fPIC.
Header-only library
add_library(mylib INTERFACE)
target_include_directories(mylib INTERFACE include)
OBJECT library
# Create only object files (no linking)
add_library(myobj OBJECT
src/common.cpp
)
# Reuse on multiple targets
add_executable(app1 main1.cpp $<TARGET_OBJECTS:myobj>)
add_executable(app2 main2.cpp $<TARGET_OBJECTS:myobj>)
An OBJECT library compiles its sources once and hands the .o files to whoever uses them, without creating an archive. Compared with a static library, every object is linked in, even ones no symbol refers to. That matters for code that works through static initialization (self-registering plugins, test cases), which a static library can silently drop because the linker only pulls in archive members that resolve an undefined symbol.
Target properties: target_* commands
target_include_directories
add_library(mylib src/lib.cpp)
target_include_directories(mylib
PUBLIC include # exposed to targets that link mylib
PRIVATE src/internal # used only while compiling mylib
)
Relative paths are interpreted relative to the current source directory. For a library that will be installed, the include path in the build tree and in the install tree differ, which is what $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include> and $<INSTALL_INTERFACE:include> express; without them, install(EXPORT ...) fails with Target "mylib" INTERFACE_INCLUDE_DIRECTORIES property contains path ... which is prefixed in the source directory.
target_link_libraries
add_executable(myapp main.cpp)
find_package(Threads REQUIRED)
target_link_libraries(myapp PRIVATE
mylib
Boost::filesystem
Threads::Threads
)
target_link_libraries accepts three kinds of names: targets (mylib, Boost::filesystem), full paths to library files, and plain names that become -lname flags. Only targets carry usage requirements, so prefer them. Threads::Threads is the portable way to request thread support; a bare pthread works on Linux but hard-codes a platform detail and skips the compile flag some platforms need.
target_compile_options
target_compile_options(myapp PRIVATE
-Wall
-Wextra
-Werror
"$<$<CONFIG:Debug>:-O0;-g>"
"$<$<CONFIG:Release>:-O3>"
)
Two details in this block are common sources of confusion. The generator expressions are quoted and use ; between flags: an unquoted $<$<CONFIG:Debug>:-O0 -g> is split at the space into two arguments, neither of which is a complete generator expression, and CMake stops with Error evaluating generator expression or passes garbage to the compiler. And the per-configuration flags are mostly redundant: CMake already adds -O0 -g-style flags for Debug and -O3 -DNDEBUG for Release through CMAKE_CXX_FLAGS_<CONFIG>, so adding them again is only needed to override those defaults. CONFIG also only has a value when a build type is set; with single-config generators and no CMAKE_BUILD_TYPE, neither expression applies.
-Werror on a library that others build is a trade-off: it keeps your own tree clean, but a newer compiler with a new warning then breaks the build for every downstream user. Many projects enable it only in CI or behind an option.
target_compile_definitions
target_compile_definitions(myapp PRIVATE
APP_VERSION="1.0"
$<$<CONFIG:Debug>:DEBUG_MODE>
$<$<PLATFORM_ID:Windows>:WINDOWS_BUILD>
)
target_compile_features
# C++20 feature requirements
target_compile_features(myapp PRIVATE cxx_std_20)
target_compile_features(... PUBLIC cxx_std_20) on a library means “anything that includes my headers needs at least C++20”, and CMake raises the standard of dependents accordingly. set(CMAKE_CXX_STANDARD 20) is a directory-wide default instead. Using the feature on the target is the more precise statement, because a library’s headers, not the project, decide which standard they need.
Visibility: PUBLIC, PRIVATE, INTERFACE
Concept
flowchart LR
subgraph lib[mylib]
priv["PRIVATE\nInternal use only"]
pub["PUBLIC\nExternal exposure"]
iface["INTERFACE\nPropagation only"]
end
subgraph app[myapp]
use[use]
end
pub --> use
iface --> use
| Keyword | Used when building the target itself | Passed to targets that link it |
|---|---|---|
| PRIVATE | yes | no |
| PUBLIC | yes | yes |
| INTERFACE | no | yes |
The question that decides the keyword is “does this appear in my public headers?” If mylib.h includes <boost/filesystem.hpp> or uses a MYLIB_VERSION macro, every user compiling against that header needs the Boost include path or the macro, so the dependency is PUBLIC. If Boost is used only inside lib.cpp, it is PRIVATE, and users do not need to know it exists. Marking everything PUBLIC works, but it leaks include paths and macros into every dependent target, slows compilation, and makes it impossible to swap the implementation later without breaking users.
Practical example
# mylib: library
add_library(mylib src/lib.cpp)
target_include_directories(mylib
PUBLIC include # targets linking mylib also get include/
PRIVATE src/internal # only used inside mylib
)
target_compile_definitions(mylib
PUBLIC MYLIB_VERSION=1 # also defined in targets linking mylib
PRIVATE MYLIB_INTERNAL # defined only inside mylib
)
# myapp: executable
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE mylib)
# myapp can #include files from include/ (PUBLIC)
# myapp cannot see src/internal (PRIVATE)
# MYLIB_VERSION is defined in myapp (PUBLIC)
INTERFACE use case
Header-only libraries have nothing to compile, so every requirement is for their users.
add_library(header_only INTERFACE)
target_include_directories(header_only INTERFACE include)
target_compile_definitions(header_only INTERFACE HEADER_ONLY_LIB)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE header_only)
# myapp can use include/
# HEADER_ONLY_LIB defined in myapp
INTERFACE libraries are also useful for bundles of settings with no code at all, such as the project_options target in section 6.
Dependency propagation
Transitive dependencies
# liba: lowest level library
add_library(liba STATIC a.cpp)
target_include_directories(liba PUBLIC include/a)
# libb: Depends on liba
add_library(libb STATIC b.cpp)
target_link_libraries(libb PUBLIC liba)
target_include_directories(libb PUBLIC include/b)
# myapp: Depends on libb
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE libb)
# myapp can use both include/a and include/b (PUBLIC propagation)
flowchart TD
liba["liba\nPUBLIC include/a"]
libb["libb\nPUBLIC include/b"]
myapp[myapp]
libb -->|PUBLIC| liba
myapp -->|PRIVATE| libb
note1["myapp can use both include/a and include/b"]
Block propagation with PRIVATE
# libb links liba as PRIVATE
target_link_libraries(libb PRIVATE liba)
# myapp
target_link_libraries(myapp PRIVATE libb)
# myapp only gets include/b (liba's usage requirements are not propagated)
One subtlety: for static libraries, PRIVATE stops the compile requirements, but not the link. A static libb is just an archive of object files with unresolved references to liba, so CMake still puts liba on myapp’s link line (as $<LINK_ONLY:liba>). That is correct and necessary; without it, the final link fails with undefined references. What PRIVATE guarantees is that myapp’s code cannot accidentally #include headers from liba and start depending on it.
Common problems and solutions
Issue 1: Using global commands
Cause: Global commands such as include_directories() and link_libraries() affect all targets defined later in the directory tree.
# ❌ Incorrect use
include_directories(/usr/local/include)
add_executable(app1 main1.cpp)
add_executable(app2 main2.cpp)
# Both app1 and app2 use /usr/local/include
# ✅ Correct use: Target-specific settings
add_executable(app1 main1.cpp)
target_include_directories(app1 PRIVATE /usr/local/include)
add_executable(app2 main2.cpp)
# app2 is not affected
Issue 2: PUBLIC/PRIVATE confusion
Symptom: fatal error: foo.h: No such file or directory in a dependent target, or internal headers usable from outside.
# ❌ Misuse: Internal header as PUBLIC
add_library(mylib src/lib.cpp)
target_include_directories(mylib PUBLIC src/internal)
# src/internal is exposed to the outside world
# ✅ Correct use
target_include_directories(mylib
PUBLIC include # API headers
PRIVATE src/internal # implementation headers
)
The opposite mistake produces the error message above. A public header of mylib includes a header from a dependency that was linked PRIVATE, so mylib itself compiles, and the failure appears only in myapp, which reads the error as a problem in its own code. When a dependent target cannot find a header that a library’s own build finds fine, check whether the library’s public headers include something from a PRIVATE dependency.
Problem 3: Circular dependencies
Cause: A links B, and B links A.
# ❌ Incorrect use
add_library(liba a.cpp)
add_library(libb b.cpp)
target_link_libraries(liba PRIVATE libb)
target_link_libraries(libb PRIVATE liba) # Circulation!
# ✅ Correct use: Dependency redesign
# Common code that both liba and libb depend on is separated into libcommon
add_library(libcommon common.cpp)
add_library(liba a.cpp)
add_library(libb b.cpp)
target_link_libraries(liba PRIVATE libcommon)
target_link_libraries(libb PRIVATE libcommon)
CMake’s reaction depends on the library types. Between static libraries, a cycle is allowed: CMake repeats the libraries on the link line so the linker can resolve references in both directions, and the build succeeds, which is why cycles tend to survive for a long time. If any target in the cycle is a shared library or an executable, CMake stops at generate time with The inter-target dependency graph contains the following strongly connected component (cycle). In both cases the cycle usually means the two libraries are really one, or share code that belongs in a third, as in the fix above.
Issue 4: OBJECT library linking
Before CMake 3.12, an OBJECT library could not appear in target_link_libraries; its objects had to be added as sources with $<TARGET_OBJECTS:>, and its usage requirements did not propagate.
# CMake 3.12+: OBJECT library linking possible
add_library(myobj OBJECT common.cpp)
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE myobj)
# CMake 3.11 and below: Use $<TARGET_OBJECTS:>
add_executable(myapp main.cpp $<TARGET_OBJECTS:myobj>)
With 3.12 and later, linking an OBJECT library adds its object files to the target that links it directly and also passes on its usage requirements. The objects are not passed further: if libb links myobj and myapp links libb, only libb receives the object files. That is usually what you want, and it avoids duplicate symbols.
Production patterns
Pattern 1: Common settings with interface libraries
# Project-wide compile options in an interface library
add_library(project_options INTERFACE)
target_compile_features(project_options INTERFACE cxx_std_20)
target_compile_options(project_options INTERFACE
"$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall;-Wextra;-Wpedantic>"
"$<$<CXX_COMPILER_ID:MSVC>:/W4>"
)
# Apply to targets
add_executable(myapp main.cpp)
target_link_libraries(myapp PRIVATE project_options)
add_library(mylib lib.cpp)
target_link_libraries(mylib PRIVATE project_options)
This replaces add_compile_options() at the top of the tree with something opt-in and visible per target, and it keeps warning flags out of third-party code added with add_subdirectory or FetchContent, which would otherwise inherit them and often fail with -Werror. Link it PRIVATE: warning flags are a property of how you build, not a requirement for your users.
Pattern 2: Alias target
add_library(mylib src/lib.cpp)
add_library(MyProject::mylib ALIAS mylib)
# Reference to the namespace elsewhere
target_link_libraries(myapp PRIVATE MyProject::mylib)
The namespace is more than cosmetics. CMake treats any name containing :: as a target, so target_link_libraries(myapp PRIVATE MyProject::mylbi) with a typo fails at configure time with Target "myapp" links to target "MyProject::mylbi" but the target was not found. The same typo without the namespace becomes -lmylbi and fails much later in the linker, with a far less helpful message. If the project is installed with install(EXPORT ... NAMESPACE MyProject::), consumers using find_package see exactly the same name, so the code that links the library does not change between in-tree and installed use.
Pattern 3: Conditional target
option(BUILD_TOOLS "Build command-line tools" ON)
if(BUILD_TOOLS)
add_executable(tool1 tools/tool1.cpp)
target_link_libraries(tool1 PRIVATE mylib)
endif()
option() values are cached. After the first configure, changing the default in CMakeLists.txt does not affect an existing build directory; pass -DBUILD_TOOLS=OFF or delete the cache to see the new default. This surprises people who edit the file and wonder why nothing changed.
Pattern 4: Target properties
# Get target properties
get_target_property(MYLIB_INCLUDES mylib INCLUDE_DIRECTORIES)
message(STATUS "mylib includes: ${MYLIB_INCLUDES}")
# Set target properties
set_target_properties(mylib PROPERTIES
VERSION 1.0.0
SOVERSION 1
OUTPUT_NAME "my_library"
)
get_target_property returns the raw property value at configure time, which may still contain unevaluated generator expressions, and it does not include requirements inherited from linked targets. For debugging “why does this target get that flag”, cmake --build build --verbose (or -v) shows the actual compiler command lines, which is usually faster.
Complete example: multi-library project
Project structure
project/
├── CMakeLists.txt
├── core/
│ ├── CMakeLists.txt
│ ├── core.cpp
│ └── core.h
├── utils/
│ ├── CMakeLists.txt
│ ├── utils.cpp
│ └── utils.h
├── app/
│ ├── CMakeLists.txt
│ └── main.cpp
└── include/
├── core/
│ └── core.h
└── utils/
└── utils.h
Here core/core.h and utils/utils.h stand for private headers used only inside each library, and include/core/core.h and include/utils/utils.h are the public API.
Root CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(MultiLib VERSION 1.0.0 LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Common settings
add_library(project_options INTERFACE)
target_compile_features(project_options INTERFACE cxx_std_20)
add_subdirectory(core)
add_subdirectory(utils)
add_subdirectory(app)
core/CMakeLists.txt
add_library(core STATIC
core.cpp
core.h
)
target_include_directories(core
PUBLIC ${PROJECT_SOURCE_DIR}/include
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(core PRIVATE project_options)
add_library(MultiLib::core ALIAS core)
utils/CMakeLists.txt
add_library(utils STATIC
utils.cpp
utils.h
)
target_include_directories(utils
PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}
)
# utils depends on core, and its public headers include core's
target_link_libraries(utils
PUBLIC MultiLib::core
PRIVATE project_options
)
add_library(MultiLib::utils ALIAS utils)
app/CMakeLists.txt
add_executable(myapp main.cpp)
# Linking utils also brings in core (PUBLIC propagation)
target_link_libraries(myapp PRIVATE
MultiLib::utils
project_options
)
Two choices in this layout are deliberate. The public include directory is include/, not include/core/, so code writes #include <core/core.h> and #include <utils/utils.h>. The directory name acts as a namespace, and two libraries can both have a config.h without one shadowing the other. And the path uses PROJECT_SOURCE_DIR rather than CMAKE_SOURCE_DIR: when this project is pulled into a larger one with add_subdirectory or FetchContent, CMAKE_SOURCE_DIR points to the outer project’s root, and every include path built from it silently points to the wrong place.
utils gets the public include directory through core, which is enough here because both share include/. In a real project where each library has its own include/, utils would add its own PUBLIC directory just like core does.
Target command summary
| Command | Description |
|---|---|
add_executable(name sources...) | Create executable target |
add_library(name STATIC sources...) | Create static library |
add_library(name SHARED sources...) | Create shared library |
add_library(name INTERFACE) | Header-only library or settings bundle |
add_library(name OBJECT sources...) | Compile object files only |
target_include_directories(target vis dirs...) | Add header search path |
target_link_libraries(target vis libs...) | Link libraries and inherit their requirements |
target_compile_options(target vis opts...) | Add compiler options |
target_compile_definitions(target vis defs...) | Add preprocessor definitions |
target_compile_features(target vis features...) | Require a C++ standard or feature |
Choosing PUBLIC, PRIVATE or INTERFACE
| Concept | Description |
|---|---|
| Target | Something CMake builds or links: executable, library, imported library |
| Usage requirements | Include paths, definitions, options and links that dependents also need |
| PUBLIC | Used by the target and passed to dependents |
| PRIVATE | Used by the target only |
| INTERFACE | Passed to dependents only (header-only libraries, settings bundles) |
| Transitive dependency | PUBLIC and INTERFACE requirements propagate through chains of links |
The practical test for every target_link_libraries and target_include_directories line is the same: does this dependency appear in the target’s public headers? If yes, PUBLIC; if only in its .cpp files, PRIVATE; if the target has no sources of its own, INTERFACE.
FAQ
Q1: Global commands vs target commands?
A: Use target commands (target_*). Global commands (include_directories, link_libraries, add_compile_options) affect every target defined afterwards in that directory tree, including third-party code added with add_subdirectory.
Q2: When do I use the OBJECT library?
A: When the same sources are needed in several targets without building them twice, or when every object must be linked even if nothing references it, such as self-registering plugins or test cases.
Q3: How do I see what flags a target really gets?
A: Build with cmake --build build -v to print the full compiler and linker commands, or generate compile_commands.json with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON. Reading the properties in CMakeLists.txt does not show inherited requirements.
Q4: Where can I learn more?
A: The cmake-buildsystem manual is the authoritative description of targets and usage requirements. “Professional CMake: A Practical Guide” and Effective Modern CMake cover the same ideas with more examples. For external libraries, continue with CMake find_package.
Related articles
- CMake for C++ Projects: Targets, Presets, find_package and C++20 Modules
- Advanced CMake: Multi-Target Projects, External Dependencies and install()
- CMake find_package: CONFIG vs MODULE, Search Paths and Errors
- CMake Tutorial for C++: CMakeLists.txt and Targets
- CMake Errors: 10 Common CMake Error Messages and How to Fix
- C++ Conan Basics — Install, conanfile, Profiles, CMake