← back to all posts

How a Python type checker decides a variable is unused

x = 1 is unused only if no read of x resolves to that assignment. In Python, that read may sit inside a nested function or comprehension. A later assignment can also determine which x the read refers to.

I ran into these cases while adding unused-variable dimming to ty, Astral's Python type checker written in Rust.

When ty finds a local binding with no reads, its language server tells the editor to dim its name. Here, a binding is one place where a name receives a value. Assignments, parameters, and loop variables all create bindings.

VS Code showing an unused welcome_message binding dimmed by ty
An unused binding dimmed by ty in VS Code.

How ty tells the editor what to dim

With the ty extension installed, the editor and ty talk over the Language Server Protocol (LSP). For every unused binding ty finds, it publishes a diagnostic. Oversimplified, the payload looks like this:

{
  "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 1 } },
  "severity": 4,
  "source": "ty",
  "message": "`x` is unused",
  "tags": [1]
}

tags: [1] is DiagnosticTag.Unnecessary, and severity: 4 is a hint, the quietest level the protocol has. The range covers just the binding, so the editor can act on x without touching the rest of the line. The tag is advisory, so each editor decides how to render it. VS Code normally dims the tagged range, with the exact appearance controlled by the active theme.

Python binds names in more places than =

My first version walked the file's abstract syntax tree (AST), ty's structured representation of the parsed Python code, in source order. Whenever it reached syntax that could introduce a local name, it looked that name up in ty's semantic model and asked whether the symbol was used in its scope. It worked on the examples I'd written.

Finding those candidates means knowing what counts as a binding in the first place, and Python has more answers than =:

def scale(factor): ...            # parameters bind

for row in rows: ...              # so do loop variables

squares = [n * n for n in rows]   # n, in the comprehension's own scope

with open(path) as f: ...         # as f binds f

try: ...
except ValueError as e: ...       # as e binds e

if (count := len(rows)) > 3: ...  # walrus, mid-expression

match event:
    case {"type": kind}: ...      # match patterns capture names

Each of those needed its own branch in my visitor. Somewhere around the match patterns it stopped feeling like a helper and started feeling like a second inventory of Python's binding syntax, and it made me uneasy before I'd even shipped it. The visitor still leaned on ty for scope and usage. Finding every definition worth checking was the duplicated part.

The merged version iterates over definitions ty has already recorded and filters them by kind, instead of walking the AST again to find every possible binding. Imports, functions, and classes are left out.

Not every definition creates a runtime binding. A bare annotation like x: int only declares a type, so ty dims it only when the name is neither bound nor read elsewhere in the scope.

The semantic index already had the data

@carljm suggested recording usage where ty already builds its use-to-definition map. For each read, that map already knows which definitions can provide its value.

x = 1
print(x)

Here the read of x resolves to the definition x = 1. If different control-flow paths assigned x, the read could resolve to several definitions. That's the data "is this used" needs, at a finer grain than I was asking. My visitor asked whether a symbol was used somewhere in its scope. The map answers per definition.

I added a parallel boolean table indexed by ty's existing definition IDs. Every entry starts as false. When use-def records a read, each definition that can provide the value gets marked as used.

The unused-binding collector now starts from the definitions use-def never marked and filters by scope and definition kind. It no longer decides for itself whether a definition has a read. The separate AST traversal went away, and the result derives from the same name-resolution data type inference consumes.

A read can cross several scopes

A name can be read outside the scope that binds it. Closures are the simplest case.

def outer():
    x = 1

    def inner():
        return x

    return inner

When ty encounters x inside inner, it has to determine which x that name refers to. Here it refers to x = 1 in outer, so ty has to mark that outer binding as used.

nonlocal is a forwarding declaration. It tells Python that x in mid doesn't get a local binding, assignments there reach into an enclosing function.

def outer():
    x = 1

    def mid():
        nonlocal x
        x = 2

        def inner():
            return x

        return inner

    return mid

nonlocal x creates no binding in mid, so both the x = 2 assignment and the read inside inner resolve to the binding outer owns.

global sends assignments to the module scope instead of creating a local binding. Because this feature reports only local bindings, ty leaves those assignments alone.

Comprehensions add one more scope, and a comprehension can sit inside a function that sits inside another function.

def outer(i: int):
    def inner():
        return [[k for k in range(i)] for _ in range(2)]

    return inner

The read of i lives in the comprehension's own scope, two boundaries from the parameter in outer. ty walks it through both before the parameter counts as used.

The owning scope isn't always known at the moment of the read.

def outer():
    x = 0

    def middle():
        def inner():
            return x

        x = 1
        return inner

    return middle

Python determines a function's local names from its whole body. The later x = 1 makes x local to middle, so inner captures middle's x and outer's x = 0 really is unused.

So ty resolves captures late. As each scope completes, unresolved reads propagate to the parent, and the first completed scope that owns the name claims them and marks its binding as used.

Unused doesn't mean removable

Some parameters are unused by every rule above, and flagging them would only annoy people. The feature skips the conventional placeholders:

from typing import overload


@overload
def scale(value: int) -> int: ...    # @overload declaration, parameters skipped


class Worker:
    def handle(self, event):         # self skipped by convention
        ...                          # body left unimplemented, event skipped too

    def poll(self):
        _ = self.checkpoint()        # underscore-prefixed names are never flagged

In stub files every body is a placeholder, so parameters there are never flagged.

An override can leave a parameter unread and still need it to stay compatible with the base method's signature.

class Base:
    def handle(self, event):
        print(event)


class Child(Base):
    def handle(self, event):   # event is dimmed here
        return 0

event has no read inside Child.handle, so ty dims it. Deleting it would break the override. I initially suppressed unused parameters in methods that override a base-class signature. @carljm pointed out it only covered one direction. A base method can leave a parameter unused while an override depends on it. Covering both directions would require knowing whether subclasses may override the method, or limiting the suppression to final methods and classes. I removed the suppression, so ty now dims an override parameter when the method body never reads it. Pylance does the same. As @MichaReiser put it, a false positive here does little harm. The hint says no read resolves to this binding. Whether the binding can go is a different question, and the hint doesn't answer it.

Where the analysis stops

The feature reports bindings inside functions, lambdas, and comprehensions. Their reads occur in the same scope or in nested scopes that capture them, all of which ty can inspect while analyzing the file.

Module and class names are harder to classify. Another file can import or re-export a module-level name, while class attributes can be accessed elsewhere through attribute lookup.

Extending the hint to those bindings would require project-wide reference analysis. That meant more implementation work and a higher risk of false positives, so I kept the first version local.

Related changes