A pointer in C or C++ can be null, and dereferencing a null pointer crashes your program, or worse. The usual defense is a check before you use it:

*p = 1;     // crashes if p is null

if (p) {
  *p = 1;   // safe: p was just checked
}

Any C programmer can see the difference between those two lines, yet clang compiles both without a word.

Many newer languages catch this with two ideas working together:

  1. “Might be null” is part of the type. Using a value like that without checking it is a compile error.
  2. The compiler narrows the type as you check it. After a null check, it knows the value isn’t null for the rest of that branch.

Each language spells it a little differently:

  • Kotlin: a nullable type is T?. A null check turns it into a plain T, which Kotlin calls a smart cast.
  • TypeScript: a nullable type is T | null. A null check removes the null, which TypeScript calls narrowing.
  • Swift: a nullable type is T?. You unwrap it into a new, non-optional variable with if let.
  • Rust: references can never be null, so a reference that might be missing is an Option<&T>. You unwrap it with if let Some(...) or match.

Here’s narrowing in Kotlin:

fun greet(user: User?) {       // User? means "a User, or null"
    println(user.name)         // error: user might be null
    if (user == null) {
        return                 // the null case leaves here
    }
    println(user.name)         // ok: user is smart-cast to User
}

And the same thing in TypeScript:

function greet(user: User | null) {   // a User, or null
  console.log(user.name);             // error: 'user' is possibly 'null'
  if (user === null) {
    return;                           // the null case leaves here
  }
  console.log(user.name);             // ok: user is narrowed to User
}

In both, the same variable has a different type on different lines. What the compiler knows about user depends on where you are in the code.

I built a clang warning that brings the same idea to C and C++. It flags the first line above and stays quiet on the second.

C and C++ have no nullable type

C and C++ have neither. Here’s a typical lookup function in C:

#include <stdio.h>

struct user {
  const char *name;
};

// Returns NULL if no user has this id.
struct user *find_user(int id);

void greet(int id) {
  struct user *u = find_user(id);
  printf("hello, %s\n", u->name);
}

There’s no nullable type: a struct user * might point at a user or it might be NULL, and the type is the same either way. The only place the contract lives is the comment. And there’s no narrowing, so even if you checked u against NULL first, the compiler would learn nothing from it.

So this compiles without a peep, even with -Wall -Wextra. It passes every test, because every test uses an id that exists. Then one day a request comes in for a deleted user, find_user returns NULL, and u->name crashes the process.

Clang took a step toward fixing this in 2015, when it added three type qualifiers: _Nullable, _Nonnull, and _Null_unspecified. The push came from Swift. Apple needed Objective-C headers to say which pointers could be null, so Swift could import those as optionals. So now the comment on find_user can become part of its type:

struct user *_Nullable find_user(int id);   // might return NULL
void take(int *_Nonnull p);                 // p must not be null

The catch is what clang does with them. Turn on -Wnullable-to-nonnull-conversion and it will catch you passing a _Nullable where a _Nonnull is expected. But it only looks at types, so it gets things backwards.

First, it warns about code that’s fine:

void take(int *_Nonnull p);

void f(int *_Nullable p) {
  if (p) {
    take(p);  // warning: implicit conversion from nullable pointer
              //          to non-nullable pointer type
  }
}

The if has already ruled out null, so this call is safe. The check can’t see that. All it knows is that p was declared _Nullable.

Second, it’s silent about code that’s broken. Here’s greet again, now with the contract written into the type:

struct user *_Nullable find_user(int id);

void greet(int id) {
  struct user *u = find_user(id);
  printf("hello, %s\n", u->name);   // no warning
}

This is the actual bug, and clang has everything it needs to see it: find_user says it might return NULL, and nothing checks u. But there’s no conversion here, just a dereference, and no clang warning looks at dereferences.

In other words, clang got the nullable type and never got the narrowing.

The Clang Static Analyzer does have a checker for this, nullability.NullableDereferenced. But it’s off by default, it needs the annotation, and it runs as a separate tool that’s far too slow for every build. On libuv and the Boehm GC, clang --analyze took 9 times as long as a plain syntax check with only the core and nullability checkers enabled, and 11 times as long with its defaults. On the version with only a comment it says nothing, because it can’t see inside find_user.

A warning that follows control flow

What I wanted was an ordinary compiler warning, as cheap as -Wuninitialized, that understands null checks. So I forked clang and built one:

$ clang -fnullability-safety -fnullability-default=nonnull -c greet.c
greet.c:11:28: warning: dereference of nullable pointer 'struct user *' [-Wnullability-safety-dereference]
   11 |   printf("hello, %s\n", u->name);
      |                            ^
greet.c:11:28: note: add a null check before dereferencing, or annotate as '_Nonnull' if this pointer cannot be null

Two flags are involved. -fnullability-safety turns the analysis on. -fnullability-default=nonnull tells it to trust ordinary pointers unless it has a reason not to, and the _Nullable on find_user is a reason. (It also knows that C library functions like malloc and getenv can return NULL, no annotations needed.) If you’d rather not trust anything, -fnullability-default=nullable treats every unannotated pointer as possibly null, and catches the comment-only version of greet too.

