본문으로 건너뛰기
Previous
Next
C++ Include Paths: #include '...' vs <...>, -I, and CMake

C++ Include Paths: #include '...' vs <...>, -I, and CMake

C++ Include Paths: #include '...' vs <...>, -I, and CMake

이 글의 핵심

How the compiler searches for headers: angle vs quotes, -I order, CMake target_include_directories, and fixing “No such file or directory” errors.

Introduction

The C++ compilation model treats header inclusion as a textual operation performed by the preprocessor before parsing and code generation. Every #include directive pulls another file into the translation unit, recursively. That simple mechanism scales to large codebases only when include search paths, header organization, and build system wiring are disciplined.

As of 2024, most production C++ teams standardize on CMake (often with Ninja or MSBuild as the backend), use a package manager (vcpkg, Conan, or a vendor-specific flow), and expect CI to reproduce a developer’s include graph on a clean machine. The odd Makefile-only service still exists, but the mental model in this post matches what you will see in that mainstream setup.

This article presents a practical, tool-oriented overview of how headers are discovered, how vendor and project trees interact, and how to keep builds fast and maintainable. You will see the difference between quoted and angle-bracket includes, how GCC/Clang and MSVC search directories, how CMake propagates include directories, and how common practices—include guards, #pragma once, forward declarations, precompiled headers, and modules—reduce compile times and coupling. (A periodic include-what-you-use or include cleanup pass still helps, but you do not need a sermon on it here.)

What you should be able to do after reading:

  • Predict which directory satisfies a given #include for your compiler and flags.
  • Add and order -I / -isystem / /I correctly for third-party code.
  • Structure a repository so public API headers, private implementation details, and generated code remain separable.
  • Diagnose “file not found” and “wrong header picked” problems systematically.

1. The include system at a glance

Preprocessor phases (simplified)

  1. The preprocessor reads the main source file (.cpp) and each included header.
  2. For each #include, it resolves a logical name (e.g. vector or project/api.h) to a physical path on disk by searching an ordered list of directories.
  3. The result is a single preprocessed translation unit that the compiler’s parser sees as one long stream of tokens.

Nothing in the C++ standard mandates a specific filesystem layout; what matters is that your toolchain and build agree on where to look. Different compilers differ in details, but the mental model below is widely useful.

Rough mental model

#include <iostream>   // “System / standard library” convention
#include "app/config.h" // “Project / same component” convention
  • Angle brackets (<>) signal headers that are not tied to the including file’s directory in the same way as quotes; implementations typically search include path lists built from defaults, -I, and system locations.
  • Quotes ("") signal user or project headers; the preprocessor usually checks the including file’s directory first, then user include paths, then falls back toward system paths (exact order is implementation-defined—see your compiler manual).

2. #include "file.h" vs #include <file.h>

System vs user includes

The standard (C++11 onwards) describes two forms of #include (cppreference: Source file inclusion):

  • H-char sequence (form using "...").
  • Q-char sequence (form using <...>).

The standard deliberately leaves much of the search behavior implementation-defined. In practice:

FormTypical intentTypical first checks (GCC/Clang style)
"header.hpp"Project or component-private headerDirectory of the file with the #include, then -iquote directories, then -I/system (see compiler docs)
<header.hpp>Standard library, system, or “installed” third-party API-I paths in order, then system include directories; does not start from the current file’s directory

Why conventions matter: Using <> for your own tree and "" for the standard library will confuse readers and can mask configuration bugs. A common convention:

  • Use #include <mylib/...> for public headers of a library that is installed or exposed as a stable API (with mylib rooted at an include root).
  • Use #include "detail/..." for private headers co-located with a .cpp file when you do not publish them as API.

Search order (practical model)

Treat the following as a portable mental model; verify with -v when debugging.

#include "x.hpp"

  1. Directory containing the file that holds the #include (often the “current” directory for quoted search).
  2. Directories from -iquote (GCC), if used.
  3. Directories from -I in left-to-right order as they appear on the command line.
  4. System/framework paths (after -isystem and built-in defaults).

#include <x.hpp>

  1. Directories from -I (or MSVC /I) in order.
  2. System include directories (e.g. /usr/include/c++/VERSION, MSVC universal CRT and SDK paths).

MSVC note: The documentation for /I (Additional include directories) describes search order for quoted includes including the directory of the including file first, similar in spirit to GCC.

When both forms find the same file

If a header exists in both a project path and a system path, order and which form you use decide which one wins. Shadowing standard headers (naming a project file string or vector) is a classic way to break builds in confusing ways—avoid it.


