Understanding How Mypy Maps Files to Module Names
One of the most confusing aspects of mypy is that it doesn’t just type-check files. Internally, mypy builds a graph of Python modules. To…
Understanding How Mypy Maps Files to Module Names
One of the most confusing aspects of mypy is that it doesn’t just type-check files. Internally, mypy builds a graph of Python modules. To do that, it must answer two questions:
- Which files should be checked?
- What is the fully-qualified module name of each file?
Many import-related errors stem from misunderstanding the second question.
The Two Phases
Mypy operates in roughly two phases:
Phase 1: Discover source files
For example:
mypy -m glue.script.ml.split
or:
mypy -p glue.script
or:
mypy glue/script/ml/split.py
Depending on the command, mypy discovers one or more source files.
Verbose mode reveals this step:
LOG: Found source:
BuildSource(path='...', module='...')
For example:
LOG: Found source:
BuildSource(
path='/workspace/dmp/glue/script/ml/split.py',
module='glue.script.ml.split'
)
Phase 2: Resolve imports
After loading source files, mypy follows imports:
import common
or:
from glue.script import common
This phase uses MYPYPATH, mypy_path, the current working directory, installed packages, and typeshed.
A common mistake is assuming MYPYPATH only affects import resolution. In practice, MYPYPATH can also affect how files are mapped to module names.
Three Ways to Run Mypy
Method 1: Passing a file path
mypy glue/script/ml/split.py
Here mypy starts with a file path and must determine the module name.
This is where package discovery matters.
Method 2: Passing a module
mypy -m glue.script.ml.split
Here the module name is already known.
Mypy simply locates the file corresponding to:
glue.script.ml.split
No path-to-module conversion is required.
Method 3: Passing a package
mypy -p glue.script
Again, the package name is already known.
Mypy recursively discovers modules underneath that package.
How Mypy Converts Paths Into Module Names
Consider:
/workspace/dmp/
└── glue/
└── script/
├── common.py
└── ml/
└── split.py
Suppose:
MYPYPATH=/workspace/dmp
and:
mypy --explicit-package-bases glue/script/ml/split.py
Mypy looks for the nearest package base.
It finds:
/workspace/dmp
The relative path is:
glue/script/ml/split.py
Mypy converts that to:
glue.script.ml.split
by:
- Removing
.py - Replacing
/with. - Using the path relative to the package base
Result:
glue.script.ml.split
Why Multiple Package Bases Cause Problems
Suppose:
/workspace/dmp/
└── glue/
└── script/
├── common.py
└── ml/
└── split.py
Suppose we run:
MYPYPATH=/workspace/dmp/glue/script \
mypy \
--module glue.script.ml.split \
--module glue.script.common
and split.py contains:
import common
Mypy reports:
Source file found twice under different module names:
"glue.script.common" and "common"
Why?
How Mypy Thinks About Files
Internally, mypy does not primarily operate on files.
Instead, it builds a dependency graph of modules:
module name -> source file
For example:
glue.script.common
-> /workspace/dmp/glue/script/common.py
glue.script.ml.split
-> /workspace/dmp/glue/script/ml/split.py
This mapping is expected to be one-to-one.
A single file should have exactly one canonical module name.
Step 1: Explicitly Checked Modules
The command line specifies:
--module glue.script.ml.split
--module glue.script.common
As a result, mypy starts with:
glue.script.ml.split
glue.script.common
Verbose logging confirms this:
Found source:
BuildSource(path=None, module='glue.script.ml.split')
Found source:
BuildSource(path=None, module='glue.script.common')
At this point there is no conflict.
Mypy knows:
glue.script.common
-> common.py
and proceeds with type checking.
Step 2: Import Resolution
Next, mypy analyzes split.py.
Inside the file it encounters:
import common
This statement is important.
Python semantics are straightforward:
import common
means:
Import a module literally named "common"
It does not mean:
Import glue.script.common
Mypy therefore begins searching for a module named:
common
Step 3: MYPYPATH Makes the Import Succeed
The environment contains:
MYPYPATH=/workspace/dmp/glue/script
Within that directory exists:
common.py
Therefore mypy successfully resolves:
common
-> /workspace/dmp/glue/script/common.py
Step 4: The Collision
At this point mypy knows:
glue.script.common
-> /workspace/dmp/glue/script/common.py
and also:
common
-> /workspace/dmp/glue/script/common.py
The same file now appears twice in the dependency graph:
glue.script.common
-> common.py
common
-> common.py
Mypy rejects this situation and raises:
Source file found twice under different module names:
"glue.script.common" and "common"
Why Multiple Package Bases Matter
A common explanation is:
“Multiple package bases cause duplicate modules.”
This is not entirely accurate.
Multiple package bases merely make duplicate module names possible.
The actual error occurs only when all of the following conditions hold:
Condition 1
The file is already known under one module name:
glue.script.common
Condition 2
Some import statement requests a different module name:
import common
Condition 3
Search paths allow both names to resolve to the same file.
Only when all three conditions are satisfied does mypy detect a duplicate module identity.
Reading the Verbose Log
Verbose output reveals exactly what happened:
LOG: Metadata fresh for glue.script.common:
file /workspace/dmp/glue/script/common.py
Later:
LOG: Metadata fresh for common:
file /workspace/dmp/glue/script/common.py
The second line is the key clue.
It proves that mypy loaded the same file under another module name.
Once these two lines appear together, the duplicate-module error is inevitable.
Two Consistent Solutions
Option 1: Use Package Imports
Treat the project as a package:
from glue.script import common
or:
from .. import common
Use:
MYPYPATH=/workspace/dmp
or no MYPYPATH at all.
Now the file has only one identity:
glue.script.common
Option 2: Use Top-Level Imports
Keep:
import common
and use:
MYPYPATH=/workspace/dmp/glue/script
Treat glue/script as the source root.
In this model, the canonical module name becomes:
common
rather than:
glue.script.common
Key Takeaway
The error message is easy to misinterpret.
Mypy is not complaining that it cannot find a file.
In fact, it found the file successfully.
Twice.
The real problem is that mypy’s dependency graph requires a single module identity per file. When one file can be reached as both:
glue.script.common
and:
common
mypy refuses to guess which identity is correct and aborts with a duplicate-module error.
Why import common Matters
Suppose split.py contains:
import common
That statement means exactly:
common
It does not mean:
glue.script.common
Even if common.py physically lives under:
glue/script/common.py
Python and mypy will not automatically reinterpret the import.
Therefore:
import common
requires mypy to locate a module literally named:
common
If the same file is already known as:
glue.script.common
a duplicate-module error becomes likely.
A Useful Mental Model
Think of mypy as maintaining a mapping:
module name -> file
Good:
glue.script.common -> common.py
glue.script.ml.split -> split.py
Bad:
glue.script.common -> common.py
common -> common.py
The second mapping assigns two module names to the same file, which mypy rejects.
Debugging Tips
Enable verbose logging:
mypy -v ...
Look for:
Found source:
BuildSource(...)
to see how mypy initially identifies modules.
Then search for:
Metadata fresh for ...
to see every module name assigned during dependency resolution.
If the same file appears under multiple module names, you’ve found the source of a duplicate-module error.
메타데이터
- post_id
- bc72df5313c3
- slug
- understanding-how-mypy-maps-files-to-module-names-bc72df5313c3
- url
- https://medium.com/@nebakei.tkb713/understanding-how-mypy-maps-files-to-module-names-bc72df5313c3
- canonical_url
- https://medium.com/@nebakei.tkb713/understanding-how-mypy-maps-files-to-module-names-bc72df5313c3
- author_url
- https://medium.com/@nebakei.tkb713
- status
- ok
- fetched_at
- 2026-06-20 20:29:01