Tooling[tools]

clang-tidy: run it, configure it, pick the checks

Section
Tooling [tools]
Standard
C++17
Compilers
MSVC 19.44, Clang 19.1 (MSVC STL)
Published
Length
16 min read
In this answer9 clauses
  1. 1Run clang-tidy on one file
  2. 2Run it on a project: compile_commands.json and run-clang-tidy
  3. 3The .clang-tidy file
  4. 4The clang-tidy checks to turn on first
  5. 5Let clang-tidy fix the code: –fix
  6. 6Silence one finding with NOLINT
  7. 7clang-tidy vs clang-format and the Clang Static Analyzer
  8. 8What changed from clang-tidy 19 to 23
  9. 9A starter setup for CI

Abstract

clang-tidy is LLVM’s linter for C++: it parses each file with Clang from its compile command and runs the checks you enable, many with fixes. Run it on one file as clang-tidy reader.cpp -- -std=c++17, with the checks named in --checks or in a .clang-tidy file, or on a project as run-clang-tidy -p build, which reads compile_commands.json. Commit a .clang-tidy that starts with bugprone-* and performance-*, check it with --verify-config, and use one release locally and in CI: 22 changed the default checks and header filtering.

A wide sheet of code drawn as nine lines of different lengths and indents, with a margin column on the left; three lines have a solid wedge in the margin, two black and one blue.
Figure 1 — A linter reads the code you already have and marks the lines worth a second look.

clang-tidy is LLVM’s linter for C++: it parses each file with Clang, from the compile command you give it, and runs the checks you choose to find bugs, outdated constructs and style problems; many checks offer a fix it can write into the file. Run it on one file with clang-tidy reader.cpp --checks='-*,bugprone-*,performance-*' -- -std=c++17, and on a project with -p build, which reads the build’s compile_commands.json. Keep the checks in a .clang-tidy file at the repository’s root, start with bugprone-* and performance-*, and add groups one at a time.

The samples are C++17 (one is C++20), built with MSVC 19.44 and Clang 19.1 on Microsoft’s standard library. Every clang-tidy message here was printed by clang-tidy 19.1.5, the one Visual Studio 2022 installs; 23.1.5 (2026-11-03) is current, and what changed from 20 to 23 comes from LLVM’s release notes. The checks suggest C++11’s nullptr and override and C++20’s contains(): Since When has the first compiler release for each.

Run clang-tidy on one file

Three mistakes in the sensor-log reader: 0 returned as a null pointer, a copy of each reading in a range-for, and a vector read after it was moved from:

[Example 1:
const Reading* find(const std::vector<Reading>& batch,
                    std::string_view sensor) {
    for (const Reading& r : batch)
        if (r.sensor == sensor)
            return &r;
    return 0;
}

int main() {
    std::vector<Reading> batch{{"t1", 21.5}, {"t2", 19.0}};
    for (auto r : batch)
        std::printf("%s %.1f\n", r.sensor.c_str(), r.value);
    keep(std::move(batch));
    std::printf("t1 %s\n", find(batch, "t1") ? "found" : "missing");
}

— end example]

Saved as 01-first-run.cpp, it was checked with:

[Example 2:
clang-tidy 01-first-run.cpp \
    --checks='-*,bugprone-*,performance-*,modernize-use-nullptr' \
    -- -std=c++17 -Wall -Wextra

— end example]

The quotes keep the shell from expanding the * (zsh stops with no matches found). clang-tidy 19.1.5 reported four findings. Two of them:

[Example 3:
01-first-run.cpp:25:12: warning: use nullptr [modernize-use-nullptr]
   25 |     return 0;
      |            ^
      |            nullptr
01-first-run.cpp:33:33: warning: 'batch' used after it was moved [bugprone-use-after-move]
   33 |     std::printf("t1 %s\n", find(batch, "t1") ? "found" : "missing");
      |                                 ^
01-first-run.cpp:32:10: note: move occurred here
   32 |     keep(std::move(batch));
      |          ^

— end example]

Each finding reads the same way:

  • file:line:column: warning:, the message, then the check’s name in brackets: the name a .clang-tidy file or a NOLINT comment uses.
  • A line under the caret (nullptr) is the check’s replacement. --fix writes it into the file.
  • A note: points at a second place that explains the first: here, where the move happened.

