You already know from the parent note that a build type is just a named bundle of compiler flags. This page does one thing: it walks through every kind of situation that topic can throw at you, so you never meet a case you haven't seen worked.
Before we compute anything, let's earn one idea we will lean on constantly.
Three "knobs" live inside that second string. Picture them as three light-switches on a wall:
-O (optimization) — a dial from -O0 (off) to -O3 (aggressive). More is faster to run, slower to compile, and harder to debug.
-g (debug info) — an on/off switch. On = the compiler bundles a symbol table (a map from machine instructions back to your source lines and variable names) so gdb can talk to you in English. Off = the map is gone. Costs disk, never costs runtime.
-DNDEBUG — a switch that defines the NDEBUG macro, which makes `assert()` vanish into nothing.
The red switch above is -g on purpose: it's the one everybody wrongly assumes Release must turn off. It doesn't have to. Keep that in mind.
Every question this topic asks is one of these cells. The worked examples below are each tagged with the cell(s) they cover, and together they hit all of them.
#
Cell (the "case class")
Representative question
A
Standard type → flags
"What does Release actually inject?"
B
The zero / empty case
"I forgot to set the type — what happens?"
C
Orthogonality (-gand-O together)
"Fast binary but readable crashes?"
D
The NDEBUG confusion case
"Does -DNDEBUG kill my debug code?"
E
Multi-config degenerate case
"Why did -DCMAKE_BUILD_TYPE=Debug get ignored?"
F
Custom / user-defined type
"Can I make an ASAN type?"
G
Limiting case — smallest size
"Firmware won't fit — which type?"
H
Exam twist — flag override / precedence
"What wins: base flags or per-type flags?"
Worked example Example 1 — Cell A: the standard Release binary
With the GCC/Clang defaults, what flags reach the compiler, and are your assert() calls still alive?
Forecast. Guess the flag string and the assert answer before reading on.
Look up T=Release in the flag-sum rule. The per-type variable CMAKE_CXX_FLAGS_RELEASE defaults to -O3 -DNDEBUG.
Why this step? The type name is nothing but a lookup key; everything follows from which string it points at.
Add the always-on base. If CMAKE_CXX_FLAGS is empty (typical), the total is just -O3 -DNDEBUG.
Why this step? The rule says base + per-type; you must include both even when base is empty.
Read off NDEBUG's effect.-DNDEBUG defines NDEBUG, so every assert(x) expands to nothing — the checks are gone.
Why this step? This is the one behavioural change (not just speed) that Release causes.
Answer. Flags = -O3 -DNDEBUG; asserts are disabled.
Verify. Sanity check the direction: -DNDEBUG present ⟹ asserts off ⟹ fewer runtime checks ⟹ faster. Consistent. The parent's flag table lists exactly -O3 -DNDEBUG for Release. ✓
Worked example Example 2 — Cell B: the empty / zero case (the classic footgun)
Statement. You forgot the type:
cmake -S . -B buildcmake --build build
A colleague says "it's basically Release." Is it? What is the effective -O level?
Forecast. Fast or slow binary?
Substitute T="" (empty) into the rule. With no type, CMAKE_CXX_FLAGS_<empty> is the empty string.
Why this step? The "zero input" of this topic is an emptyCMAKE_BUILD_TYPE, and the rule handles it just like any other value — it simply contributes nothing.
Total the flags. flags = CMAKE_CXX_FLAGS (base, usually empty) + nothing = no -O, no -g, no -DNDEBUG.
Why this step? Nothing injected means we fall back to the compiler's own default, which for GCC/Clang is -O0.
Interpret.-O0 ⟹ unoptimized ⟹ slow. It only looks like Release because the code is identical.
Why this step? This is the whole trap: sameness of source ≠ sameness of speed.
Answer. Effective level is -O0. It is not Release — it can be many times slower.
Verify. Confirm with the cache after configuring: grep CMAKE_BUILD_TYPE build/CMakeCache.txt shows CMAKE_BUILD_TYPE:STRING= (empty). Empty in, empty flags out — matches the parent's "acts like -O0" warning. ✓
Worked example Example 3 — Cell C: orthogonality,
-gand-O at once
Statement. Production crashes in the field. You need a fast binary and a backtrace that shows function names, not ??. Which type, and what exactly makes both possible?
Forecast. Can one binary be fast and debuggable? Or must you choose?
Recall the two knobs are independent switches (see the figure at the top). Speed is the -O dial; readability is the -g switch. Nothing forces them to move together.
Why this step? The entire answer hinges on -g and -O being orthogonal — the parent's key insight.
Pick the type that flips both the right way.RelWithDebInfo = -O2 -g -DNDEBUG: high optimization and the symbol table.
Why this step? We want the dial high and the map switch on — exactly this cell.
Reject the neighbours.Release drops -g (crash shows ??); Debug uses -O0 (too slow, different behaviour from real load).
Why this step? Covering the why-not cases is what proves this cell is genuinely distinct.
Answer.RelWithDebInfo (-O2 -g -DNDEBUG).
Verify. Count the knobs: optimization present (-O2 ✓), symbols present (-g ✓), asserts off for prod speed (-DNDEBUG ✓). All three consistent with "fast but explainable." ✓
Worked example Example 4 — Cell D: does
-DNDEBUG kill my own debug logging?
Statement. You have this in your code:
You build Release (which injects -DNDEBUG). Which of (1), (2), (3) still run?
Forecast. Guess yes/no for each line.
Line (1): it's a standard assert.-DNDEBUG defines NDEBUG; the standard assert() macro expands to nothing when NDEBUG is defined. → removed.Why this step?assert is the only thing NDEBUG is contractually allowed to gate.
Line (2): a plain if.NDEBUG has zero connection to your own runtime if. → still runs (whenever verbose is true).
Why this step? The name "NDEBUG" sounds like "no debugging," but it touches nothing you didn't wrap in it yourself.
Line (3): you wrote #ifndef NDEBUG yourself. With NDEBUG defined, #ifndef NDEBUG is false → the block is compiled out. → removed.Why this step? You opted this code into the same switch, so it shares assert's fate — but only because you chose to.
Answer. (1) removed, (2) kept, (3) removed.
Verify. Rule of thumb: NDEBUG affects assert()and anything you personally guarded with NDEBUG. Line (2) is guarded by nothing NDEBUG-related, so it survives. Matches the parent's "your own if(debug) logic is untouched." ✓
Worked example Example 5 — Cell E: the multi-config degenerate case
You expected a Debug binary; you got a Release-flavoured one. Why? What's the correct command?
Forecast. Where did the -DCMAKE_BUILD_TYPE=Debug go?
Classify the generator. Visual Studio (also Xcode, Ninja Multi-Config) is a multi-config generator — see Single-config vs Multi-config Generators.
Why this step? The flag-sum rule's "pick T at configure time" story only holds for single-config generators; multi-config carries all types.
See why the flag is ignored. A multi-config build has no single baked-in T at configure time, so CMAKE_BUILD_TYPE is silently dropped; the default active config is often Debug... but the default of the build command may differ per generator.
Why this step? This is the degenerate edge: the "universal incantation" simply has no meaning here.
Choose the type at build time. You select the config when you build:
cmake --build build --config Debug
Why this step? Multi-config generators decide Tper build invocation, not at configure.
Answer.CMAKE_BUILD_TYPE is ignored by multi-config generators; use --config Debug at build time.
Verify. Consistency check: single-config = one T frozen in the cache; multi-config = all T available, chosen with --config. The command that actually names the type at build time is the correct one. ✓
Worked example Example 6 — Cell F: defining your own build type (ASan)
Statement. You want a type named ASAN that gives light optimization, debug symbols, and AddressSanitizer. Write the CMake, then predict the flags a file compiled under -DCMAKE_BUILD_TYPE=ASAN receives.
Why this step? The flag-sum rule looks up CMAKE_CXX_FLAGS_<TYPE>. If that variable exists, the type name is valid — types aren't a fixed enum, they're variable lookups (see CMake Cache Variables).
Configure with the new name.cmake -S . -B build -DCMAKE_BUILD_TYPE=ASAN.
Why this step? Now T=ASAN resolves to the string you defined.
Apply the rule. flags = base +-O1 -g -fsanitize=address.
Why this step? Same machine as every other type — no special case.
Answer. The compile line gets -O1 -g -fsanitize=address (plus base). A brand-new, fully working build type — see Sanitizers (ASan, UBSan).
Verify. The type is legal iff CMAKE_CXX_FLAGS_ASAN is set — which we set. Note it deliberately has no-DNDEBUG, so asserts stay live, which is what you want while hunting memory bugs. Self-consistent. ✓
Worked example Example 7 — Cell G: limiting case, smallest possible binary
Statement. Firmware for a microcontroller with tiny flash. The -O3 build doesn't fit. Which standard type minimises size, what flag does it inject, and what do you give up?
Forecast. Which knob position minimises bytes?
Identify the size-minimising -O position.-Os = "optimize for size": like -O2 but declines transforms that bloat code (aggressive inlining, loop unrolling — see Compiler Optimization Levels (-O0 to -O3)).
Why this step? Size is a different objective than speed, so it needs its own dial position — the limiting case of "small," not "fast."
Map it to the standard type.MinSizeRel = -Os -DNDEBUG.
Why this step? This is the only standard type built around -Os.
State the trade-off. No -g (smaller, but crashes show ??), asserts off (-DNDEBUG), and possibly slightly slower than -O3.
Why this step? Every extreme buys one thing by spending another; naming the cost completes the case.
Answer.MinSizeRel → -Os -DNDEBUG; you trade away debug symbols and a little speed for smallest size.
Debug implies -O0 via its default flags; you also forced -O2 into the base. The compiler sees something like -O2 ... -g. Which optimization level actually applies?
Forecast. Does -O0 (from Debug) or -O2 (your base) win?
Write the concatenation in order. flags = CMAKE_CXX_FLAGS+CMAKE_CXX_FLAGS_DEBUG = -O2+-g. (Debug's default per-type string is essentially -g; the -O0 is the compiler's own default, not always an explicit flag.)
Why this step? The flag-sum rule is ordered: base comes first, per-type second.
Apply the compiler's "last -O wins" rule. GCC/Clang honour the last optimization flag on the line. Here the only explicit -O is -O2 (from base), and Debug adds no explicit -O.
Why this step? When two conflicting flags appear, order on the command line decides — this is the twist the question is testing.
Conclude. The binary compiles at -O2, with-g (Debug still contributed its symbols).
Why this step? You accidentally built a RelWithDebInfo-like binary while asking for Debug.
Answer.-O2 wins; you get an optimized-but-symbol-rich build, not a true -O0 Debug.
Verify. Rule: later -O overrides earlier. Base string is emitted first, but it holds the only explicit -O, so it stands. Lesson: don't hand-inject -O into CMAKE_CXX_FLAGS; let the type own it. ✓
An empty CMAKE_BUILD_TYPE produces which optimization level? ::: -O0 (no flags injected — the "zero input" case)
One binary that is both fast and shows function names in a crash — which type? ::: RelWithDebInfo (-O2 -g -DNDEBUG)
Does -DNDEBUG disable a plain if(verbose) line? ::: No — it only gates assert() and code you wrapped in #ifndef NDEBUG
Why is -DCMAKE_BUILD_TYPE=Debug ignored by Visual Studio/Xcode? ::: They are multi-config generators; pick the type at build time with --config
What makes a custom type name like ASAN valid? ::: Defining the variable CMAKE_CXX_FLAGS_ASAN
Standard type for the smallest binary, and its flag? ::: MinSizeRel → -Os -DNDEBUG
If base flags hold -O2 and Debug adds -g, which -O applies? ::: -O2 — the last/only explicit -O on the command line wins