João Faria
← Index

How pylint reads code it never runs

September 1, 2026 · 3 min read

pylint never imports your code, but it will still tell you that response.jsn doesn't exist on whatever fetch() returns. The part doing the heavy lifting is astroid, the AST library underneath, and its source is where I ended up the first time a no-member false positive made no sense to me.

astroid wraps the stdlib ast module with two things it lacks: scope information and inference. Inference is the interesting one. Any node can be asked for its possible values:

import astroid

node = astroid.extract_node("""
def answer():
    return 21 * 2

x = answer()
x  #@
""")
print(node.inferred())
# [<Const.int l.2 at 0x...>]  (the int 42)

The #@ marks which node extract_node returns. Nothing ran here. astroid resolved x to its assignment, inferred the call by looking at the function's return statements, and folded 21 * 2 because both operands are constants.

There's no grand algorithm behind this, just per-node rules that call each other. A Name resolves through enclosing scopes to whatever binds it. A Call infers its callee, then the callee's return statements. An Attribute infers the object on the left and walks the MRO of whatever comes back — the same lookup order the interpreter would use, except against class ASTs instead of live objects. That last rule basically is the no-member check.

Since nothing executes, ambiguity is normal. A name assigned in both branches of an if infers to both values, so .infer() can yield several results. And when a rule runs out of road (an argument that could be anything, getattr with a computed string) it yields Uninferable, and everything downstream is poisoned by it.

That one value explains most confusing pylint behavior: on Uninferable, pylint stays quiet. The project consistently picks false negatives over false positives. So when pylint misses something that looks obvious, it's usually not the checker being dumb — some link in the inference chain came back Uninferable and it withdrew rather than guess. pylint even has a safe_infer() helper that returns nothing when two inference paths disagree about the type.

Then there's code too dynamic for any of this. namedtuple("Point", "x y") builds a class out of strings at runtime; statically, there is no class there. astroid deals with the known cases through "brain" plugins, transforms that rewrite the tree before checkers see it. The namedtuple brain reads the literal arguments, renders an actual class definition from a template string, parses it, and grafts the result in. Downstream nobody knows the difference. The brain directory covers dataclasses, enums, six, chunks of numpy — skimming it is a good way to calibrate what static analysis genuinely can't see.

The flip side follows directly: pass namedtuple a computed field list and the brain can't do anything, inference degrades to Uninferable, and now you get false positives on every attribute access. That's the actual purpose of generated-members and ignored-classes in pylintrc. They're escape hatches for code that outruns inference, not settings to copy from somebody's gist.

Docs: astroid's inference intro and the no-member message page, which lists the known false-positive scenarios.