The fix is what you’d expect:

void greet(int id) {
  struct user *u = find_user(id);
  if (!u) {
    return;
  }
  printf("hello, %s\n", u->name);   // no warning: u can't be null here
}

It also gets the backwards example from earlier right: with -fnullability-safety on, the take(p) inside the if (p) doesn’t warn.

Staying quiet on the fixed version is the hard part: the warning has to know that u->name is safe because of the check one line up. If you want to try it first, the playground runs the compiler in your browser.

How the compiler sees your function

Think about how you’d check this by hand. You’d read the function top to bottom, and when you hit the if (!u) check you’d make a mental note: “past this line, u isn’t null.” When you reach u->name, you’d check your note.

The compiler does the same thing, but it needs your code in a form it can reason about. Clang gets there in a few steps, and all of them already exist in stock clang.

First, it builds a tree. The parser turns your source text into an abstract syntax tree (AST), where each node is one piece of the language: a function, an if, a variable reference. In the AST, u->name is a MemberExpr node sitting on top of a reference to u. Dereferences come in three shapes: p->field, *p, and p[i]. Those are the nodes the warning cares about.

Next, it figures out what everything means. Clang’s semantic analysis, called Sema, links each u back to the variable it refers to and attaches types to every node. That includes the nullability qualifiers, so the checker can ask any pointer “were you declared _Nullable?”

Then, it maps out the paths. A tree tells you how code is nested, but not the order it runs in. For that, clang builds a control-flow graph (CFG). Each box in the graph is a run of statements that always execute together, and each arrow is a possible jump from one box to the next. An if becomes a box with two arrows coming out of it: one for when the condition is true, one for when it’s false.

That graph is exactly what we need. In the fixed greet, the if (!u) box has two exits. One is only taken when u is null, and it leads to return. The other is only taken when u is not null, and it’s the only way to reach u->name.

Carrying notes along the paths

Now we can do what you did by hand. Walk the graph from the top, carry a set of notes about each pointer, and update the notes as you go. This technique is called dataflow analysis. It’s the same idea behind clang’s uninitialized-variable warning, just with different notes.

Our notes are simple: a set of pointers that have been narrowed, meaning proven non-null on this path. Here’s the walk for greet:

entry narrowed: { } if (!u) u is null narrowed: { } u is non-null narrowed: { u } return; printf(..., u->name); no dereference on this path u is narrowed: no warning delete the if and u is never narrowed, so u->name warns

At the top, nothing has been proven yet. When the walk reaches if (!u), it makes two copies of the notes, one for each exit. On the exit that’s only taken when u is non-null, u goes into the narrowed set. When the walk gets to u->name, it checks the notes, finds u, and stays quiet.

Delete the if, and there’s no point where u gets narrowed. The walk reaches u->name and falls back on what it knows about u itself: it came from find_user, which is declared _Nullable, so the analysis warns.

When paths meet again

Branches split the notes. The other half of the job is combining them when two paths come back together:

struct node { int value; };
void use(int);

void f(struct node *_Nullable n, struct node *_Nullable p, int cond) {
  if (cond) {
    if (!n || !p) {   // this path proves n and p
      return;
    }
  } else {
    if (!n) {         // this path proves only n
      return;
    }
  }
  use(n->value);      // ok: n was checked on both paths
  use(p->value);      // warning: p was checked on only one
}

After the if/else, we can’t know which path we came from. So the analysis keeps only what both paths agree on. (In dataflow terms, this merge is called the join.) n was narrowed on both paths, so it stays narrowed. p was only narrowed on one, so it’s dropped.

Loops work the same way, with one wrinkle. A loop’s back edge brings new notes around to the top of the loop, which can change what’s true inside it. So the analysis keeps revisiting blocks until the notes stop changing. For narrowing, that settles quickly.

What that looks like in the code

All of this lives in clang/lib/Analysis/NullabilitySafety.cpp. The notes are a struct called NullState. At its heart it’s two sets of variables:

struct NullState {
  llvm::DenseSet<const VarDecl *> NarrowedVars;  // proven non-null on this path
  llvm::DenseSet<const VarDecl *> NullableVars;  // known to possibly be null
  // ...plus the same for struct members (this->ptr, o.inner.ptr),
  // bool flags that hold a null check (bool ok = p != nullptr),
  // and local copies (q = p, so checking q also checks p)
};

The walk itself reuses ForwardDataflowWorklist, the same machinery that drives -Wuninitialized. Each statement in a block goes through a visitor with one method per kind of node, such as VisitMemberExpr for u->name, VisitUnaryOperator for *p, or VisitArraySubscriptExpr for p[i]. At the end of each block, narrowOnTerminator looks at the branch condition, works out which pointer it tests and which exit proves it non-null, and narrows it on that exit only.