The other two were performance-for-range-copy on the loop’s copy and bugprone-exception-escape on main (an exception may be thrown in function 'main' which should not throw exceptions). The vector built in main can throw: that is what a whole group brings, findings you did not ask for by name.

Everything after -- is the compile command: the standard, include paths (-I), defines. Without -std=c++17, the Visual Studio Clang 19.1 compiles as C++14, and clang-tidy 19.1.5 printed error: no type named 'string_view' in namespace 'std' [clang-diagnostic-error], then three of the four findings. The use after move, in a call to the function that failed, was gone; the exit code was 1.

With no --checks and no .clang-tidy file, 19.1.5 runs 125 checks, all clang-analyzer-*; from 22 the default is clang-diagnostic-* alone, the compiler’s own warnings. Name the checks you want.

Run it on a project: compile_commands.json and run-clang-tidy

On a project, clang-tidy takes each file’s compile command from compile_commands.json, the compilation database the build writes, and -p names the folder that holds it. CMake writes it from 3.5, for the Makefile and Ninja generators only.

reader.cpp enters a box, Clang parses the file, fed by compile_commands.json, then a box, checks, fed by .clang-tidy, giving warnings. A dotted arrow, fixes (–fix), leads back to reader.cpp.
Figure 2 — clang-tidy parses your file with Clang, using the compile command from your build's compile_commands.json, then runs the checks you chose over the result; with --fix, it also applies the fixes that some checks offer.
[Example 4:
# configure; writes build/compile_commands.json
cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# one file, its command read from the database
clang-tidy -p build src/reader.cpp
# every file in the database, in parallel
run-clang-tidy -p build
# only the lines changed since the previous commit
git diff -U0 --no-color HEAD^ | clang-tidy-diff.py -p1

— end example]

With -p, no -- is needed; a -- after -p build replaces the database’s command (a bare -- brought the string_view error back). With neither, 19.1.5 searched only the source’s folder and its parents for compile_commands.json, printed Running without flags. and parsed the file with none.

Way Runs What to know
run-clang-tidy every file in the database, in parallel (-j jobs) LLVM’s Python script, in bin since 13 (once run-clang-tidy.py); needs clang-tidy and clang-apply-replacements on the PATH; -clang-tidy-binary names another clang-tidy
run-clang-tidy on Windows python "…\Llvm\x64\bin\run-clang-tidy" -p build the script has no extension, so name Python
CMAKE_CXX_CLANG_TIDY beside the compiler, on every build CMake 3.6 and later; Makefile and Ninja generators; without -checks= in it, the .clang-tidy file decides
clang-tidy-diff.py on the lines a diff changed LLVM’s script; still analyzes whole files; not in Visual Studio’s LLVM folder
Visual Studio in Code Analysis, after a successful build since Visual Studio 2019 16.4; adds no checks of its own (its Clang-Tidy Checks property is passed as --checks); reads .clang-tidy

To keep third-party and generated sources out: run-clang-tidy’s trailing arguments are regexes on file paths, and -source-filter (19) is a regex of the sources to report on. For headers, ExcludeHeaderFilterRegex (19) hides findings that HeaderFilterRegex would show: on 19.1.5, ExcludeHeaderFilterRegex: 'third_party' beside HeaderFilterRegex: '.*' kept the finding in src/own.h and hid the one in third_party/vendor.h.

The .clang-tidy file

clang-tidy reads the .clang-tidy file in the closest parent directory of each source file, and options on the command line win over it. This starter was checked with clang-tidy 19.1.5, and --verify-config printed No config errors detected.:

