A Python import is a filesystem lookup dressed up as a language feature. When you write import os.path, the interpreter walks a list of directories called sys.path, opens the first match, executes it once, and binds the resulting module object to the name os. That is the whole idea behind Python's import path system, and once you can see the path, the infamous ModuleNotFoundError stops being mysterious.
What actually happens during an import statement?
Take import json. Python does five things in a fixed order. It splits the dotted name into json. It searches sys.path for a file called json.py or a package directory called json. It creates a module object to hold that module's namespace. It executes the module's top-level code inside that namespace. Finally it binds the name json in the scope where you wrote the import.
The binding is the part worth internalising: an import does not copy code into your file. It creates a second reference to a single shared module object. That is why a module can have state that every importer sees, and it is why editing a module mid-session does nothing until you reload it.
import math
import math as m
from math import sqrt, pi
from math import gcd
print(math.floor(3.7)) # 3
print(m.ceil(3.2)) # 4
print(sqrt(16)) # 4.0
print(round(pi, 4)) # 3.1416
print(gcd(12, 18)) # 6
All four spellings above reach the same module object. import math binds the module; from math import sqrt binds one name inside it; import math as m binds the module under a different name; and a dotted from package.module import name walks the package first, then picks the name out of the submodule. Pick a spelling per situation rather than per habit, and be consistent inside a project.
Where does Python look for a module?
sys.path is an ordinary list of directories, searched from left to right. The first entry is special: it is the directory of the script you ran, or an empty string in the interactive interpreter. After that come your user site directory, any directory you added with PYTHONPATH, and finally the standard library and site-packages where pip installs third-party code.
import sys
import importlib.util
print("sys.path[0]:", sys.path[0] or "(empty - interactive mode)")
print("json found:", importlib.util.find_spec("json") is not None)
print("nope found:", importlib.util.find_spec("no_such_module_xyz") is None)
import json
print("json cached:", "json" in sys.modules)
print("json file:", json.__file__)
The find_spec call is the diagnostic you want when an import fails. It searches the same path the importer uses and returns None instead of raising, which tells you immediately whether the problem is a missing file or a broken file. The last two lines also show where the module actually came from: a path inside your project means your code shadowed something; a path inside the Python installation means you are looking at a library module.
Two facts about that list explain most import surprises. First, the script's own directory comes before the standard library, so a file called random.py in your project silently replaces the standard library's random module for every script run from that directory. Second, python script.py puts the script's directory on the path while python -m package.module puts your current working directory there instead. That difference is the whole reason -m works for package code and plain script invocation does not.
Why does a module's code run only once?
Python keeps every imported module in sys.modules, keyed by name. The second time you import it, the interpreter finds the cached object and rebinds the name, so the top-level code does not run a second time. That is why module-level code is a bad place for side effects: it happens exactly once, at an unpredictable moment, and often during an import you did not expect.
The idiom that controls this is the __main__ guard. __name__ is set to "__main__" only for the file you executed directly; for an imported file it is the module's own name. Putting the demo code behind a guard means the file can be both a library and a runnable script.
# geometry.py
"""Small geometry helpers used by the demo."""
import math
def circle_area(radius: float) -> float:
"""Return the area of a circle with the given radius."""
return math.pi * radius ** 2
def hypotenuse(a: float, b: float) -> float:
"""Return the length of the hypotenuse of a right triangle."""
return math.hypot(a, b)
if __name__ == "__main__":
print("circle_area(2) =", circle_area(2))
print("hypotenuse(3, 4) =", hypotenuse(3, 4))
# runner.py - imports geometry, so its guard does not fire
from geometry import circle_area, hypotenuse
print("area of r=2:", circle_area(2))
print("hypotenuse(3, 4):", hypotenuse(3, 4))
Run python runner.py and you get two lines of output. Run python geometry.py and you get the same two values under different labels, because now the file is the program rather than a library. The important part is what does not happen: importing geometry from runner.py never prints the demo lines, because the guard is false for an imported module.
How do I fix a circular import error?
Two modules that import each other break in a specific way. The first module starts executing, reaches its import statement, and begins the second module. The second module imports the first - but the first is only half-built, so the name it is trying to grab does not exist yet.
# module_a.py
from module_b import b_function
def a_function():
return "a" + b_function()
# module_b.py
from module_a import a_function
def b_function():
return "b"
Running python module_a.py raises ImportError: cannot import name 'b_function' from 'module_b'. The wording varies slightly between Python versions - some add a hint about a partially initialised module - but the cause is always the same: a name was imported from a module that had not finished loading.
There are two clean repairs. The first is to import the module rather than the name, so the lookup happens later, at call time, when both modules are complete. The second is to move the shared piece into a third module that both of them import.
# module_a.py - repaired by importing the module, not the name
import module_b
def a_function():
return "a" + module_b.b_function()
if __name__ == "__main__":
print(a_function())
# module_b.py - repaired the same way
import module_a
def b_function():
return "b"
With those two versions in place, python module_a.py prints a+b. The deeper fix is architectural: a cycle between two modules is usually a sign that one of them is doing two jobs. Splitting the shared data into a third module removes the cycle instead of papering over it.
Which import traps bite beginners most often?
- Shadowing a standard library name. Your own
random.py,json.pyortypes.pyreplaces the library version for any script run from that folder. Rename the file; the fix is never to fiddle withsys.path. - Running a script from the wrong directory.
sys.path[0]is the script's folder, not where you typed the command, so a sibling module that "clearly exists" is invisible from another folder. - Expecting a package to be a single file.
import mypkgneeds a directory with an__init__.py, whileimport mypkg.modneedsmod.pyinside it. - Wildcard imports.
from mypkg import *hides where names come from, overwrites your own variables without warning, and breaks when the module grows a new export. - Installing into the wrong interpreter.
pip installcan target a different Python than the one running your script.python -m pip installalways targets the interpreter you are holding.
Key takeaways
- An import is a directory search followed by a one-time execution and a name binding.
sys.pathis the search list;sys.modulesis the cache of what has already been imported.- Module code runs once, at import time - put side effects behind
if __name__ == "__main__":. find_specanswers "could Python find this?" without raising an exception.- Circular imports are a design smell; import modules, not names, or factor the shared code out.
Try it yourself: Create a folder called import-lab with two files, toolbox.py (containing a shout function and a __main__ guard that prints a message) and app.py (which imports shout and calls it). Run python app.py and note that the guard's message never appears. Then run python toolbox.py and watch it appear. Next, add import sys and print(sys.path[0]) to app.py, and run the same command from the parent directory to see the path change.