Static Analysis in C++: Enforce Quality with Clang-Tidy and Cppcheck

Why Static Analysis?

Tests verify behavior for inputs you thought of. Static analysis verifies structural properties of the code itself — without executing it. It catches bugs that exist on rarely-executed code paths, in unusual configurations, or in code that was never tested at all.

Common bugs caught before runtime:

  • Use-after-move: accessing a moved-from object on the happy path looks fine, but calling it twice crashes
  • Null pointer dereference: pointer checked on one branch but dereferenced on another
  • Uninitialized variable: reads whatever was on the stack, so the value can differ between debug and optimized builds and the bug appears only in one of them
  • Range-for copy: for (auto item : container) copies every element when const auto& was intended
  • Resource leak: allocated but never freed on an error path

Finding these at CI time instead of in production is a significant reliability improvement. Static analysis is not a replacement for runtime tools, though. Sanitizers such as AddressSanitizer and ThreadSanitizer only report what actually executes, but when they report, it is a real bug. Static analyzers see every path, including paths that can never happen at runtime, so they trade some false positives for coverage. Running both is how the two blind spots cover each other.


The Two Tools and Why Both

Clang-Tidy is LLVM’s linter. It plugs into the Clang frontend and uses the same AST the compiler builds. This gives it deep understanding of C++ semantics — it can reason about move semantics, template instantiations, and type conversions. It has 400+ checks across categories: bugprone-*, modernize-*, performance-*, readability-*, cppcoreguidelines-*.

Cppcheck is a standalone analyzer that does its own parsing. It does not need a compilation database and runs on projects without Clang in the toolchain. It excels at flow-sensitive analysis: detecting null dereferences, out-of-bounds access, and integer overflow.

They catch different things. Running both increases coverage. The practical difference is in how they fail: clang-tidy is only as good as its compilation database, and a file it cannot compile produces errors instead of findings. Cppcheck always produces something, but when it cannot see a macro or a header it guesses, which is where most of its false positives come from. Clang-tidy is also the slower of the two, because it runs a full Clang parse of every translation unit, including all headers; on a large project, a full clang-tidy run can take longer than the build itself.


Setting Up Clang-Tidy

Generate compile_commands.json

Clang-Tidy needs to know your build flags (include paths, preprocessor definitions, language standard) to analyze each file correctly. CMake generates this with one flag:

cmake -B build -S . \
    -DCMAKE_EXPORT_COMPILE_COMMANDS=ON \
    -DCMAKE_BUILD_TYPE=Debug

The result is build/compile_commands.json. Some projects symlink it to the project root so editors find it automatically:

ln -sf build/compile_commands.json compile_commands.json

For non-CMake builds, bear can generate it by intercepting compiler invocations:

# Debian/Ubuntu
sudo apt-get install bear
bear -- make -j$(nproc)

The .clang-tidy Configuration File

Place .clang-tidy at the project root. Clang-Tidy searches upward from each file’s directory to find it:

# .clang-tidy
Checks: >
  bugprone-*,
  -bugprone-easily-swappable-parameters,
  modernize-use-nullptr,
  modernize-use-override,
  modernize-avoid-bind,
  performance-for-range-copy,
  performance-unnecessary-copy-initialization,
  readability-identifier-naming,
  readability-avoid-const-params-in-decls

# Treat these checks as errors (block CI)
WarningsAsErrors: 'bugprone-*,performance-for-range-copy'

# Report warnings from your own headers only (LLVM regex: no lookahead support)
HeaderFilterRegex: '.*/(src|include)/.*'
# clang-tidy 19+: explicitly exclude vendored code
ExcludeHeaderFilterRegex: '.*/third_party/.*'

CheckOptions:
  - key:   readability-identifier-naming.ClassCase
    value: CamelCase
  - key:   readability-identifier-naming.FunctionCase
    value: camelCase
  - key:   readability-identifier-naming.MemberCase
    value: lower_case
  - key:   readability-identifier-naming.MemberSuffix
    value: '_'

