C++ is a complex language, and many of its features can be misused. clang-tidy is a tool that analyzes code for a wide range of common mistakes and raises warnings when it finds them. When run in CI, it helps enforce code quality and prevents many subtle bugs from reaching production.
This article explores why clang-tidy matters and how to enable it easily in your project.
Why clang-tidy matters
clang-tidy helps ensure that C++ features are used correctly. When misused, they can lead to simple bugs, hard-to-debug issues, sporadic failures, and sometimes performance degradation that remains invisible at first but slowly hurts the project over time.
Consider the following code:
class MethodInvoker {
public:
template<typename F, typename... Args>
void invoke(F f, Args&&... args) {
boost::asio::post(io_context, [this, f = std::move(f), args...]() mutable {
f(std::forward<Args>(args)...);
});
}
private:
boost::asio::io_context io_context;
};
`
There is no functional bug here. But the forwarding references are used incorrectly: args... are captured by value. Even if an argument is passed as an rvalue, it may still be copied into the lambda. For trivially copyable types this may not matter, but for heavier types such as std::string or std::vector, it introduces an unnecessary performance penalty.
This is the kind of issue that is easy to miss during code review, and tests usually won’t reveal it. clang-tidy, however, can detect it and warn about it.
Here is another example — a typical C++ mistake: use-after-move.
class Account {
public:
virtual ~Account() = default;
virtual int amount() const = 0;
virtual std::string_view name() const = 0;
};
class BankAccounts {
public:
void store(std::unique_ptr<Account> account) {
m_accounts.push_back(std::move(account));
print_log("Store account for {}", account->name());
}
private:
std::vector<std::unique_ptr<Account>> m_accounts;
};
The problem is in BankAccounts::store: it uses account after moving from it.
A moved-from standard library object remains valid, but its state is unspecified. That does not mean every operation on it is automatically undefined behavior. However, it does mean that you must not rely on its previous value. In this particular case, std::unique_ptr becomes empty after the move, so calling account->name() dereferences a null pointer and leads to undefined behavior.
These examples show why static analyzers such as clang-tidy are so valuable in C++ development. Some language misuses are difficult to catch during code review and may never show up in tests — but they are often visible to clang-tidy immediately.
Want more practical C++ breakdowns like this?
I write a monthly newsletter, From complexity to essence in C++, where I explain subtle C++ issues, Modern C++, CMake, and real-world engineering trade-offs in a simple way.
Subscribe here: From complexity to essence in C++
Integrating clang-tidy into CMake
CMake provides a straightforward way to integrate clang-tidy. A single configuration option, CMAKE_CXX_CLANG_TIDY, enables it for the whole project:
cmake -DCMAKE_CXX_CLANG_TIDY=clang-tidy ..
With this option enabled, CMake invokes clang-tidy as part of the normal build process for each translation unit (that is, for each .cpp file). In other words, static analysis becomes tied to compilation. As a result, during an incremental build, clang-tidy is not run again for translation units that are already up to date.
One important detail: if your source tree also contains third-party libraries, their code may be analyzed as well. If that is not what you want, use the per-target property CXX_CLANG_TIDY instead. This allows you to enable clang-tidy only for specific targets.
In practice, it is often convenient to wrap this logic in a small helper function:
option(ENABLE_CLANG_TIDY "" OFF)
function(enable_clang_tidy_for target)
if(ENABLE_CLANG_TIDY)
find_program(CLANG_TIDY_PATH clang-tidy REQUIRED)
set_target_properties(${target}
PROPERTIES
CXX_CLANG_TIDY "${CLANG_TIDY_PATH}")
endif()
endfunction()
You can then apply it only to the targets that belong to your project:
add_library(my-lib STATIC some_file.cpp)
enable_clang_tidy_for(my-lib)
To enable clang-tidy, configure the project with:
cmake -DENABLE_CLANG_TIDY=ON ..
clang-tidy does not compile the code by itself. Instead, it parses each .cpp file, builds the abstract syntax tree (AST), and runs its checks on that representation. That means it needs the same compilation flags, include paths, and preprocessor definitions as the normal build.
When integrated through CMake, this information is passed to clang-tidy automatically.
The .clang-tidy file
clang-tidy has a large number of configuration options: which checks to run, which warnings to promote to errors, which headers to exclude, and more. Technically, you can pass all of these options directly through CMAKE_CXX_CLANG_TIDY or the CXX_CLANG_TIDY target property, but that is usually not the best approach.
When it runs, clang-tidy looks for a .clang-tidy file in the source tree. This file contains the configuration in YAML format and is the usual place to define checks, warning policies, and header filters.
Some of the most important options are:
Checks— defines which checks to run,ExcludeHeaderFilterRegex— a regular expression used to exclude header files from diagnostics (for.cppfiles, the usual approach is to control which targets haveclang-tidyenabled),WarningsAsErrors— specifies which warnings should be treated as errors.
The entries in Checks and WarningsAsErrors can both enable and disable checks. A check name prefixed with - disables it. You can also use the wildcard * to match groups of checks.
For example:
---
# Enable groups of checks: bugprone, readability, and modernize.
# Disable specific checks using the '-' prefix.
Checks: >
bugprone-*,
readability-*,
modernize-*,
-modernize-use-trailing-return-type,
-readability-magic-numbers,
-bugprone-narrowing-conversions
Not every check makes sense for every project. In this example, the configuration enables the bugprone-*, readability-*, and modernize-* groups, while explicitly disabling:
modernize-use-trailing-return-typereadability-magic-numbersbugprone-narrowing-conversions
That kind of selective configuration is important: clang-tidy is most useful when it reflects the rules your project actually wants to enforce.
How to introduce clang-tidy into an existing project
The main goal of using clang-tidy is to make it part of your regular CI pipeline.
An especially important step is promoting selected warnings to errors. This prevents problematic code from being merged into the main branch.
The best-case scenario is to have clang-tidy enabled from the beginning. In that case, incorrect code is blocked before it ever becomes part of the project.
The harder case is introducing clang-tidy into an already existing codebase.
If you enable all checks at once, you may get thousands of warnings. Fixing all of them at once is usually unrealistic. And if you try to do it in one large pull request, new warnings may appear before the cleanup is finished.
That is why introducing clang-tidy requires a strategy.
A practical approach looks like this:
- Create a
.clang-tidyconfiguration file. - Decide which groups of checks matter most for your project.
- Enable those groups in the
Checksoption. - Disable only the specific checks that do not make sense for your codebase.
- Run
clang-tidyonce and collect the list of existing warnings. - Add those warnings to
WarningsAsErrorsto exclude the currently existing violations from blocking the build. - Then, step by step, remove one exclusion from
WarningsAsErrors, fix the corresponding issues, and commit the result.
With this approach, clang-tidy initially tolerates the warnings that already exist in the project, but fails the build if new ones are introduced. That is important, because it prevents further degradation.
By fixing one check (or one class of warnings) at a time, you make steady progress without blocking development. More importantly, once a category of issues is cleaned up, it stays clean.
This is a practical way to introduce clang-tidy even into a large existing codebase with thousands of warnings.
Conclusion
C++ is powerful, but that power comes with many ways to make mistakes that still compile.
clang-tidy helps make those mistakes visible early. It gives you a practical safety net: not by replacing thinking, but by consistently checking patterns that are easy for humans to miss.
Used from the start, it keeps a project clean. Added later, it still brings value — as long as you introduce it gradually and prevent new issues from slipping in.
That is why clang-tidy is worth using: it turns part of C++ complexity into something much easier to control.
Want to go deeper?
If you want a more structured, end-to-end view of real C++ development — from project structure and CMake to practical engineering decisions — take a look at my book:
No More Helloworlds — Build a Real C++ App
Learn more: https://sqglobe.com/no-more-helloworlds-build-a-real-c-app/