Intuition What this page is for
The parent note gave you the rules of PUBLIC / PRIVATE / INTERFACE and
transitive linking. Rules are easy to read and easy to misapply. This page walks every case
class those rules can produce, so that when a real project throws one at you, you have already
seen it drawn out and traced by hand.
The one mental question that drives every example below is:
"Does my header expose this dependency, or only my .cpp?" Keep it in front of you.
Linked ideas you may want open in another tab: Static vs Dynamic Linking ,
Compilation Pipeline preprocess-compile-assemble-link , Header files and Include Guards ,
Package Management find_package .
Think of a CMake dependency question as having a few independent "dials". Each row below is a case
class — a distinct kind of situation the topic can throw at you. The worked examples that follow
each carry a tag like (C3) telling you which cell they cover. Together they cover every row.
#
Case class
The core question it tests
Covered by
C1
PRIVATE leaf (executable)
Does propagation past a program mean anything?
Ex 1
C2
PRIVATE library link
Dependency used only in .cpp — does it leak?
Ex 2
C3
PUBLIC library link
Dependency named in a header — does it flow?
Ex 3
C4
Transitive PUBLIC chain
3+ levels: does the far dep reach the top?
Ex 4
C5
PRIVATE breaks the chain
Where exactly does propagation stop ?
Ex 5
C6
INTERFACE / header-only
A target with zero sources
Ex 6
C7
Diamond dependency
Two paths to the same lib — double-link?
Ex 7
C8
Degenerate / zero input
Empty link, self-link, missing target
Ex 8
C9
Real-world word problem
Translate a requirement into keywords
Ex 9
C10
Exam twist + non-include usage req
The "looks PUBLIC but must be PRIVATE" trap, and compile-defs/flags propagation
Ex 10
Definition First, "leaf" — the one tree word we need
A dependency graph is a set of boxes (targets) with arrows (links) between them. A leaf is a
box that nothing points at — no other target links against it. An executable is always a
leaf : you run a program, you never link another target against it. A library is not a leaf
if some target links it. Look at figure s01: the two upper boxes ("users of T") only exist for
non-leaf targets; a leaf simply has that side empty.
Definition The three keywords, in one breath (so we never guess)
When target T links a dependency D (D = the thing being depended on) with a keyword, the
keyword decides two audiences :
Do I (T) get D's usage requirements? (see below)
Do my users (things that later link T) get D too?
In the table below, D still means the dependency — read "get D" as "receive D's usage
requirements."
keyword
I get D (the dependency)?
my users get D?
PRIVATE
✅ yes
❌ no
INTERFACE
❌ no
✅ yes
PUBLIC
✅ yes
✅ yes
"Usage requirements" = the settings a user of a target needs. There are three kinds,
and they all propagate by the same PUBLIC/PRIVATE/INTERFACE rules:
include directories — target_include_directories(...) (Ex 1–7 exercise this).
compile definitions — target_compile_definitions(...), i.e. -DNAME=value passed to the
compiler (Ex 10 exercises this).
compile / link flags — target_compile_options(...), target_link_options(...), e.g.
-fPIC, -pthread (Ex 10 exercises this too).
Figure s01 shows the two audiences a single keyword serves.
Figure s01 — one link keyword answers two independent questions: does target T itself get the
dependency D (arrow to "T's own .cpp"), and do the users of T get D (arrow to "users of T")?
The three keywords are just the yes/no settings on those two arrows.
Worked example An executable linking a library
add_library (mathlib STATIC math/add.cpp)
target_include_directories (mathlib PUBLIC math)
add_executable (calc app/main.cpp)
target_link_libraries (calc PRIVATE mathlib) # ← the line under test
Forecast (guess first): Could we have written PUBLIC on that last line instead? Would
anything about the produced calc binary change?
Step 1 — Ask the driving question.
Why this step? The keyword only matters if someone links calc. So ask: does anything link
calc? calc is an executable, and (from the definition above) an executable is a leaf — no
other target points at it, so it has no "users" side (see the empty right branch in figure
s01).
Step 2 — Evaluate the "my users get D?" column.
Why this step? From the table, PUBLIC differs from PRIVATE only in what users receive.
calc has no users, so that column is empty either way.
Step 3 — Conclude.
Why this step? When two options produce identical output, pick the one that says less.
PRIVATE is the honest, minimal choice.
Verify: The binary calc links mathlib and can #include "add.h" (because mathlib's
include dir was PUBLIC). Swapping to PUBLIC changes nothing observable — confirming a
leaf target's link keyword is cosmetic. ✅
core uses util only inside its .cpp
add_library (util STATIC util.cpp)
target_include_directories (util PUBLIC util_inc)
add_library (core STATIC core.cpp)
target_link_libraries (core PRIVATE util) # util used only in core.cpp
add_executable (app app.cpp)
target_link_libraries (app PRIVATE core)
Forecast: Can app.cpp write #include "util.h" and compile?
Step 1 — Locate where util is used. Why? The keyword must match reality: util's symbols
appear only inside core.cpp, never in core's public header.
Step 2 — Apply the table to core → util (PRIVATE). Why? PRIVATE ⇒ my users don't get D .
So util's include dir is not handed to whoever links core.
Step 3 — Trace up to app. Why? app links core, but core refuses to pass util
along. Therefore app does not receive util's include dir.
Verify: app.cpp writing #include "util.h" → compile error: file not found — and that
is correct , because app was never supposed to know util exists. Encapsulation held. ✅
Figure s02 — the same three targets under two keywords. Left (PRIVATE): the dashed orange
line is a wall — util is used inside core but cannot be included by app. Right (PUBLIC):
the teal curved arrow shows util's usage requirements flowing all the way up to app, which can
now include util.h. Only one word changed between the two.
Worked example Same files, one keyword changed
target_link_libraries (core PUBLIC util) # ← now PUBLIC
target_link_libraries (app PRIVATE core)
This is the case where core's header (core.h) does #include "util.h", so any user of
core who includes core.h transitively needs util's headers. Compare the right-hand panel of
figure s02.
Forecast: Does app now link util and see its includes?
Step 1 — Match keyword to reality. Why? core.h exposes util, so util is part of
core's interface . That is exactly what PUBLIC encodes.
Step 2 — Apply the table to core → util (PUBLIC). Why? PUBLIC ⇒ my users get D .
util's include dir is now attached to core's interface.
Step 3 — Propagate to app. Why? app links core; it inherits core's interface,
which now carries util. So app links util and gets its include dir — automatically,
with no line mentioning util inside app.
Verify: app.cpp can #include "util.h" and the final executable links against util's
object code. Contrast with Ex 2: the only change was one word, and the whole outcome flipped. ✅
app → core → util, both PUBLIC
target_link_libraries (core PUBLIC util)
target_link_libraries (app PUBLIC core)
Forecast: If tomorrow a fourth target plugin links app PUBLIC-ly, will plugin see
util?
Step 1 — Walk the graph edge by edge. Why? Propagation is decided one edge at a time; we
compose the answers.
core → util PUBLIC ⇒ core's interface = {core, util}.
app → core PUBLIC ⇒ app's interface = {app, core, util}.
Step 2 — Add the hypothetical edge. Why? To test how far the chain reaches.
plugin → app PUBLIC ⇒ plugin's interface = {plugin, app, core, util}.
Step 3 — Read off the answer. Why? The question asks whether util ∈ plugin's inherited
set. It is.
Verify: util reaches plugin — PUBLIC edges compose like an unbroken pipe. Count the
targets that end up linking util: core, app, plugin → 3 targets downstream of util.
✅
Worked example One PRIVATE edge in the middle
target_link_libraries (core PUBLIC util) # edge 1: PUBLIC
target_link_libraries (app PRIVATE core) # edge 2: PRIVATE
add_executable (tests test .cpp)
target_link_libraries (tests PRIVATE app) # edge 3 (illustrative)
(Linking an executable app is unusual; treat it as a thought experiment about where
propagation dies.)
Forecast: Does tests receive core or util's include dirs from app?
Step 1 — Interface of core. Why? Start at the bottom. core → util PUBLIC ⇒
interface {core, util}.
Step 2 — Interface of app. Why? app → core is PRIVATE . PRIVATE ⇒ my users get
nothing . So app's interface contains only app itself; core and util are used by
app's own .cpp but not exported .
Step 3 — Trace to tests. Why? tests links app; it inherits app's interface ,
which is empty of core/util. So tests gets neither .
Verify: The single PRIVATE edge acted as a wall : everything below it (core, util) is
visible to app internally but invisible to tests. Number of deps propagated to tests:
0 . See figure s03. ✅
Figure s03 — the four-box chain tests → app → core → util. The two lower edges are PUBLIC, so
util's requirements climb up to app. But the top edge tests → app is PRIVATE — the dashed
orange "WALL" — so nothing crosses into tests. It inherits zero deps.
Worked example A target with no
.cpp at all
add_library (json INTERFACE ) # zero sources
target_include_directories (json INTERFACE include) # note: INTERFACE, not PUBLIC
add_executable (app main.cpp)
target_link_libraries (app PRIVATE json)
Forecast: Why is the include dir INTERFACE and not PUBLIC here? And why does app still
link json with PRIVATE, not INTERFACE?
Step 1 — There is no "I" to satisfy. Why? json compiles no source of its own.
PUBLIC means "I need this dir and my users do." But json has nothing to compile, so the "I
need it" half is meaningless. Only the "users need it" half is real → INTERFACE.
Step 2 — Choose app's link keyword. Why? Reuse Ex 1's logic: app is a leaf executable,
so its keyword only affects users it doesn't have. PRIVATE is the minimal correct choice; here
it means "app consumes json's headers."
Step 3 — Confirm the flow. Why? json's INTERFACE include dir attaches to anyone who
links json. app links it, so app gets include/ on its include path.
Verify: main.cpp can #include <json.hpp>. Using PRIVATE on line 2 instead of
INTERFACE would give the dir to nobody (a header-only lib that hides its headers — useless),
the classic mistake from the parent's §5. ✅
Common mistake "INTERFACE library is just an empty library."
It has no object code and no binary output. It is a pure bag of usage requirements —
include dirs, compile definitions, and further INTERFACE link deps. Think of it as a
"settings sticker" you slap onto targets.
Worked example Two paths reach the same library
add_library (base STATIC base.cpp)
add_library (left STATIC left.cpp)
add_library (right STATIC right.cpp)
add_executable (app app.cpp)
target_link_libraries (left PUBLIC base)
target_link_libraries (right PUBLIC base)
target_link_libraries (app PRIVATE left right)
Forecast: app reaches base via left and via right. Will base's object code get
linked twice , causing "duplicate symbol" errors?
Step 1 — Collect the dependency set of app. Why? CMake computes a set , not a list of
paths. app needs {left, right} directly; each pulls in base (PUBLIC). Union:
{left, right, base}.
Step 2 — Dedupe & order. Why? Because it is a set, base appears once . CMake also
topologically sorts it so base comes after its dependents on the linker line.
Step 3 — Predict the linker invocation. Why? To sanity-check "no duplicates."
Roughly: ... left right base — base listed a single time.
Verify: No duplicate-symbol error; base is compiled once and linked once. The diamond
collapses cleanly — this is exactly why we "declare what depends on what" instead of hand-ordering
-l flags (parent §5, mistake 3). ✅
Worked example The edge cases that break naive scripts
Consider four odd calls. Predict each before reading the verdict.
8a — Empty link list:
target_link_libraries (app PRIVATE ) # no deps listed
Forecast: error or no-op? → No-op (valid but pointless). CMake attaches nothing.
8b — Linking a target defined later in the file:
add_executable (app app.cpp)
target_link_libraries (app PRIVATE mathlib) # mathlib defined below
add_library (mathlib STATIC add.cpp)
Forecast: Does forward reference fail? → It works. CMake resolves target names across
the whole configure pass; declaration order among targets does not matter (unlike raw
Makefiles). Only cmake_minimum_required and project have a required position.
8c — Linking a name that is not a target and not a real library:
target_link_libraries (app PRIVATE ThisNameDoesNotExist)
Forecast: → CMake treats an unknown bare name as a raw library flag (-lThisName...) and
defers the failure to the linker , which then errors "cannot find -lThisNameDoesNotExist."
Lesson: a typo'd target name doesn't fail at configure time — it fails loudly later.
8d — Self-link:
target_link_libraries (app PRIVATE app)
Forecast: → CMake errors at configure: a target cannot link itself (it would create a
cycle of length 1).
Verify: Each verdict follows from one principle — CMake resolves a name-based graph during
configure, and only genuine cycles / structural errors stop it there; unknown names are punted to
the linker. ✅
Worked example Translate the requirement into keywords
"We have a network library. It uses OpenSSL internally to encrypt bytes, but nothing about
OpenSSL appears in network's public headers — callers just call network::send(). network
also ships a header logging.h that #includes a tiny header-only spdlog. The server
executable uses network. Write the links."
Forecast: For each of OpenSSL and spdlog, is it PRIVATE, PUBLIC, or INTERFACE from
network's point of view?
Step 1 — OpenSSL. Why? "Used internally, not in headers" ⇒ only network's .cpp sees it
⇒ PRIVATE .
Step 2 — spdlog. Why? network's public header includes it, so users who include that
header need spdlog too ⇒ PUBLIC (network compiles against it and exposes it).
Step 3 — server. Why? Leaf executable ⇒ PRIVATE (Ex 1 logic).
add_library (network STATIC network.cpp)
target_link_libraries (network
PRIVATE OpenSSL::SSL # hidden implementation detail
PUBLIC spdlog::spdlog) # leaks through logging.h
add_executable (server main.cpp)
target_link_libraries (server PRIVATE network)
Verify: server links network and (transitively) spdlog, but not OpenSSL's include
dirs — server can #include "logging.h" yet cannot accidentally depend on OpenSSL. That is the
encapsulation we wanted, expressed purely through keyword choice. (Namespaced names like
OpenSSL::SSL come from Package Management find_package .) ✅
Worked example The trap, plus compile-defs and link-flags propagation
add_library (engine STATIC engine.cpp) # engine.cpp includes <Eigen/Dense>
# engine.h does NOT include any Eigen header; it only forward-declares types.
target_link_libraries (engine PRIVATE Eigen3::Eigen)
# engine also needs two NON-include usage requirements:
target_compile_definitions (engine PUBLIC ENGINE_API=1) # a macro users must see
target_compile_options (engine PUBLIC -pthread) # a flag users must pass
target_compile_definitions (engine PRIVATE ENGINE_INTERNAL_LOG=1) # only for engine.cpp
Forecast: (a) Should the Eigen3::Eigen link be PUBLIC or PRIVATE? (b) Which of the three
target_compile_* lines will a downstream app (linking engine) actually receive?
Step 1 — The include-dir trap. Why? Apply the one question: "Does my header expose it?"
engine.h only forward-declares — it does not #include <Eigen/...>. Header does not
expose ⇒ users don't need Eigen ⇒ PRIVATE . Writing PUBLIC here would dump Eigen's huge
include tree onto every downstream target — the parent's "bloated builds" warning.
Step 2 — Compile definitions propagate by the SAME rule. Why? A -DNAME macro is a usage
requirement exactly like an include dir. ENGINE_API=1 is PUBLIC, so any target linking
engine gets -DENGINE_API=1 on its compiler command line. ENGINE_INTERNAL_LOG=1 is
PRIVATE, so only engine.cpp sees it; app does not.
Step 3 — Compile / link flags too. Why? -pthread was marked PUBLIC, so app also
compiles with -pthread. This is how a threading dependency correctly "infects" everyone who
needs it — via propagation, not by hand-editing each target.
Verify — tally what app receives when it links engine:
Eigen include dirs: 0 (PRIVATE) ✅
-DENGINE_API=1: yes (PUBLIC) ✅
-DENGINE_INTERNAL_LOG=1: no (PRIVATE) ✅
-pthread: yes (PUBLIC) ✅
So exactly 2 of the three target_compile_* settings reach app, and Eigen is correctly
hidden. The lesson: include dirs, compile definitions, and compile/link flags all obey the same
PUBLIC/PRIVATE/INTERFACE propagation — one mental model covers every usage requirement. ✅
Recall One-line answer per case class
C1 leaf keyword ::: cosmetic — nobody links an executable, so PRIVATE.
C2 PRIVATE lib link ::: dep stays hidden; users can't include it (correct encapsulation).
C3 PUBLIC lib link ::: dep flows to users; needed when a header exposes it.
C4 PUBLIC chain ::: PUBLIC edges compose; far dep reaches the top.
C5 PRIVATE in middle ::: acts as a wall; nothing below it propagates upward.
C6 INTERFACE ::: no sources → only the "users need it" half exists.
C7 diamond ::: CMake dedupes the dep set → linked once, no duplicate symbols.
C8 degenerate ::: name-based graph; order among targets free; bad names fail at link, cycles at configure.
C9 word problem ::: internal→PRIVATE, exposed-in-header→PUBLIC, header-only→INTERFACE.
C10 exam twist ::: forward-declared→PRIVATE; and defs/flags propagate by the SAME keyword rule as includes.
Mnemonic The whole page in seven words
"If my header shows it, it's PUBLIC."
See also: Makefiles , Ninja build system (the generators CMake feeds),
Static vs Dynamic Linking (what STATIC/SHARED actually produce), and the
parent CMake note for the base rules.