When a dereference isn’t covered, the finding goes to a handler instead of straight to clang’s diagnostic engine. That’s why the same analysis can show up as a compiler warning or as a squiggle in your editor through clangd. Sema kicks it off once it finishes parsing each function, in the same place it runs the uninitialized-variable and thread-safety analyses.

One more detail: the analysis doesn’t treat functions in arbitrary order. It walks the call graph callees-first, so if a helper always returns a non-null pointer, callers of that helper already know it.

Using it on real code

-fnullability-safety turns the analysis on. It doesn’t check every function right away, though. A function is checked when at least one of these is true:

  • it has a _Nullable or _Nonnull on a parameter or return type (annotating just the header declaration is enough)
  • it’s inside a #pragma clang assume_nonnull region
  • you’ve set -fnullability-default to nonnull or nullable, which opts in every function in the file

So you can adopt it one function at a time. The bigger question is what to assume about all the pointers nobody has annotated. That’s what -fnullability-default controls:

-fnullability-default=an unannotated T * is…so you get warnings for…
unspecified (the default)not judgedonly functions you’ve annotated or put in a pragma region
nonnulltrustedpointers that come from a real source of null: a function declared _Nullable, a NULL initializer, malloc, fopen, getenv, a reset() smart pointer
nullablesuspectevery dereference that isn’t guarded by a check

nonnull is the practical way to start. Every warning points at a place where a null value really does come from somewhere, so each one is either a real bug or a false positive worth a look. nullable is much stricter, and it’s more useful for auditing a piece of code than for leaving on.

Beyond if (p) and early returns, the analysis follows most of the ways people actually write null checks:

  • p != nullptr, && and || chains, !(p && q), ternaries, and __builtin_assume(p)
  • a check saved in a flag (bool ok = p != nullptr; if (ok) ...)
  • a check on a copy (q = p; if (q) *p;) and checks on struct members at any depth
  • unique_ptr, shared_ptr, and weak_ptr: make_unique gives you non-null, and reset() or std::move makes it possibly null again
  • C library functions that return null on failure, like malloc, fopen, and strchr, are treated as _Nullable whatever the system headers say

When it’s unsure, it leans toward staying quiet. For example, calling a function doesn’t erase what’s been proven, even though that function could in theory change the pointer. Warning there would bury real bugs under noise.

The five checks (dereference, pointer arithmetic, return, assignment, and argument) are grouped under -Wnullability-safety, so -Werror=nullability-safety turns them all into errors.

From warnings to annotations

Warnings in unannotated code only go so far. The analysis is most useful once pointers carry annotations, and nobody is going to hand-annotate a million-line codebase.

So the analysis can write the annotations for you. As each file compiles, it records what it learned about each function’s parameters, return values, and struct members. A whole-program pass then combines those records across every file. If every value that ever flows into a pointer is provably non-null, the pointer gets _Nonnull. If anything anywhere tests it for null, that’s a sign someone expected null, and it doesn’t. A source rewriting tool then inserts the inferred _Nonnull annotations and lists the likely _Nullable ones for a person to review.

This is built on clang’s Scalable Static Analysis Framework (SSAF), which handles the record-per-file, combine-later plumbing.

Does it work?

Unit tests only cover the cases someone thought to write. To see how it behaves on code nobody wrote for it, I built a small harness (clang/utils/nullability-safety/corpus.py) that runs the analysis over real projects: libuv, the Boehm garbage collector (bdwgc), and LLVM’s own Support library.

It measures two things. The first is noise: how many warnings show up, and whether a change to the analysis adds new ones. The second is missed bugs, which is harder to measure because you don’t know where the bugs are. The trick is to make them. The harness finds real null checks followed by a dereference, deletes one check at a time, and asks whether the analysis now warns. Every deleted check is a bug we know about.

Across 215 files (one libuv file wouldn’t build on my Mac), with each default mode:

nonnullnullable
warnings on the unmodified code135,140
planted bugs caught (null checks deleted one at a time)2 of 3736 of 37

It’s also cheap. On the same libuv and bdwgc files, the analysis added 4% to a plain syntax check in nonnull mode and 20% in nullable mode. The Static Analyzer took 9 to 11 times as long. On clang’s own ExprConstant.cpp, a 22,000-line C++ file, a full compile with the analysis on took 10.6 seconds, and the Static Analyzer with all checkers took over 7 minutes.

The two columns are a trade. nullable finds almost every planted bug, but 5,140 warnings is far too many to turn on in existing code. nonnull is quiet enough to adopt, but it trusts ordinary pointers, so deleting a check on one leaves nothing for it to object to. It catches a bug only when the pointer comes from a real source of null. That’s why nonnull is the mode to start with, and why the annotations in the previous section matter: each _Nullable you add from the inference’s review list lets nonnull mode check that pointer as strictly as nullable mode would, without the noise from every other pointer.

The mutation results come with a caveat: 37 is a small sample, and it only tests the plain “check, then dereference” shape. It shows the core idea works on real code. Harder cases, like aliasing or flow across functions, need their own tests.

Try it