5.3.9 · D2Build Systems & Toolchain

Visual walkthrough — CMake build types — Debug, Release, RelWithDebInfo

1,954 words9 min readBack to topic

We only need three ideas from everyday life to start:

  • A compiler is a program that turns your human-readable code (.cpp files full of words) into a machine-runnable file. Think of it as a translator.
  • A flag is a short instruction you give that translator, written after its name on the command line — like -O3. The dash-and-letters are the flag.
  • A cache variable is just a labelled box CMake keeps, holding some text. We name the box, we look inside, we get text back. (Deeper dive: CMake Cache Variables.)

That's the whole vocabulary. Let's build.


Step 1 — What "compiling" actually hands over

WHAT. Before any "build type" exists, understand the raw event we are trying to influence. The compiler is invoked as one long line of text: the translator's name, then your file, then a list of flags.

WHY. Every single thing in this topic — Debug, Release, all of it — exists only to change that one line of text. If you see the line clearly, nothing later is mysterious. There is no hidden second mechanism.

PICTURE. In the figure, the green box is the fixed part (compiler + your files, identical every time). The yellow box is the variable part — the flags. Build types touch only the yellow box.

Figure — CMake build types — Debug, Release, RelWithDebInfo

  • g++ — the compiler program's name. Never changes when you switch build types.
  • main.cpp — your source. Also never changes. The same essay, as the parent note says.
  • -O3 -DNDEBUG — the flags. This substring is the entire subject of this page.

Step 2 — What one flag does (three independent knobs)

