Skip to content
C++
Analysis & Formatting
Updated 2025-01-01T00:00:00.000Z

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.

bash
clang-tidy src/main.cpp -- -std=c++20 -I include/
# With compile_commands.json (preferred):
clang-tidy src/main.cpp

Setup

Generate compile_commands.json

bash
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:

yaml
# .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: file

Running clang-tidy

bash
# 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

cpp
// 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 identical

modernize — upgrades old C++ idioms

cpp
// 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); }  // FIXED

performance — avoids inefficiencies

cpp
// 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 effect

cppcoreguidelines — C++ Core Guidelines

cpp
// 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

cmake
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:

cmake
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

yaml
- 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

cpp
// 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)

Start with a focused set, then expand:

yaml
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.

CheckWhat it flagsFix
modernize-use-nullptrNULL or 0 used as null pointerReplace with nullptr
modernize-use-overrideVirtual overrides missing override keywordAppend override
modernize-use-emplacepush_back(T(args...)) constructing a temporaryReplace with emplace_back(args...)
modernize-loop-convertIndex-based loops over containersRewrite as range-for
modernize-use-autoExplicit type repetition on the right-hand sideReplace with auto
modernize-make-uniquestd::unique_ptr<T>(new T(...))Replace with std::make_unique<T>(...)
modernize-make-sharedstd::shared_ptr<T>(new T(...))Replace with std::make_shared<T>(...)
modernize-use-default-member-initInitialising members in constructor body when a default sufficesMove init to member declaration
modernize-return-braced-init-listreturn T{a, b} written as return T(a, b)Use braced-init-list
modernize-use-nodiscardFunctions returning a value that is never [[nodiscard]]Add attribute
cpp
// 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_unique

bugprone-* — 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.

CheckWhat it flags
bugprone-use-after-moveUsing a variable after it has been std::moved
bugprone-integer-divisionInteger division assigned to a floating-point variable
bugprone-narrowing-conversionsSilent lossy integer/float conversions
bugprone-branch-cloneif/else branches with identical bodies
bugprone-signed-char-misuseUsing char (possibly signed) to hold binary data
bugprone-string-constructorstd::string(ptr, 0) and similar easy misuses
bugprone-suspicious-memset-usagememset(p, val, 0) — size and value args swapped
bugprone-dangling-handlestd::string_view or similar bound to a temporary
bugprone-infinite-loopLoop with no reachable exit condition
bugprone-unchecked-optional-accessDereferencing std::optional without checking has_value()
cpp
// 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() first

cppcoreguidelines-* — 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:

cpp
// 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-punning

performance-* — 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:

cpp
// 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 > 0

Auto-Fix with --fix

clang-tidy can rewrite your source files directly — many checks are paired with transformations that mechanically apply the correct modern idiom.

bash
# 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.cpp

Always 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.

bash
# 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 drop

Not every check ships with a fix. The table below summarises fix availability for common checks:

CheckAuto-fix?
modernize-use-nullptrYes
modernize-use-overrideYes
modernize-make-uniqueYes
modernize-loop-convertYes
bugprone-use-after-moveNo — fix requires understanding intent
bugprone-integer-divisionNo
performance-for-range-copyYes
readability-container-size-emptyYes
cppcoreguidelines-pro-type-cstyle-castYes (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.

cpp
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 checks

Use InheritParentConfig: true to layer configs rather than repeat the base settings in every subdirectory file:

yaml
# 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
yaml
# 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-numbers

To 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:

yaml
# .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

cpp
// 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

cpp
// 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:

yaml
# .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:

yaml
# generated/.clang-tidy
Checks: '-*'
InheritParentConfig: false

Performance 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.

cpp
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 move

performance-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.

cpp
// 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 copy

performance-avoid-endl

std::endl flushes the stream buffer on every call. For high-frequency output this can be orders of magnitude slower than '\n'.

cpp
// 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 necessary

performance-for-range-copy

Range-for loops copy each element unless the loop variable is a reference.

cpp
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(); }   // mutating

performance-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.

cpp
// 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.

bash
# 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:

yaml
# .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:

bash
# 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

cmake
# 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.

cmake
# 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 target

Opt-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:

cmake
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()
bash
# 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=ON

Separating analysis from the build

Running clang-tidy during cmake --build doubles build time. For large projects, keep analysis separate:

cmake
# 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
)
bash
cmake --build build          # fast build, no analysis
cmake --build build -t tidy  # run analysis on demand
Edit on GitHubUpdated 2025-01-01T00:00:00.000Z