[Example 5:
Checks:
  - -*
  - bugprone-*
  - -bugprone-easily-swappable-parameters
  - performance-*
  - clang-analyzer-*
  - modernize-use-override
  - modernize-use-nullptr
  - modernize-use-emplace
  - modernize-loop-convert
  - readability-container-contains
WarningsAsErrors: 'bugprone-use-after-move,bugprone-dangling-handle'
HeaderFilterRegex: '.*'
CheckOptions:
  performance-for-range-copy.WarnOnAllAutoCopies: true

— end example]

  • Checks applies its globs in order: -* turns everything off, each line after it turns a group or a check on, and a leading - turns one off again, here the noisy check for neighboring parameters a caller could swap. The list form needs 17 or later; before that, write one string.
  • WarningsAsErrors makes those findings errors and the exit code 1; it enables nothing, and it is one string because its list form needs 22. On the reader: error: 'batch' used after it was moved [bugprone-use-after-move,-warnings-as-errors].
  • HeaderFilterRegex: '.*' reports findings in your own headers, which 19.1.5 hides by default (Suppressed 1 warnings (1 in non-user code).). On Windows the paths have backslashes (src\reading.h): '^src/' matched nothing, --header-filter='src[/\\]' matched. On 19.1.5 the filter in the file took effect only when clang-tidy ran from this file’s folder or below it, a bug LLVM 21’s notes list as fixed.
  • CheckOptions sets one check’s options. This one makes performance-for-range-copy flag every auto loop variable of a type that is costly to copy, even one the loop modifies.

--list-checks printed the 232 checks in force (125 clang-analyzer-*, 83 bugprone-*, 19 performance-*, the five named); --dump-config printed the merged configuration, every option included.

Run --verify-config after each edit: a misspelled check or option is silent in a normal run. It caught unknown check 'modernize-use-emplaec'; did you mean 'modernize-use-emplace' and a misspelled option, but not a misspelled check in WarningsAsErrors.

A .clang-tidy in a subfolder replaces the root’s file: src/.clang-tidy holding Checks: -bugprone-exception-escape left the defaults minus that check. With InheritParentConfig: true added, its list was appended to the root’s.

The clang-tidy checks to turn on first

Start with bugprone-* and performance-*. Findings per group on a 49-line file of the reader:

Group For, in the project’s words Findings on 49 lines
bugprone-* “bug-prone code constructs” 1
performance-* “performance-related issues” 1
clang-analyzer-* “Clang Static Analyzer checks” 0
cppcoreguidelines-* “C++ Core Guidelines” 3
misc-* “Checks that we didn’t have a better category for” 5, three misc-use-internal-linkage
modernize-* “modern language constructs”; the docs still say “C++11”, but 19.1.5’s group reaches C++23’s std::print 6, four modernize-use-trailing-return-type
readability-* “readability-related issues”, of no particular coding style 13, six readability-braces-around-statements

Of the 557 checks in 19.1.5 (--checks=*), the ones to know first; Fix says whether 19.1.5 printed a replacement:

Check Finds Fix
bugprone-use-after-move an object read after std::move no
bugprone-dangling-handle a std::string_view of a temporary no
bugprone-unchecked-optional-access an optional read with no check no
bugprone-narrowing-conversions a double added into an int no
bugprone-exception-escape an exception that can leave main, a destructor, a move or a noexcept function no
performance-for-range-copy a loop variable copied, then only read yes
performance-unnecessary-copy-initialization a const local copied from a reference yes
performance-unnecessary-value-param a parameter copied, then only read yes
performance-noexcept-move-constructor move operations without noexcept yes
modernize-use-override virtual where override belongs yes
modernize-use-nullptr 0 or NULL as a null pointer yes
modernize-use-emplace a push_back of std::make_pair(…) yes
modernize-loop-convert an index loop over a container yes
readability-container-contains count() as a membership test yes
misc-const-correctness locals that could be const yes

Clang-Tidy Checks lists them all. Add misc-const-correctness last: it reported 3 locals in 12 ordinary lines.

bugprone-dangling-handle caught a std::string_view bound to the std::string that label() returns by value:

[Example 6:
04b-dangling-handle.cpp:18:29: warning: std::basic_string_view outlives its value [bugprone-dangling-handle]
   18 |     std::string_view name = label(r);  // the string dies here
      |                             ^

— end example]

From 20 it treats std::span as a handle too; the std::span answer shows a span that dangles.

bugprone-unchecked-optional-access follows the tests the code makes before reading an optional:

[Example 7:
double arrow(const std::vector<Reading>& batch) {
    std::optional<Reading> r = latest(batch);
    return r->value;          // no check
}

double star(const std::vector<Reading>& batch) {
    std::optional<Reading> r = latest(batch);
    return (*r).value;        // no check
}

double value(const std::vector<Reading>& batch) {
    std::optional<Reading> r = latest(batch);
    return r.value().value;   // no check (throws when empty)
}

— end example]