3. Compiler search paths and defaults

Default system paths

Compilers ship with built-in defaults so that #include <vector> works without any -I. On Linux with GCC, typical system paths include:

  • /usr/include
  • /usr/local/include
  • Libstdc++ versioned paths such as /usr/include/c++/12 (version-specific)

On Windows with MSVC, the Windows SDK and Universal CRT paths are added based on installation and vcvars environment setup. The exact list is long and version-specific; use compiler verbose output to print it.

Inspecting the full search list (GCC/Clang)

# Show #include search paths (verbose preprocessor)
g++ -v -E -x c++ /dev/null

# Alternative: ask the preprocessor for verbose include info
echo | g++ -Wp,-v -x c++ - -fsyntax-only

For Clang, similar flags work; you can also use:

clang++ -v -E -x c++ /dev/null

What to look for: Blocks labeled include <...> search starts here: list directories in order. When a header is “not found,” this output is the ground truth for what the driver considered.

MSVC

From a Developer Command Prompt for your toolset:

cl /nologo /TP /showIncludes /c main.cpp

/showIncludes prints a tree of included files and helps confirm which physical file satisfied each #include.


4. The -I flag (and friends)

Adding custom include roots

GCC/Clang: -I/path/to/include adds a directory to the quote and angle include path (see your compiler’s documentation for interaction with -iquote).

g++ -std=c++17 -I./include -I./third_party/include src/main.cpp -o app

Multiple -I flags: Each addition extends the path list. Earlier directories can shadow later ones when multiple candidates share the same relative name.

g++ -I./overrides -I./upstream/include src/main.cpp -o app

If both overrides/foo.h and upstream/include/foo.h exist, the first -I wins for #include <foo.h> (typical GCC/Clang behavior—always confirm for your version).

-iquote (GCC)

-iquote dir affects the search order for #include "..." separately from -I in GCC. This is useful when you want different precedence between quoted includes and angle includes. Many projects skip -iquote and standardize on -I plus consistent #include style.

Environment variables

GCC honors:

  • CPLUS_INCLUDE_PATH — colon-separated (; on Windows for some toolchains) list of directories searched after -I in some configurations, or as additional user includes.
export CPLUS_INCLUDE_PATH=/opt/custom/include
g++ main.cpp

Relying on environment variables for production builds is fragile (reproducibility suffers). Prefer explicit build system settings (CMake, Bazel, Meson) and document toolchain setup.

MSVC /I

cl /std:c++17 /Iinclude /Ithird_party\include main.cpp

Use forward slashes or quoted paths with spaces—MSVC accepts both styles in modern versions.


5. -isystem: suppress warnings in third-party headers

Third-party libraries may trigger warnings you cannot fix. Marking their include roots as system headers changes how -Wall, -Wextra, and related flags apply: warnings generated inside system headers are typically suppressed or reduced.

GCC/Clang:

g++ -I./include -isystem./third_party/asio/include src/main.cpp -o app

Rules of thumb:

  • Put your code under -I.
  • Put vendored or external libraries under -isystem when you do not want their warnings to fail your -Werror build.
  • Do not abuse -isystem for your own headers to hide problems you should fix.

CMake: target_include_directories(tgt SYSTEM PUBLIC ext/include) maps to -isystem for supported generators.


6. Include guards

The problem: double inclusion

Headers often include other headers. Without protection, the same declarations can appear twice, causing redefinition errors. Include guards prevent multiple inclusion of the same content in one translation unit.

Traditional include guard pattern

#ifndef MYPROJECT_CONFIG_HPP
#define MYPROJECT_CONFIG_HPP

namespace myproject {
// declarations
} // namespace myproject

#endif // MYPROJECT_CONFIG_HPP

Macro names must be unique project-wide. Many teams use long, prefixed names (MYPROJECT_CONFIG_HPP) or a rule based on path (INCLUDE_MYPROJECT_APP_CONFIG_HPP).

Benefits

  • Works on every compiler.
  • Easy to understand in code review.
  • Plays well with tools that do not support non-portable constructs.

Drawbacks

  • Boilerplate in every header.
  • Risk of copy-paste errors (two files using the same guard macro).
  • Cannot protect against identical content included via different paths that resolve to different files (rare but possible in pathological layouts).

7. #pragma once

Mechanism

#pragma once is a non-standard but widely supported preprocessor directive that asks the compiler to include a physical header file at most once per translation unit.

#pragma once

namespace myproject {
// declarations
} // namespace myproject

Trade-offs