WHAT. There are exactly three flag-ideas we care about, and each is an independent dial:

  • -O0 … -O3 = the optimization dial (how hard the translator works to make it fast).
  • -g = the debug-symbol switch (keep a map back to source lines, or don't).
  • -DNDEBUG = the assert switch (keep runtime safety checks, or delete them).

WHY. "Independent" is the key word. People assume turning speed up (-O3) forces debug info off. It does not. We draw them as three separate sliders so that later, when RelWithDebInfo turns two of them on at once, it feels obvious rather than paradoxical.

PICTURE. Three horizontal sliders, each moving on its own track. No slider is wired to another. (This "no wire" idea is exactly the parent's word orthogonal — it just means "adjust one without disturbing the others.")

Figure — CMake build types — Debug, Release, RelWithDebInfo

Step 3 — A "build type" is a labelled box of flags

WHAT. A build type is a name attached to a fixed little string of flags. Release is a name; behind it CMake keeps a box labelled CMAKE_CXX_FLAGS_RELEASE whose text is -O3 -DNDEBUG.

WHY. Instead of memorising -O3 -DNDEBUG and typing it every time, you type one friendly word. The name is a shortcut for a bundle. That's the entire abstraction — nothing more clever is happening.

PICTURE. Four labelled boxes (Debug, Release, RelWithDebInfo, MinSizeRel). Open each box and its flag-string spills out. Notice the box name and the box contents are different things — a point people constantly conflate.

Figure — CMake build types — Debug, Release, RelWithDebInfo
Box (name) Contents (the string inside)
CMAKE_CXX_FLAGS_DEBUG -g
CMAKE_CXX_FLAGS_RELEASE -O3 -DNDEBUG
CMAKE_CXX_FLAGS_RELWITHDEBINFO -O2 -g -DNDEBUG
CMAKE_CXX_FLAGS_MINSIZEREL -Os -DNDEBUG
  • The suffix (_DEBUG, _RELEASE, …) is the box's name, spelled in UPPERCASE.
  • The right column is the literal text CMake will later paste onto the command line.

Step 4 — The selector: one variable picks one box

WHAT. The variable CMAKE_BUILD_TYPE holds one word. That word is the pointer telling CMake which box to open.

WHY. You need one place to say "use Release." That place is this variable. Set on the command line as -DCMAKE_BUILD_TYPE=Release, it stores the word Release in the cache. CMake then looks up CMAKE_CXX_FLAGS_ + RELEASE.

PICTURE. An arrow labelled CMAKE_BUILD_TYPE = Release points from your terminal into exactly one of the four boxes, lighting it up. The other three go dim.

Figure — CMake build types — Debug, Release, RelWithDebInfo

  • CMAKE_CXX_FLAGS_ — the fixed prefix, same for every type.
  • uppercase(CMAKE_BUILD_TYPE) — your word, Release, uppercased to RELEASE, glued on to form the box name.

Step 5 — Gluing it together: the final command line

WHAT. The command line is built by concatenation (joining strings end-to-end): a base string that is always applied, plus the chosen box's contents.

WHY. There are two boxes, not one. CMAKE_CXX_FLAGS (no suffix) is your own always-on flags — things like warnings you want in every build. The per-type box adds the build-type-specific part. The final line is their sum.

PICTURE. Two puzzle pieces snapping together into one command-line strip. Left piece = base flags (always). Right piece = the lit box from Step 4.

Figure — CMake build types — Debug, Release, RelWithDebInfo

Concrete trace for Release: base is (say) empty, box is -O3 -DNDEBUG, so the compiler receives g++ main.cpp -O3 -DNDEBUG. That is the whole journey from word to command.


Step 6 — Reading the effects: what each flag changed

WHAT. Now we run the two extreme boxes side by side and watch what changed in the output program, not the command.

WHY. Steps 1–5 explained how the string is chosen. This step closes the loop: why anyone cares. We look at three observable consequences.

PICTURE. Two program bars, Debug vs Release. Debug is short (fast to compile), carries a fat "symbol map" tag (-g), keeps its safety checks (asserts ON), and runs slowly. Release is the reverse.

Figure — CMake build types — Debug, Release, RelWithDebInfo
  • -O level → runtime speed. -O0 (Debug) runs slow but the machine code follows your source line-by-line. -O3 (Release) reorders and deletes code for speed → fast, but hard to step through.
  • -g → a symbol table, a map from machine instructions back to your source lines for gdb. Costs disk size, zero runtime cost.
  • -DNDEBUG → deletes assert(). It defines the NDEBUG macro; the standard assert(...) then expands to nothing. See assert and the NDEBUG macro.

Step 7 — The degenerate case: forgetting the word entirely

WHAT. What if CMAKE_BUILD_TYPE is left empty? Then the selector arrow of Step 4 points at no box. No per-type flags are added at all.

WHY. This is the single most common real-world bug. People assume "no type = Release-ish default." Wrong: empty means no optimization flags — the program behaves like -O0 (no speed) with no -DNDEBUG. Your "release" binary is secretly a slow debug-speed binary.

PICTURE. The selector arrow dangling into empty space; all four boxes dim; the final command line has an empty right-hand puzzle piece.

Figure — CMake build types — Debug, Release, RelWithDebInfo

Step 8 — The other degenerate case: multi-config generators

WHAT. Some generators (Visual Studio, Xcode, Ninja Multi-Config) carry all boxes at once and pick the box at build time, ignoring CMAKE_BUILD_TYPE at configure time.

WHY. Single-config generators (Makefiles, plain Ninja) bake one chosen box into the cache — one configure, one type. Multi-config keep every box and let you choose per build. So the Step 4 arrow moves from configure time to build time. Detail: Single-config vs Multi-config Generators and Ninja vs Make.

PICTURE. Two timelines. Single-config: the arrow fires at configure. Multi-config: all boxes survive configure, and the arrow fires later at build via --config.

Figure — CMake build types — Debug, Release, RelWithDebInfo
# single-config (Makefiles / Ninja): choose at configure
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
 
# multi-config (VS / Xcode / Ninja Multi-Config): choose at build
cmake --build build --config Release

The one-picture summary

WHAT. The whole pipeline in one strip: your word → the selector variable → the picked box → concatenated with the base → the compiler command → the observable program.

Figure — CMake build types — Debug, Release, RelWithDebInfo
Recall Feynman: retell the whole walkthrough in plain words

Compiling is just one long line of text handed to a translator program: its name, your files, and some short instructions called flags (Step 1). Only three of those instructions matter: how hard to work for speed (-O), whether to keep a map back to your code (-g), and whether to keep safety checks (-DNDEBUG) — and none of these three forces the others, they're separate dials (Step 2). A "build type" is nothing but a friendly name stuck on a little box holding a fixed string of those flags (Step 3). You point at one box by putting its name in the CMAKE_BUILD_TYPE variable (Step 4). CMake then glues an always-on base string to that box's contents and that is the flags on the command line (Step 5). Run it and you see the payoff: fast-or-debuggable, safe-or-stripped (Step 6). Forget to name a box and you get no flags — a secretly slow build (Step 7). And on Visual-Studio-style generators every box is kept around, so you name your box later, at build time, with --config (Step 8). One word in, one command line out — no magic.

Recall

What is a build type, in one sentence? ::: A friendly name attached to a fixed bundle of compiler flags (CMAKE_CXX_FLAGS_<TYPE>). The + in means what? ::: Literal string concatenation — base flags laid beside the chosen box's flags. Why does empty CMAKE_BUILD_TYPE give a slow binary? ::: The selector points at no box, so zero optimization flags are added — behaves like -O0. Why can RelWithDebInfo have both -O2 and -g? ::: -O and -g are orthogonal dials; setting one places no constraint on the other. Where do multi-config generators pick the type? ::: At build time via --config, not from CMAKE_BUILD_TYPE at configure time.