On Microsoft’s library, clang-tidy 19.1.5 flagged r->value and r.value() (unchecked access to optional value) and missed (*r).value, so a clean run there does not prove every *o is checked; the std::optional answer has the reason.

readability-container-contains wants C++20’s contains() for a membership test:

[Example 8:
04h-contains.cpp:11:23: warning: use 'contains' to check for membership [readability-container-contains]
   11 |     return last_value.count(sensor) > 0;
      |                       ^~~~~         ~~~
      |                       contains

— end example]

On 19.1.5, on Microsoft’s library, it flagged count() only, not last_value.find(sensor) != last_value.end(); LLVM 20’s notes list a fix for “false negatives in the find() case”. std::map covers contains().

On 19.1.5, modernize-use-emplace flagged peaks.push_back(std::make_pair("t1", 21.5)) and left batch.push_back(Reading{"t1", 21.5}) alone, in C++17, where emplace_back cannot build an aggregate, and in C++20, where it can.

In 19.1.5, bugprone-narrowing-conversions is an alias of cppcoreguidelines-narrowing-conversions; with both on, the finding carried both names.

performance-noexcept-move-constructor said move constructors should be marked noexcept, and the same of move assignment: when a move constructor should be noexcept has why std::vector cares.

Keep the compilers’ warnings on too: MSVC’s /W4 warned about the narrowing (C4244) where Clang’s -Wall -Wextra did not, and Clang about the dangling view (-Wdangling-gsl, on by default) where /W4 did not. Inside clang-tidy they are the clang-diagnostic-* checks: with -* first, the -Wall -Wextra after -- printed nothing, and clang-diagnostic-* in Checks brings them back. Clang vs GCC compares the compilers’ warnings.

Let clang-tidy fix the code: –fix

--fix writes the replacements into the file; --fix-errors does so even when the file has compile errors, and --fix-notes also applies fixes attached to notes. Run it on a clean working tree and read the diff. This file had four findings, from modernize-use-override, modernize-use-nullptr (two) and modernize-loop-convert:

[Example 9:
class PrintSink : public Sink {
public:
    virtual void write(const Reading& r) {
        std::printf("%s %.1f\n", r.sensor.c_str(), r.value);
    }
};

void flush(std::vector<Reading> batch, Sink* sink = 0) {
    if (sink == 0)
        return;
    for (std::size_t i = 0; i < batch.size(); ++i)
        sink->write(batch[i]);
}

— end example]

clang-tidy 19.1.5 said clang-tidy applied 6 of 6 suggested fixes. (the override and loop fixes are two replacements each) and wrote:

[Example 10:
class PrintSink : public Sink {
public:
    void write(const Reading& r) override {
        std::printf("%s %.1f\n", r.sensor.c_str(), r.value);
    }
};

void flush(std::vector<Reading> batch, Sink* sink = nullptr) {
    if (sink == nullptr)
        return;
    for (const auto & i : batch)
        sink->write(i);
}

— end example]

override in place of virtual is what C++ virtual functions recommends. Two things to do after every --fix:

  • Run it again. With batch[i] gone (its non-const operator[] had hidden that batch is only read), performance-unnecessary-value-param reported that batch is copied but only used as a const reference. A second --fix made it const std::vector<Reading>& batch; the next run found nothing.
  • Format it. The loop came out as for (const auto & i : batch): the element keeps the index’s name, i, and & has a space on both sides. --format-style=file (or FormatStyle in the file) passes the fixes through clang-format, as run-clang-tidy -format does; these runs did not.

For a project, run-clang-tidy -p build -fix applies the fixes. To read them first, run-clang-tidy -p build -export-fixes fixes writes one YAML file per source into the existing folder fixes, and clang-apply-replacements fixes applies them.

Silence one finding with NOLINT

// NOLINT(check) silences a check on its line, // NOLINTNEXTLINE(check) on the next line, and NOLINTBEGIN(check) and NOLINTEND(check) around a block (since 14). Five null pointers written as 0, four of them silenced:

[Example 11:
const char* unit = 0;  // NOLINT(modernize-use-nullptr)

// NOLINTNEXTLINE(modernize-use-nullptr)
const char* label = 0;

// NOLINTBEGIN(modernize-use-nullptr)
const char* first_sensor = 0;
const char* last_sensor = 0;
// NOLINTEND(modernize-use-nullptr)

const char* log_path = 0;  // NOLINT(bugprone-use-after-move)

— end example]

With --checks='-*,modernize-use-nullptr', clang-tidy 19.1.5 reported only the last line, whose NOLINT names another check:

[Example 12:
06-nolint.cpp:16:24: warning: use nullptr [modernize-use-nullptr]
   16 | const char* log_path = 0;  // NOLINT(bugprone-use-after-move)
      |                        ^
      |                        nullptr

— end example]

The check list in parentheses is optional; fill it in, so the comment silences that check and no other. A NOLINTBEGIN with no NOLINTEND silenced nothing and added an error: unmatched 'NOLINTBEGIN' comment without a subsequent 'NOLINTEND' comment [clang-tidy-nolint].

clang-tidy vs clang-format and the Clang Static Analyzer

Tool Does Changes your code Run as
clang-tidy parses the code with Clang and runs checks over it with --fix clang-tidy, run-clang-tidy, CMake, Visual Studio
clang-format lays out code without reading what it means yes: layout and #include order; some options (InsertBraces) edit tokens clang-format
Clang Static Analyzer follows paths through the code to find bugs no scan-build before the build command, clang --analyze, or clang-tidy’s clang-analyzer-*

The analyzer is path-sensitive. Through clang-tidy 19.1.5 (--checks='-*,clang-analyzer-*'), it found the null dereference in this function, on the path where no reading matches:

[Example 13:
double value_of(const std::vector<Reading>& batch,
                std::string_view sensor) {
    const Reading* found = nullptr;
    for (const Reading& r : batch)
        if (r.sensor == sensor)
            found = &r;
    return found->value;  // null when no reading matches
}

— end example]

It reported Access to field 'value' results in a dereference of a null pointer (loaded from variable 'found') [clang-analyzer-core.NullDereference], then traced the path:

[Example 14:
07-analyzer.cpp:26:27: note: Calling 'value_of'
   26 |     std::printf("%.1f\n", value_of(batch, "t9"));
      |                           ^~~~~~~~~~~~~~~~~~~~~
07-analyzer.cpp:16:5: note: 'found' initialized to a null pointer value
   16 |     const Reading* found = nullptr;
      |     ^~~~~~~~~~~~~~~~~~~~

— end example]

When the null came back from another function, a find() returning nullptr, it reported nothing. That is a heuristic, not a limit of the analysis: by default the analyzer drops a path whose null is a called function’s return (suppress-null-return-paths). With -Xclang -analyzer-config -Xclang suppress-null-return-paths=false after --, clang-tidy 19.1.5 reported it and traced the path into find() (note: Returning null pointer). Toolchain lists clang-tidy with the other analyzers.

What changed from clang-tidy 19 to 23

From LLVM’s release notes, most of it in the 22.1.0 notes, the changes that alter what a .clang-tidy file does:

Release Change For your file
19 ExcludeHeaderFilterRegex and --exclude-header-filter hide some headers; filters reports only
20 bugprone-narrowing-conversions becomes the check, the cppcoreguidelines- name its alias the bugprone- name works in both
21 a HeaderFilterRegex set in .clang-tidy takes effect wherever clang-tidy runs on 19.1.5, run it from the folder of the .clang-tidy
22 clang-analyzer-* removed from the default checks, now clang-diagnostic-* alone list it in Checks
22 findings in all non-system headers are shown by default (the filter’s default became .*) an empty -header-filter turns it off
22 WarningsAsErrors takes the list form one string for older releases
22 RemovedArgs and --removed-arg take flags out of the compile command drop a flag Clang rejects
22 global StrictMode and IgnoreMacros removed, deprecated since 20 set them per check in CheckOptions
23 hicpp-* removed; each of its checks now lives in another group (modernize-, readability-, cppcoreguidelines-, bugprone-, performance-, misc-, portability-) rename those entries from the 23.1.0 notes’ table
23 no early exit when the only checks enabled are clang-diagnostic-* a run on 22’s default goes ahead
23 misc-const-correctness covers auto and lambdas expect more findings