A few details in this file are worth understanding rather than copying. The Checks list is applied in order, so bugprone-* followed by -bugprone-easily-swappable-parameters enables the whole group and then removes one noisy check; putting the negation first would have no effect. bugprone-easily-swappable-parameters is disabled because it flags nearly every function with two adjacent parameters of the same type, which on existing code is mostly noise.

HeaderFilterRegex controls which headers diagnostics are reported from; source files named on the command line are always reported. By default no header warnings are shown at all, which surprises people who expect clang-tidy to check their .hpp files. The regex is an LLVM (POSIX-style) regular expression, so Perl features such as lookahead (?!...) are not supported; a pattern like '^(?!.*third_party).*' is rejected or never matches, depending on the version. Match your own directories positively instead, and on clang-tidy 19 or newer use ExcludeHeaderFilterRegex for vendored code.

Newer clang-tidy versions also accept CheckOptions as a plain mapping (readability-identifier-naming.ClassCase: CamelCase); the list-of-key/value form above still works and is what older versions require.

Running Clang-Tidy

# Check a single file
clang-tidy -p build src/main.cpp

# Check all files in src/
clang-tidy -p build src/*.cpp

# Parallel (faster on multi-core); the script ships with clang-tidy
# (on Debian/Ubuntu it may be named run-clang-tidy-<version>)
run-clang-tidy -p build -j$(nproc)

# Auto-fix: apply safe fixes (review the diff!)
clang-tidy -p build --fix src/*.cpp
git diff    # review what was changed

Be careful with --fix on several files at once. When two source files include the same header, both runs can try to rewrite it, and the fixes are applied on top of each other, which occasionally produces duplicated override keywords or broken code. run-clang-tidy -fix avoids this by collecting all fixes first and deduplicating them before applying them with clang-apply-replacements. Either way, commit before running an auto-fix, so git diff shows exactly what the tool did and git checkout . undoes it.

What Each Check Category Finds

// bugprone-use-after-move
std::string s = "hello";
auto s2 = std::move(s);
std::cout << s.size() << '\n';  // WARNING: use-after-move — s is in valid but unspecified state

// performance-for-range-copy
std::vector<std::string> names = {"Alice", "Bob", "Charlie"};
for (auto name : names)           // WARNING: copies each string
    std::cout << name << '\n';
// Fix:
for (const auto& name : names)    // no copy
    std::cout << name << '\n';

// modernize-use-nullptr
int* ptr = NULL;                  // WARNING: use nullptr
int* ptr2 = nullptr;              // OK

// modernize-use-override
class Base {
    virtual void foo() {}
};
class Derived : public Base {
    virtual void foo() {}         // WARNING: use override
    // Fixed: void foo() override {}
};

// readability-identifier-naming (if configured)
class my_class { };               // WARNING: should be MyClass (CamelCase)

bugprone-use-after-move is the check that has paid for itself most often in my experience, precisely because the bug does not crash. A moved-from std::string is usually just empty, so code that reads it after std::move prints an empty field or sends an empty request, and tests with a single call pass. The check follows control flow within a function, so it also catches the loop version: std::move(buffer) inside a loop body, where the second iteration uses the already moved-from buffer. It cannot see across function boundaries, so a helper that takes std::string&& and a caller that keeps using the argument afterwards will not be flagged.


Setting Up Cppcheck

Cppcheck works without a compilation database and is simpler to run:

# Basic: check src/ directory with all checks enabled
cppcheck \
    --enable=all \
    --suppress=missingIncludeSystem \
    --error-exitcode=1 \
    -I include/ \
    -j $(nproc) \
    src/

# More targeted: specific enable categories
cppcheck \
    --enable=warning,performance,portability \
    --suppress=missingIncludeSystem \
    --error-exitcode=1 \
    src/

Enable categories:

  • warning — suspicious code that might be a bug
  • style — code style violations (unused variables, etc.)
  • performance — inefficient code patterns
  • portability — code that may not work on all platforms
  • information — informational messages
  • unusedFunction — functions that are never called (needs the whole program)
  • all — all of the above

--enable=all looks like the thorough choice but is noisy in practice. unusedFunction reports every public API function that your own code happens not to call, and it only works when Cppcheck sees the whole program in one run, so Cppcheck turns it off when -j is used and says so in the output. information mostly produces missingInclude messages about headers it could not find. In CI, --enable=warning,performance,portability (as in the second command) gives far fewer, far more actionable results. Also note --error-exitcode=1: without it Cppcheck exits with 0 even when it finds errors, and the CI step always passes.

What Cppcheck Finds

// Null pointer dereference
void process(int* data, int size) {
    if (!data) {
        printf("Error\n");
        // Forgot to return!
    }
    for (int i = 0; i < size; ++i)
        data[i] *= 2;   // Cppcheck: dereference of null pointer 'data'
}

// Memory leak
void leak() {
    int* p = new int[100];
    if (someCondition())
        return;       // Cppcheck: memory leak: p
    delete[] p;
}

// Out-of-bounds access
void oob() {
    int arr[5];
    for (int i = 0; i <= 5; ++i)   // <= instead of <
        arr[i] = i;                  // Cppcheck: out of bounds access
}

// Assignment in condition (common typo)
int x = 5;
if (x = 0) {           // Cppcheck: found assignment in condition
    printf("zero\n");
}

Suppressing False Positives

// Inline suppression — suppress for the next line
// cppcheck-suppress nullPointer
data->field = value;

// Suppress with comment justification (better)
// cppcheck-suppress useInitializationList  // not applicable in this case
MyClass::MyClass() { }

Or a suppressions file for project-wide rules:

# suppressions.txt
missingIncludeSystem
# Suppress specific check in a specific file
uninitvar:src/legacy/old_code.cpp
cppcheck --suppressions-list=suppressions.txt src/

CI Integration with GitHub Actions

# .github/workflows/static-analysis.yml
name: Static Analysis

on:
  pull_request:
    paths:
      - 'src/**'
      - 'include/**'
      - '.clang-tidy'

jobs:
  clang-tidy:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # needed for git diff against origin/main

      - name: Install clang-tidy
        run: sudo apt-get install -y clang-tidy cmake

      - name: Configure CMake
        run: cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=Debug

      - name: Run clang-tidy (changed files only)
        run: |
          # Only check files changed in this PR — much faster than full scan
          CHANGED=$(git diff --name-only origin/main...HEAD | grep '\.cpp$' || true)
          if [ -z "$CHANGED" ]; then
            echo "No C++ files changed"
            exit 0
          fi
          clang-tidy -p build --warnings-as-errors='bugprone-*' $CHANGED

  cppcheck:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4

      - name: Install Cppcheck
        run: sudo apt-get install -y cppcheck

      - name: Run Cppcheck
        run: |
          cppcheck \
            --enable=warning,performance \
            --suppress=missingIncludeSystem \
            --error-exitcode=1 \
            -I include/ \
            -j 2 \
            src/

Analyzing Only Changed Files

Running the full analysis on a large codebase can take minutes. For PRs, analyze only the changed files:

# Get files changed vs main branch
CHANGED_CPP=$(git diff --name-only origin/main...HEAD | grep '\.cpp$')

if [ -n "$CHANGED_CPP" ]; then
    clang-tidy -p build $CHANGED_CPP
fi

This reduces CI time considerably for typical PRs, but it has two traps. The first is the checkout: actions/checkout fetches a single commit by default, so origin/main does not exist in the runner and the step fails with fatal: ambiguous argument 'origin/main...HEAD': unknown revision or path not in the working tree. The fetch-depth: 0 line in the workflow above is what fixes it. The second is coverage: a PR that only changes a header is not analyzed at all, because the filter only keeps .cpp files, even though the header change can introduce warnings in every file that includes it. A reasonable compromise is changed-files analysis on pull requests plus a scheduled or post-merge full run on main.

The CMake configure step also has to succeed on the runner, which means installing the same dependencies the real build needs. If find_package fails, there is no compile_commands.json, and clang-tidy falls back to guessing flags and reports 'foo.h' file not found for every file.


Adopting Incrementally in Legacy Codebases

Turning on all checks at once on a legacy codebase generates thousands of warnings. This is overwhelming and makes CI fail immediately. The incremental approach:

Week 1: Enable bugprone-* only. Fix the real bugs (these are high-value). Suppress false positives with NOLINT(bugprone-easily-swappable-parameters).

Week 2-3: Add performance-for-range-copy and performance-unnecessary-copy-initialization. These are easy wins — the fix is usually adding const&.

Month 2: Add modernize-use-nullptr, modernize-use-override. Use --fix to apply automatically and review the diff.

Month 3+: Add readability-identifier-naming if your team agrees on naming conventions.

# Start with just the most impactful checks
clang-tidy -p build \
    -checks='-*,bugprone-*,performance-for-range-copy' \
    src/*.cpp

Excluding Third-Party Code

Never run static analysis on third-party headers or vendored code — the warnings are not yours to fix and they overwhelm real signal:

# In .clang-tidy
HeaderFilterRegex: '.*/(src|include)/.*'
ExcludeHeaderFilterRegex: '.*/(third_party|vendor|external)/.*'   # clang-tidy 19+