TopicInclude guards#pragma once
PortabilityUniversal C++Supported by MSVC, GCC, Clang, Apple Clang (verify exotic compilers)
BoilerplateHigherLower
Edge casesDifferent paths to same file via linksSome historical bugs with hard links / exotic FS; modern toolchains improved

Practical guidance: For code targeting mainstream compilers on common filesystems, #pragma once is convenient. If you need maximum portability (embedded exotic toolchains), use include guards or both (redundant but sometimes done in corporate style guides).


8. Forward declarations: reducing includes

Motivation

Heavy includes in headers cascade: one include pulls thousands of lines from the standard library and your graph. Forward declarations allow you to reference a type name without including its full definition when only pointers or references are needed.

// Widget.hpp — prefer forward declaration instead of #include "Detail.hpp"
namespace ui {
class WidgetImpl; // forward declaration
class Widget {
public:
    explicit Widget();
    ~Widget(); // defined in .cpp where impl is complete
private:
    WidgetImpl* impl_;
};
} // namespace ui

In the .cpp file:

#include "widget.hpp"
#include "detail/widget_impl.hpp"

ui::Widget::Widget() : impl_(new WidgetImpl()) {}
ui::Widget::~Widget() { delete impl_; }

Modern alternative: std::unique_ptr<WidgetImpl> requires a complete type at destruction—use out-of-line destructor in .cpp as with unique_ptr to pimpl idioms.

Effect on compile time

Fewer includes mean less work for the preprocessor and parser in every .cpp that touches the header. This is one of the highest-leverage tactics for large projects.


9. Precompiled headers (PCH)

Concept

A precompiled header is a cached snapshot of early tokens and compiler state for a stable set of includes (e.g. standard library and common project headers). Parsing that block once and reusing it can cut compile time dramatically in large projects.

GCC/Clang example (simplified)

Create a header pch.hpp:

#pragma once
#include <vector>
#include <string>
#include <memory>
#include <iostream>

Compile the PCH:

g++ -std=c++17 pch.hpp -o pch.hpp.gch

When compiling sources, the compiler recognizes a matching pch.hpp.gch next to pch.hpp and can use it if the first include in each .cpp includes pch.hpp—details are version-specific; consult your compiler manual.

MSVC

MSVC supports .pch through /Yc (create) and /Yu (use) or through CMake’s target_precompile_headers.

Benefits and costs

BenefitCost
Faster full and incremental builds for common codeExtra complexity; all TUs should share the same macro/standard settings
Centralized “expensive” includesStaleness issues if PCH dependencies change—build systems must regenerate PCH

CMake

target_precompile_headers(mytarget PRIVATE
  include/project/pch.hpp
)

Prefer private PCH unless you fully understand export implications across targets.


10. CMake integration

Prefer target_include_directories

Modern CMake centers on targets. Use target_include_directories with PUBLIC, PRIVATE, and INTERFACE semantics:

add_library(widgets STATIC
  src/widget.cpp
)
target_include_directories(widgets
  PUBLIC
    ${CMAKE_CURRENT_SOURCE_DIR}/include
  PRIVATE
    ${CMAKE_CURRENT_SOURCE_DIR}/src
)
  • PUBLIC — consumers of widgets also inherit these include directories (for API headers).
  • PRIVATE — only widgets compilation uses these paths (for internal headers).
  • INTERFACE — for header-only libraries: no source in this target, but consumers need the includes.

Avoid global include_directories in new code

Older CMake used:

include_directories(${CMAKE_SOURCE_DIR}/include)

This silently applies to all targets in the directory scope and obscures dependencies. Prefer target-scoped commands for clarity and faster mental models when debugging include issues.

Generator expressions and imported targets

When using imported targets from find_package, includes often come for free:

find_package(Boost REQUIRED COMPONENTS filesystem)
target_link_libraries(app PRIVATE Boost::filesystem)
# Boost::filesystem brings include usage requirements

This is the preferred pattern: link the logical target, let CMake propagate usage requirements.

SYSTEM flag

target_include_directories(app SYSTEM PRIVATE ${THIRD_PARTY_DIR}/include)

This marks includes as system for warning behavior, mirroring -isystem.


11. Package managers: vcpkg and Conan

vcpkg

vcpkg installs libraries into a triplet-specific directory tree. CMake integration often uses CMAKE_TOOLCHAIN_FILE:

cmake -S . -B build -DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake

Each installed package exports CMake targets (when supported) so that find_package pulls in include paths and libraries together. Avoid hand-adding -I to vcpkg installed trees unless you have a specific reason—duplicated or wrong triplet paths are a common source of link/include mismatches.

