CMake basics that actually matter: targets.

Modern Cmake Is About Targets, Not Variables

I spent three years in high-frequency trading where a single misconfigured build flag didn’t just mean a failed test—it meant a million-dollar slippage in a production environment. Most tutorials treat CMake like a magic wand, a series of “copy-paste these five lines” steps that supposedly solve your problems. But if you’re just blindly following those boilerplate templates without understanding how they manipulate your dependency graph, you aren’t building a project; you’re building a ticking time bomb. I’m tired of seeing developers struggle with linker errors that could have been avoided if they had mastered the cmake basics that actually matter instead of memorizing outdated patterns from a 2014 StackOverflow thread.

I have no interest in teaching you how to write a “Hello World” script that works on your machine but breaks the moment a colleague tries to compile it on Linux. Instead, I’m going to strip away the abstraction and show you how the tool actually interacts with your compiler and linker. We are going to focus on the mechanics of the build process—the parts that actually dictate whether your project is scalable or just a collection of fragile hacks.

Table of Contents

Why Variable Scoping Is a Silent Build Killer

Why Variable Scoping Is a Silent Build Killer

The problem with CMake isn’t just syntax; it’s the way it handles state. Most developers treat a `CMakeLists.txt` file like a linear script, but it’s actually a nested tree of scopes. When you set a variable in a subdirectory, you might assume it’s globally available, or conversely, that it won’t leak upwards. Both assumptions are dangerous. If you aren’t understanding cmake variables and cache properly, you’ll eventually hit a wall where a flag set in a leaf directory silently fails to propagate to your main executable, leaving you wondering why your optimization levels look like they’re from 1998.

This is where the “silent” part comes in. Unlike a compiler error that screams at you during the build, scoping issues usually manifest as logical inconsistencies during the configuration phase. You think you’ve configured your dependency management correctly, but a variable shadowed by a sub-module has effectively neutered your build settings. Moving toward a cmake target based approach is the only way to stop playing this guessing game. By attaching properties directly to targets rather than relying on global variable state, you stop fighting the tool and start actually using it.

Modern Cmake Best Practices for Sanity

Modern Cmake Best Practices for Sanity.

If you are still using `include_directories()` or `link_libraries()` at the global level, you aren’t writing CMake; you’re writing a recipe for a dependency nightmare. The only way to maintain sanity in a non-trivial project is to adopt a strict cmake target-based approach. You should treat every library and executable as an isolated object with its own requirements. Instead of polluting the global namespace, you use `target_include_directories()` and `target_link_libraries()` to define exactly what a specific target needs to compile and what it needs to run.

This isn’t just about tidiness; it’s about controlling the propagation of requirements. When you properly distinguish between `PUBLIC`, `PRIVATE`, and `INTERFACE` keywords, you are explicitly telling the build system how to handle the transitive chain of dependencies. This level of precision is the backbone of modern cmake best practices. It ensures that when Target A links to Target B, it doesn’t accidentally inherit a dozen unrelated header paths that happen to be sitting in the cache. If you don’t define these boundaries now, your build configuration will eventually collapse under its own weight.

The Five Rules for Not Losing Your Mind

  • Stop using `include_directories()`. It’s a blunt instrument that leaks include paths into every subdirectory, polluting the global namespace and causing header collisions that are a nightmare to debug. Use `target_include_directories()` with `PUBLIC`, `PRIVATE`, or `INTERFACE` keywords instead. You need to explicitly define who actually needs that header to compile.
  • Treat `add_definitions()` like a landmine. It injects flags globally across your entire build tree. If you’re working on a large project, one rogue `-D` flag can change the behavior of a third-party library you didn’t even write, and you’ll spend three days chasing a phantom bug. Use `target_compile_definitions()` to scope your flags to the specific target that requires them.
  • Avoid the “Global Variable Trap” by refusing to use `set(VAR …)` for anything that isn’t a local helper. If you’re setting variables that need to propagate through a complex dependency graph, use Cache variables or, better yet, build-system properties. Global state in a build script is just technical debt with a faster compile time.
  • Stop treating `find_package()` as a black box. If it fails, don’t just mash environment variables around until it works. Learn to use `CMAKE_PREFIX_PATH` and understand the difference between a module mode search and a config mode search. Knowing where CMake is actually looking for a `.cmake` file saves hours of staring at “package not found” errors.
  • Use `target_link_libraries()` to manage your dependency graph, not just to fix linker errors. It isn’t just about grabbing `.lib` or `.a` files; it’s about propagating requirements. If Library A needs a specific compiler flag to be compatible with Library B, that relationship should be encoded in the target properties, not hardcoded into your manual build steps.

The Bottom Line

Stop treating CMake variables like global state; if you aren’t using directory-scoped properties or target-based commands, you’re just building a house of cards.

Prefer `target_link_libraries` over `link_libraries` every single time—transitive dependencies are the only way to keep a build system from becoming a nightmare.

If a command doesn’t have a `target_` prefix, you probably shouldn’t be using it in a modern codebase.

Stop Treating CMake Like Magic

At the end of the day, CMake is just a generator. It isn’t your compiler, and it isn’t your linker; it is a script that tells those tools how to behave. If you treat it like a black box, you will eventually succumb to the ghosts in the machine—those inexplicable build failures caused by leaking variables or a misunderstood dependency graph. We have covered why scope matters, why target-based commands are your only defense against dependency hell, and why you should stop using global state like it’s 2005. Mastering these basics isn’t about following a style guide; it is about controlling the build environment so that your code behaves the same way on your machine as it does in the CI pipeline.

Moving from “it compiles on my machine” to a professional-grade build system is a steep climb, but it is one worth making. There is a profound, quiet satisfaction in watching a complex project build cleanly, knowing exactly which flags are being passed to which object files. Don’t be afraid to dig into the generated build files or read the documentation when things go sideways. C++ is a language of precision, and your build tooling should reflect that same rigorous intent. Stop guessing, start specifying, and build something that actually lasts.

About Ruaridh Kensington-Oyelaran

C++ rewards people who know what the compiler is allowed to do. I write about the rules that bite, the ones nobody mentions until you have already shipped the bug.

More From Author

Threads in C++ the basics: program termination.

A Thread You Neither Join Nor Detach Terminates the Program

Choosing the right container for vector designs.

Start With Vector and Change Your Mind Only With Evidence