Adding third-party include directories with CMake’s SYSTEM keyword (target_include_directories(app SYSTEM PRIVATE third_party/include)) is a second line of defense: headers found through -isystem are treated as system headers, so neither the compiler nor clang-tidy reports warnings from them.

# For Cppcheck: exclude directories
cppcheck --suppress=missingIncludeSystem \
    -i third_party/ \
    -i vendor/ \
    src/

Editor Integration

Both tools integrate with major C++ editors:

VS Code (with C/C++ Extension):

  • clangd language server reads .clang-tidy and shows warnings inline as you type
  • Install: clangd extension + clangd binary (sudo apt-get install clangd)

CLion:

  • Built-in clang-tidy support reads your .clang-tidy file
  • Settings → Editor → Inspections → C/C++ → clang-tidy

Neovim (with LSP):

-- Using clangd as language server
require('lspconfig').clangd.setup({
    cmd = {'clangd', '--clang-tidy'},
})

With editor integration, developers see warnings while writing code — not just in CI. This catches issues earlier and makes the CI gate feel less surprising.


Rolling out clang-tidy and Cppcheck

  • Clang-Tidy uses the Clang AST — deep C++ understanding, 400+ checks, auto-fix for many — requires compile_commands.json
  • Cppcheck does its own parsing — no compilation database needed, strong on flow-sensitive bugs (null deref, OOB, leaks)
  • compile_commands.json: generate with cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON; symlink to project root for editor support
  • .clang-tidy: place at project root; use HeaderFilterRegex to exclude third-party headers
  • CI: check only changed files on PRs for fast feedback; full scan on main branch merges
  • Incremental adoption: start with bugprone-*, fix real bugs, then add performance-* and modernize-*
  • Suppress sparingly: // NOLINT(check-name) or // cppcheck-suppress for genuine false positives — document why
  • Editor integration: clangd with .clang-tidy shows warnings as you type — the fastest feedback loop

Frequently Asked Questions (FAQ)

Q. clang-tidy reports “file not found” for my own headers or “unknown argument” errors. What is wrong?

A. clang-tidy parses each file with the exact flags recorded in compile_commands.json; without that database it guesses, so include paths and defines are missing and headers are not found. Generate the database with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON and pass -p build, and regenerate it after adding files or targets. Because clang-tidy uses the Clang frontend, GCC-only flags in the database can also show up as unknown arguments, which you can strip or override with --extra-arg in the tidy invocation.