Conan

Conan generates toolchain and compiler flags information. For CMake, the cmake generator (or CMakeDeps / CMakeToolchain in Conan 2 flows) injects include_dirs into targets. Treat package-provided usage as authoritative and avoid manually re-specifying the same roots.

General rule

Package managers succeed when one layer (the generated CMake/Bazel files) owns include, lib, and defines. Fighting the abstraction with extra raw -I lines often causes ABI or ODR surprises.

We switched package managers three times, and the bug was still -I

This is a composite war story, not a vendor shootout. One team I worked with started on vendored tarballs and hand-maintained third_party/include. They moved to Conan to get reproducible version pins, then to vcpkg because Windows CI got easier with a single toolchain file, then briefly back to Conan for a dependency that was packaged oddly on vcpkg—each hop left ghost include roots in old CMakeCache.txt entries and developer notes.

The failure mode never was “Conan is bad” or “vcpkg is good.” It was always the same: two different recipes both added a path to OpenSSL (or gRPC, or Protobuf) with slightly different layout, the compiler found headers from install A and shared libs from install B, and the linker or a runtime dlopen path failed hours later. The “fix” was not another migration; it was deleting the build directory, picking one package story per configuration, and letting find_package + imported targets carry the include graph alone.

As of 2024, if you are greenfield on Windows+Linux, vcpkg and Conan 2 are both credible; the winning move is consistency across developers and CI, not the logo on the slide.


12. C++20 modules: the future of #include

Modules vs textual inclusion

Modules (import std;, import mylib;) provide isolation and single definition semantics without exporting macros and without re-parsing the same text in every translation unit the way #include does.

import std;
int main() { return 0; }

Interop period

Real codebases mix headers and modules during migration. Build systems must compile module interfaces (.ixx, module.mpp, etc.—conventions vary) with correct dependency order. CMake 3.28+ improves FILE_SET CXX_MODULES support; check your generator and compiler pair.

When includes remain

Even with modules, legacy libraries and C APIs remain header-based for years. Understanding include paths remains essential for maintenance and integration work.


13. Circular dependencies: strategies

Symptom

Header A.hpp includes B.hpp, and B.hpp includes A.hpp (possibly indirectly). The compiler may fail with incomplete types or infinite include expansion (though include guards stop reprocessing, they do not fix logical cycles).

Strategies

  1. Forward declare in one header; move #include to .cpp where full types are needed.
  2. Extract shared interfaces into a third, slimmer header common_fwd.hpp.
  3. Restructure layers so lower layers never include higher layers (dependency inversion).
  4. Apply the pimpl idiom to hide concrete class dependencies behind a pointer to an incomplete type.

Example: breaking a two-header cycle

// a_fwd.hpp
#pragma once
class B; // forward declaration

// a.hpp
#pragma once
#include "a_fwd.hpp"
class A { public: void use(B& b); };
// b.hpp
#pragma once
#include "a_fwd.hpp" // if needed, or forward declare A
class B { public: void react(A& a); };

Full definitions that need member access stay in .cpp files that include both complete headers after declarations are consistent.


14. Build performance: reducing compile times

Tactics checklist

  1. Minimize includes in headers — prefer forward declarations; include only what the header’s inline code truly needs.
  2. Avoid “god” headers that include half the world; split into cohesive modules.
  3. Use ccache / sccache for compiler caching in CI and locally.
  4. Unity builds (optional) — combine many .cpp files to reduce redundant parsing; trade longer single-compilation times and potential ODR hazards if misused.
  5. Precompiled headers for stable, heavy includes.
  6. Modules when toolchain support matches your deployment targets.

Measuring

Use -ftime-trace (Clang) for per-pass timing, or BuildInsights / MSVC trace utilities on Windows. Without measurement, “include cleanup” is guesswork.


15. Cross-platform: Windows vs Linux paths

Separators and escaping

  • Unix: forward slash / everywhere; case-sensitive by default.
  • Windows: MSVC and Clang accept / in many flags; backslashes need escaping in scripts. Path length and spaces push you toward quoted paths.

Line endings and CMake

Git core.autocrlf differences should not affect compilation, but generated include paths in response files must be correct. Prefer CMake generator expressions over hand-built relative paths.

Case sensitivity

Linux builds fail if #include "MyHeader.hpp" mismatches myheader.hpp on a case-sensitive FS. macOS default case-insensitive APFS can hide those bugs until CI runs on Linux—use consistent casing in #include directives and filenames.

