clang-tidy
Static analysis and linting for C++ — checks for bugs, style issues, modernization opportunities, and performance problems.
TL;DR
clang-tidy is a clang-based linting tool. It finds real bugs (not just style issues), offers automatic fixes, and integrates with CMake, CI, and editors. Use it with compile_commands.json for accurate analysis.
clang-tidy src/main.cpp -- -std=c++20 -I include/
# With compile_commands.json (preferred):
clang-tidy src/main.cppSetup
Generate compile_commands.json
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# build/compile_commands.json is now available
# Symlink to project root for editors:
ln -s build/compile_commands.json compile_commands.json.clang-tidy config file
Place .clang-tidy in project root:
# .clang-tidy
Checks: >
clang-diagnostic-*,
clang-analyzer-*,
cppcoreguidelines-*,
modernize-*,
performance-*,
readability-*,
bugprone-*,
-modernize-use-trailing-return-type,
-readability-magic-numbers,
-cppcoreguidelines-avoid-magic-numbers
WarningsAsErrors: ''
HeaderFilterRegex: '(src|include)/.*'
FormatStyle: fileRunning clang-tidy
# Single file
clang-tidy src/main.cpp
# Entire project (parallel)
run-clang-tidy -p build/ -j$(nproc)
# Apply automatic fixes
clang-tidy -fix src/main.cpp
# Apply fixes for entire project
run-clang-tidy -p build/ -fix -j$(nproc)Check categories
bugprone — catches real bugs
// bugprone-use-after-move
auto v = make_vector();
use(std::move(v));
use(v); // WARNING: v was moved from
// bugprone-integer-division
double result = 1 / 2; // WARNING: integer division (result = 0.0)
// bugprone-narrowing-conversions
long long big = 1LL << 40;
int small = big; // WARNING: narrowing conversion
// bugprone-branch-clone
if (a) { return 1; }
else { return 1; } // WARNING: both branches identicalmodernize — upgrades old C++ idioms
// modernize-use-nullptr
int* p = NULL; // WARNING: use nullptr
int* p = nullptr; // FIXED
// modernize-use-override
struct Derived : Base {
void foo(); // WARNING: missing override
void foo() override; // FIXED
};
// modernize-use-emplace
v.push_back(MyType(arg1, arg2)); // WARNING
v.emplace_back(arg1, arg2); // FIXED (avoids temporary)
// modernize-loop-convert
for (int i = 0; i < v.size(); ++i) {
use(v[i]); // WARNING: can be range-for
}
for (const auto& x : v) { use(x); } // FIXEDperformance — avoids inefficiencies
// performance-unnecessary-copy-initialization
std::string s = getString(); // WARNING: unnecessary copy if func returns string
const auto& s = getString(); // or: auto s = getString(); (move)
// performance-for-range-copy
for (auto x : container) { use(x); } // WARNING: copies each element
for (const auto& x : container) { ... } // FIXED
// performance-move-const-arg
void f(std::string&& s);
std::string str = "hello";
f(std::move(str)); // OK
const std::string cstr = "hi";
f(std::move(cstr)); // WARNING: move on const has no effectcppcoreguidelines — C++ Core Guidelines
// cppcoreguidelines-owning-memory
void bad() {
int* p = new int(42); // WARNING: use smart pointer
delete p;
}
// cppcoreguidelines-pro-bounds-array-to-pointer-decay
void bad(int arr[10]) { ... } // WARNING: use std::span
void good(std::span<int, 10> arr) { ... }
// cppcoreguidelines-special-member-functions
struct MyClass {
~MyClass(); // WARNING: if you define destructor,
// define copy/move too (Rule of 5)
};CMake integration
find_program(CLANG_TIDY NAMES clang-tidy clang-tidy-17 clang-tidy-18)
if(CLANG_TIDY)
set_target_properties(myapp PROPERTIES
CXX_CLANG_TIDY "${CLANG_TIDY};--warnings-as-errors=*"
)
endif()Or run as a separate target:
add_custom_target(tidy
COMMAND run-clang-tidy -p ${CMAKE_BINARY_DIR} -j$(nproc)
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Running clang-tidy"
)CI with GitHub Actions
- name: Install clang-tidy
run: sudo apt-get install -y clang-tidy
- name: Configure (with compile_commands)
run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
- name: Run clang-tidy
run: |
run-clang-tidy -p build/ \
-header-filter="$(pwd)/include/.*" \
-warnings-as-errors="*"Editor integration
VS Code (clangd)
clangd runs clang-tidy checks automatically if .clang-tidy is present. No extra config needed.
Neovim (clangd via nvim-lspconfig)
Same — clangd picks up .clang-tidy automatically.
CLion
Settings → Editor → Inspections → clang-tidy. Enable and configure checks per project.
Suppressing individual warnings
// NOLINT — suppress for this line
int x = NULL; // NOLINT(modernize-use-nullptr)
// NOLINTBEGIN / NOLINTEND — suppress for a block
// NOLINTBEGIN(cppcoreguidelines-owning-memory)
int* raw = new int(42);
delete raw;
// NOLINTEND(cppcoreguidelines-owning-memory)Recommended starting config
Start with a focused set, then expand:
Checks: >
bugprone-*,
modernize-use-nullptr,
modernize-use-override,
modernize-use-default-member-init,
performance-unnecessary-copy-initialization,
performance-for-range-copy,
readability-const-return-type,
readability-container-size-empty,
-bugprone-easily-swappable-parameters
WarningsAsErrors: 'bugprone-*'Check Categories Deep Dive
Each check category targets a distinct class of problem. Knowing which checks belong to which category helps you enable exactly what you need without drowning in noise.
modernize-* — C++11/14/17 upgrades
The modernize-* family automatically rewrites old C++ into idiomatic modern code. Most checks in this category carry auto-fix support, so enabling them costs almost nothing.
| Check | What it flags | Fix |
|---|---|---|
modernize-use-nullptr | NULL or 0 used as null pointer | Replace with nullptr |
modernize-use-override | Virtual overrides missing override keyword | Append override |
modernize-use-emplace | push_back(T(args...)) constructing a temporary | Replace with emplace_back(args...) |
modernize-loop-convert | Index-based loops over containers | Rewrite as range-for |
modernize-use-auto | Explicit type repetition on the right-hand side | Replace with auto |
modernize-make-unique | std::unique_ptr<T>(new T(...)) | Replace with std::make_unique<T>(...) |
modernize-make-shared | std::shared_ptr<T>(new T(...)) | Replace with std::make_shared<T>(...) |
modernize-use-default-member-init | Initialising members in constructor body when a default suffices | Move init to member declaration |
modernize-return-braced-init-list | return T{a, b} written as return T(a, b) | Use braced-init-list |
modernize-use-nodiscard | Functions returning a value that is never [[nodiscard]] | Add attribute |
// BEFORE: pre-C++11 patterns flagged by modernize-*
class Widget {
int value_;
public:
Widget() : value_(0) {} // modernize-use-default-member-init
virtual void draw(); // modernize-use-override (in subclass)
};
std::vector<Widget*> widgets;
for (int i = 0; i < widgets.size(); ++i) { // modernize-loop-convert
widgets[i]->draw();
}
std::unique_ptr<Widget> w(new Widget()); // modernize-make-unique
// AFTER: what clang-tidy --fix produces
class Widget {
int value_ = 0; // default member init
public:
virtual void draw();
};
for (auto* w : widgets) { // range-for
w->draw();
}
auto w = std::make_unique<Widget>(); // make_uniquebugprone-* — common bugs
bugprone-* checks find code patterns that are technically legal but almost always wrong. These are the checks to turn on first and treat as errors.
| Check | What it flags |
|---|---|
bugprone-use-after-move | Using a variable after it has been std::moved |
bugprone-integer-division | Integer division assigned to a floating-point variable |
bugprone-narrowing-conversions | Silent lossy integer/float conversions |
bugprone-branch-clone | if/else branches with identical bodies |
bugprone-signed-char-misuse | Using char (possibly signed) to hold binary data |
bugprone-string-constructor | std::string(ptr, 0) and similar easy misuses |
bugprone-suspicious-memset-usage | memset(p, val, 0) — size and value args swapped |
bugprone-dangling-handle | std::string_view or similar bound to a temporary |
bugprone-infinite-loop | Loop with no reachable exit condition |
bugprone-unchecked-optional-access | Dereferencing std::optional without checking has_value() |
// bugprone-dangling-handle: common string_view trap
std::string_view sv = std::string("hello"); // temporary destroyed immediately
// sv is now dangling — clang-tidy warns here
// bugprone-suspicious-memset-usage
char buf[64];
memset(buf, 64, 0); // WARNING: probably meant memset(buf, 0, 64)
// bugprone-unchecked-optional-access
std::optional<int> opt = getValue();
int x = *opt; // WARNING: check has_value() firstcppcoreguidelines-* — C++ Core Guidelines enforcement
The cppcoreguidelines-* checks encode Bjarne Stroustrup and Herb Sutter's C++ Core Guidelines so a tool can enforce them mechanically.
Key checks and what they mandate:
// cppcoreguidelines-pro-type-cstyle-cast
int x = (int)someDouble; // WARNING: use static_cast / narrow_cast
int x = static_cast<int>(someDouble); // OK
// cppcoreguidelines-pro-bounds-pointer-arithmetic
int arr[10];
int* p = arr + 5; // WARNING: use std::span or at least std::array
*(p + 1) = 0; // WARNING: prefer arr[6] or span indexing
// cppcoreguidelines-avoid-goto
again:
if (!ready) goto again; // WARNING: use loops
// cppcoreguidelines-no-malloc
int* p = (int*)malloc(sizeof(int) * 10); // WARNING: use new or containers
free(p); // WARNING: use delete
// cppcoreguidelines-pro-type-union-access
union { int i; float f; } u;
u.i = 1;
float bad = u.f; // WARNING: undefined behaviour via type-punningperformance-* — avoids inefficiencies
performance-* checks find code that compiles and runs correctly but wastes CPU cycles or memory. They are particularly valuable in hot paths.
readability-* — code clarity
readability-* improves human comprehension without changing program semantics. Use these to enforce consistent style across a team.
Key readability checks:
// readability-identifier-naming — enforce casing conventions (configured in .clang-tidy)
int MyVariable = 0; // WARNING if snake_case is required
// readability-const-return-type — const on return type is useless for scalars
const int getValue() { return 42; } // WARNING: const on value return is misleading
// readability-container-size-empty
if (v.size() == 0) { ... } // WARNING: use v.empty()
if (v.size() != 0) { ... } // WARNING: use !v.empty()
// readability-else-after-return
if (cond) {
return x;
} else { // WARNING: else is redundant after return
return y;
}
// readability-simplify-boolean-expr
if (cond == true) { ... } // WARNING: just use cond
return (x > 0) ? true : false; // WARNING: just return x > 0Auto-Fix with --fix
clang-tidy can rewrite your source files directly — many checks are paired with transformations that mechanically apply the correct modern idiom.
# Fix a single file in place
clang-tidy --fix src/main.cpp
# Fix the entire project in parallel
run-clang-tidy -p build/ -fix -j$(nproc)
# Fix even when there are compilation errors in the TU
clang-tidy --fix --fix-errors src/main.cppAlways review the diff before committing auto-fixed code. Fixes are almost always correct, but they operate syntactically — they cannot know your intent when the original code was ambiguous.
# Safe workflow: stage current state, apply fixes, review diff
git stash
clang-tidy --fix src/widget.cpp
git diff src/widget.cpp # review what changed
git add src/widget.cpp
git stash dropNot every check ships with a fix. The table below summarises fix availability for common checks:
| Check | Auto-fix? |
|---|---|
modernize-use-nullptr | Yes |
modernize-use-override | Yes |
modernize-make-unique | Yes |
modernize-loop-convert | Yes |
bugprone-use-after-move | No — fix requires understanding intent |
bugprone-integer-division | No |
performance-for-range-copy | Yes |
readability-container-size-empty | Yes |
cppcoreguidelines-pro-type-cstyle-cast | Yes (inserts static_cast) |
The --fix-errors flag tells clang-tidy to apply fixes even in translation units that contain errors. Use with caution — broken code plus mechanical fixes can produce unexpected results.
Per-File and Per-Directory Configuration
.clang-tidy files are looked up hierarchically — clang-tidy walks up the directory tree from the analysed file and merges configurations it finds.
project/
├── .clang-tidy ← project-wide defaults
├── src/
│ ├── .clang-tidy ← overrides for src/ only
│ └── legacy/
│ └── .clang-tidy ← legacy code gets lighter checks
└── tests/
└── .clang-tidy ← tests can disable some pedantic checksUse InheritParentConfig: true to layer configs rather than repeat the base settings in every subdirectory file:
# project/src/legacy/.clang-tidy
# Inherits project-root config, then disables checks that legacy code fails
InheritParentConfig: true
Checks: >
-cppcoreguidelines-owning-memory,
-modernize-*,
-readability-identifier-naming# project/tests/.clang-tidy
# Tests can use raw pointers and magic numbers freely
InheritParentConfig: true
Checks: >
-cppcoreguidelines-owning-memory,
-readability-magic-numbers,
-cppcoreguidelines-avoid-magic-numbersTo stop the upward search at a specific directory, set UseColor or any field — any .clang-tidy file without InheritParentConfig: true does not inherit from parent configs.
CMake-generated build directories often contain auto-generated source files that should not be analysed. Use HeaderFilterRegex and ExcludeHeaderFilterRegex to control scope:
# .clang-tidy
HeaderFilterRegex: '(src|include)/.*'
# Ensures clang-tidy ignores headers from build/ and third-party/Suppressing Findings
Suppression annotations belong in the code, not in the config file, so the reason for the exception is visible at the call site.
Inline suppressions
// NOLINT — suppress ALL checks on this line
void* raw = malloc(1024); // NOLINT
// NOLINT(check-name) — suppress only the named check
void* raw = malloc(1024); // NOLINT(cppcoreguidelines-no-malloc)
// Multiple checks on one line
int x = (int)d; // NOLINT(cppcoreguidelines-pro-type-cstyle-cast, bugprone-narrowing-conversions)
// NOLINTNEXTLINE — suppress on the FOLLOWING line (keeps the flagged line clean)
// NOLINTNEXTLINE(modernize-use-nullptr)
int* p = NULL;Block suppressions
// NOLINTBEGIN / NOLINTEND — suppress for an entire block
// NOLINTBEGIN(cppcoreguidelines-owning-memory, cppcoreguidelines-no-malloc)
void* arena = malloc(ARENA_SIZE);
// ... arena allocator internals ...
free(arena);
// NOLINTEND(cppcoreguidelines-owning-memory, cppcoreguidelines-no-malloc)Suppressing CMake-generated code
Generated source files (e.g. protobuf output, Qt MOC files) should be excluded entirely rather than peppered with NOLINT. Use HeaderFilterRegex and a path filter:
# .clang-tidy — exclude generated files by path pattern
HeaderFilterRegex: '^(?!.*(generated|proto|moc_)).*\.(h|hpp)$'Alternatively, keep generated files in a dedicated directory and add a .clang-tidy there that disables all checks:
# generated/.clang-tidy
Checks: '-*'
InheritParentConfig: falsePerformance Checks in Practice
The performance-* category contains a small number of high-signal checks — each one targets a specific inefficiency that compilers cannot always optimise away.
performance-move-const-arg
Moving a const object is a no-op because std::move casts to an rvalue reference, but a const rvalue reference still binds to the copy constructor.
const std::string label = "status";
// WARNING: move has no effect — copies instead
process(std::move(label));
// Fix: either drop const so the move works, or pass by value/const-ref
process(label); // clear: passes by const-ref via overload
// or
std::string mutable_label = "status";
process(std::move(mutable_label)); // genuine moveperformance-unnecessary-copy-initialization
Initialising a local variable from a function that already returns by value can force an extra copy when const auto& or move semantics would suffice.
// WARNING: getName() returns std::string by value;
// this copy-initialises 'name' unnecessarily
std::string name = obj.getName();
// OK options:
auto name = obj.getName(); // move-constructs (NRVO/move)
const auto& name = obj.getName(); // extends lifetime, no copyperformance-avoid-endl
std::endl flushes the stream buffer on every call. For high-frequency output this can be orders of magnitude slower than '\n'.
// WARNING: flushes on every line — expensive in loops
for (auto& line : lines) {
std::cout << line << std::endl;
}
// Fix: use '\n'; flush explicitly only when needed
for (auto& line : lines) {
std::cout << line << '\n';
}
std::cout.flush(); // once, at the end if necessaryperformance-for-range-copy
Range-for loops copy each element unless the loop variable is a reference.
std::vector<BigObject> objects = loadObjects();
// WARNING: copies each BigObject on every iteration
for (auto obj : objects) {
obj.process();
}
// Fix: const reference for read-only, reference for mutation
for (const auto& obj : objects) { obj.process(); } // read-only
for (auto& obj : objects) { obj.mutate(); } // mutatingperformance-unnecessary-value-param
Passing a parameter by value when it is only read — and the type is expensive to copy — wastes an unnecessary copy at the call site.
// WARNING: std::string copied into parameter, never moved from
void setName(std::string name) {
name_ = name; // another copy here
}
// Fix: take by value and move into storage (one move total)
void setName(std::string name) {
name_ = std::move(name);
}
// Or if the function only reads: take by const reference
void printName(const std::string& name) { std::cout << name; }CI Pipeline Integration
Running clang-tidy on every pull request prevents new warnings from accumulating. The key insight: check only what changed, fail on any new finding.
Using run-clang-tidy for parallel analysis
run-clang-tidy is a Python script shipped alongside LLVM. It reads compile_commands.json, filters files, and dispatches clang-tidy processes in parallel.
# Analyse all files, 8 parallel workers
run-clang-tidy -p build/ -j8
# Restrict to project source (exclude third-party and generated)
run-clang-tidy -p build/ \
-header-filter="$(pwd)/include/.*" \
-j$(nproc)
# Treat all warnings as errors (exit non-zero if any finding)
run-clang-tidy -p build/ \
-warnings-as-errors="*" \
-j$(nproc)Exit codes for CI gating
clang-tidy exits non-zero when it finds issues (with --warnings-as-errors) or when compilation fails. Gate the CI job on this exit code:
# .github/workflows/static-analysis.yml
name: Static Analysis
on: [pull_request]
jobs:
clang-tidy:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y clang-tidy cmake ninja-build
- name: Configure (export compile_commands)
run: |
cmake -B build -G Ninja \
-DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
-DCMAKE_BUILD_TYPE=Debug
- name: Run clang-tidy
run: |
run-clang-tidy -p build/ \
-header-filter="$(pwd)/include/.*" \
-warnings-as-errors="bugprone-*,performance-*" \
-j$(nproc)
# Non-zero exit fails the job and blocks merge--header-filter patterns
By default clang-tidy only reports findings in the file explicitly named on the command line — not in headers it transitively includes. --header-filter extends reporting into matching headers:
# Report findings in your own headers but not system or third-party headers
clang-tidy src/widget.cpp \
--header-filter="$(pwd)/(src|include)/.*"
# Exclude a vendored directory
clang-tidy src/widget.cpp \
--header-filter="^(?!.*/vendor/).*"clang-tidy in CMake
CMake has first-class support for clang-tidy via the CXX_CLANG_TIDY target property — when set, CMake runs clang-tidy on each source file immediately after compiling it.
Project-wide via CMAKE_CXX_CLANG_TIDY
# CMakeLists.txt — enable for all targets in this directory and below
find_program(CLANG_TIDY_EXE
NAMES clang-tidy clang-tidy-18 clang-tidy-17
DOC "Path to clang-tidy executable"
)
if(CLANG_TIDY_EXE)
set(CMAKE_CXX_CLANG_TIDY
"${CLANG_TIDY_EXE}"
"--warnings-as-errors=bugprone-*"
"--header-filter=${CMAKE_SOURCE_DIR}/(src|include)/.*"
)
message(STATUS "clang-tidy enabled: ${CLANG_TIDY_EXE}")
endif()Per-target via set_target_properties
Per-target configuration lets you apply stricter checks to new code while leaving legacy targets untouched.
# Enable clang-tidy only for the main library, not for vendored code
find_program(CLANG_TIDY_EXE NAMES clang-tidy clang-tidy-18 clang-tidy-17)
if(CLANG_TIDY_EXE)
set_target_properties(mylib PROPERTIES
CXX_CLANG_TIDY
"${CLANG_TIDY_EXE};-checks=bugprone-*,modernize-*,performance-*;--warnings-as-errors=*"
)
endif()
# Legacy target: no clang-tidy
add_library(legacy_vendor STATIC vendor/old_lib.cpp)
# CXX_CLANG_TIDY not set — clang-tidy skipped for this targetOpt-in via CMake option
Give developers a build option to toggle clang-tidy so that fast iteration builds stay snappy while CI always runs analysis:
option(ENABLE_CLANG_TIDY "Run clang-tidy during build" OFF)
if(ENABLE_CLANG_TIDY)
find_program(CLANG_TIDY_EXE NAMES clang-tidy clang-tidy-18)
if(NOT CLANG_TIDY_EXE)
message(FATAL_ERROR "ENABLE_CLANG_TIDY=ON but clang-tidy not found")
endif()
set(CMAKE_CXX_CLANG_TIDY "${CLANG_TIDY_EXE}")
endif()# Developer build (fast, no analysis)
cmake -B build -DENABLE_CLANG_TIDY=OFF
# CI build (full analysis)
cmake -B build -DENABLE_CLANG_TIDY=ON -DCMAKE_EXPORT_COMPILE_COMMANDS=ONSeparating analysis from the build
Running clang-tidy during cmake --build doubles build time. For large projects, keep analysis separate:
# Add a custom target instead of hooking into the build
add_custom_target(tidy
COMMAND run-clang-tidy
-p ${CMAKE_BINARY_DIR}
-header-filter="${CMAKE_SOURCE_DIR}/(src|include)/.*"
-j$$(nproc)
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
COMMENT "Running clang-tidy static analysis"
VERBATIM
)cmake --build build # fast build, no analysis
cmake --build build -t tidy # run analysis on demand