A starter setup for CI

Commit the .clang-tidy above at the repository’s root, have the build write compile_commands.json, and run this in CI:

[Example 15:
run-clang-tidy -p build -quiet -warnings-as-errors='*'

— end example]

With clang-tidy 19.1.5 it turned every finding into error: … [check,-warnings-as-errors] and exited with 1. -quiet passes clang-tidy’s --quiet, which leaves out the statistics about ignored warnings; run-clang-tidy still prints each command.

  • One group at a time: fix or switch off what it finds before the next.
  • A check name in every NOLINT.
  • --fix on a clean working tree, then a second run, then the formatter.
  • The same clang-tidy release locally and in CI, checked with clang-tidy --version (Visual Studio’s, in VC\Tools\Llvm\x64\bin, says LLVM version 19.1.5): 22 changed the default checks and the header filter.
  • Every finding an error in CI only; in the file, only the checks that are always bugs.

Tooling covers the compilers and build setups.

Questions

What is clang-tidy?

clang-tidy is the LLVM project’s clang-based C++ linter, built in its own words for “diagnosing and fixing typical programming errors, like style violations, interface misuse, or bugs that can be deduced via static analysis”. It parses each file with Clang, from the file’s compile command, and runs the checks you enable over the result; release 19.1.5 has 557. Many checks also offer a replacement that clang-tidy writes into the file with --fix.

What is the difference between clang-tidy and clang-format?

clang-format formats code: it is a standalone formatter, built on LibFormat, for C, C++, Java, JavaScript, JSON, Objective-C, Protobuf and C#. clang-tidy parses C++ the way the compiler does and reports bugs, outdated constructs and style problems, many with a fix. Use both: the fixes clang-tidy 19.1.5 wrote did not follow the file’s layout (a loop came out as for (const auto & i : batch)), so format after a --fix.

Is clang-tidy a static analyzer?

It is a linter that can also run the Clang Static Analyzer’s checks, named clang-analyzer-*. The analyzer follows paths through the code: through clang-tidy 19.1.5 it found a pointer that is null on one path of a function and dereferenced. Its checks are on by default in clang-tidy up to 21 and off from 22, so list clang-analyzer-* in Checks to keep them; the analyzer also runs alone, through scan-build or clang --analyze.

How do I run clang-tidy on a whole project?

Have the build write compile_commands.json (in CMake, turn on CMAKE_EXPORT_COMPILE_COMMANDS, which the Makefile and Ninja generators honor), then run run-clang-tidy -p build, LLVM’s script that runs clang-tidy on every file in that database in parallel. In CI, add -warnings-as-errors with the glob * in quotes: with clang-tidy 19.1.5 it turned every finding into an error and the run exited with 1.

How do I disable a clang-tidy check?

In .clang-tidy, add the check’s name with a leading minus to Checks, after the glob that turned it on, since the globs apply in order: bugprone-*, then -bugprone-easily-swappable-parameters. For one line, write // NOLINT(check-name) on it or // NOLINTNEXTLINE(check-name) above it; with clang-tidy 19.1.5, a NOLINT that named a different check silenced nothing.

Does clang-tidy work with GCC or MSVC projects?

It parses every file with Clang, so the compile commands must be ones Clang accepts: given GCC’s -fanalyzer after --, clang-tidy 19.1.5 printed error: unknown argument ‘-fanalyzer’; did you mean ‘-Xanalyzer’? [clang-diagnostic-error] before the file’s findings, and from clang-tidy 22 --removed-arg takes a flag out of the command. Visual Studio has run clang-tidy as part of Code Analysis since Visual Studio 2019 16.4, for MSVC and clang-cl builds; it adds no checks of its own and reads .clang-tidy.

Read next

  1. Clang vs GCC: what differs for a C++ projectTooling · Tobias Mwangi · written for C++23
  2. C++ string to int: std::stoi or std::from_chars, not atoiStandard Library · Miriam Castellanos · written for C++17
  3. C++ generic lambdas, template lambdas and function objectsTemplates · Miriam Castellanos · written for C++20