Always print verbose include lists on each platform when porting. Do not assume /usr/include exists on Windows or that MSVC searches /usr/local/include.


16. Lessons from painful CMake migrations

The include story and the CMake story are the same story: you are negotiating who owns which roots and which targets see them. A migration hurts when that ownership was never explicit.

What usually breaks first: a library that used a global include_directories() in one subdirectory suddenly gets split into two targets, and one of them loses a path that was “just there” before. The fix is not to sprinkle more -I on the command line; it is to put public API roots on PUBLIC, private implementation roots on PRIVATE, and to treat find_package targets as the single source of truth for third-party include usage.

What breaks second: generated headers. Protobuf, Qt, Thrift, or your own code generator wrote files into the build tree, and an old FILE(GLOB ...)-style rule never propagated ${CMAKE_CURRENT_BINARY_DIR}/generated to the one consumer that now lives in a different CMake folder scope. The symptom is always the same: the file is on disk, the compiler is sincere, and the error still says not found.

What breaks last: INTERFACE vs PRIVATE for header-only targets. You “linked” a header-only package by adding an include path by hand, then someone refactors to a real INTERFACE library and you get duplicate or divergent paths. The lesson is painful because it is boring: match CMake’s model instead of fighting it.

If you are mid-migration, print verbose include traces on a clean build directory before you trust CI. The diff between machines is almost never the standard library; it is a forgotten target_include_directories line.


17. The most confusing error messages and what they mean

fatal error: 'foo.hpp': No such file or directory (but the file exists in the repo)
The preprocessor is not looking in the directory you think. For quoted includes, remember the including file’s directory; for generated code, the consumer often needs the build output on the path, not the source tree. This message looks dumb; it is usually telling you the dependency graph in CMake is wrong, not that ls is wrong.

fatal error: 'vector': file not found (or any standard header)
You are often not in C++ mode, or you are not using a C++ driver (g++ / clang++ / MSVC’s C++ compilation path). Less dramatically, a broken sysroot or a minimal container image is missing the development packages—same symptom, different fix.

Two different foo.hpp units in one build
You get “invalid use of incomplete type” or API mismatches that make no sense until you discover two headers named the same, satisfied from different -I roots. The compiler picked the first match. Reordering -I is a stopgap; renaming or namespacing the project header is the honest fix.

Warnings that explode from “nowhere” after adding a dependency
You pulled in a header that drags macros (min/max on Windows is the classic) or a different threading model define. The error points at your line; the cause is their include. The fix is include order, feature macros, or marking third-party roots as SYSTEM when the warnings are genuinely not yours to fix.


Summary

  1. Include paths are ordered lists built from command-line flags, environment, and system defaults; the first matching file wins.
  2. "" vs <> follows team convention; quoted includes usually search the including file’s directory first; angle includes do not.
  3. -I / /I add roots; order matters; -isystem / SYSTEM mark third-party trees for warning behavior.
  4. Include guards and #pragma once stop repeated textual inclusion; they do not fix bad architecture.
  5. Forward declarations keep API headers from dragging half the world; PCH and modules address scale when your toolchain and CI actually support them.
  6. CMake should propagate usage requirements with target_include_directories and imported targets, not ad hoc globals.
  7. Package managers work best when you trust their generated include usage.
  8. Portability requires checking verbose include traces on each target platform and respecting case sensitivity.

Next reading: Header files, CMake build system, and the compilation process for how preprocessing fits into the full pipeline.



FAQ (from practice)

Q. Difference between "" and <>?
A. In practice, quoted includes usually search the including file’s directory and favor project-local files; angle includes are used for system / installed headers and do not start from the including file’s directory. Exact rules are implementation-defined—print verbose include paths when it matters.

Q. How do I add include directories reproducibly?
A. In CMake, use target_include_directories (scoped to targets). For raw flags, use -I (GCC/Clang) or /I (MSVC) in build recipes rather than ad hoc machine-local environment variables.

Q. My build passes locally but fails in CI on Linux.
A. Check case sensitivity, which compiler is invoked, and whether generated include directories exist in the CI workspace before targets that include them are built. Compare verbose include output from both environments.

Q. Should I use -isystem for everything to silence warnings?
A. No. Reserve it for genuine third-party code. Your own headers should remain clean under your normal warning set so defects are visible.


Relevant search phrases: C++ include path, -I -isystem difference, CMake target_include_directories PUBLIC PRIVATE, include guard vs pragma once, include what you use, precompiled headers CMake, MSVC /I order, cross-platform C++ headers.