norboten · journal

Where Python looks for a module, and who gets there first

python, linux-basics · 30 minutes · 1224 words

AttributeError: module 'calendar' has no attribute 'monthrange' is a strange thing to read. The standard library’s calendar module has had monthrange since the 1990s, so the sentence can only mean one thing: the calendar that was imported is not the standard library’s. Somebody added a helper called calendar.py next to the program, and from then on every import calendar in that program found the helper.

The rule behind it is short — a script’s own directory is the first place Python looks — and the consequence is one every Python programmer meets once: a file you named after a standard library module takes its place, for your program and for everything your program imports.

What you should be able to do after this

The mechanism

sys.path, and what is at the front

When Python imports a module that is not built in, it walks sys.path in order and takes the first match. The list is built at start-up:

  1. position 0 — a directory that depends on how Python was started (below);
  2. the directories in PYTHONPATH;
  3. the standard library, and the site-packages directories added by the site module.

Position 0 is the one that surprises people:

Started as sys.path[0]
python3 /opt/digest/digest.py /opt/digest — the script’s directory, not the caller’s
python3 -c '…' '', the current directory
python3 -m pkg.mod '', the current directory
python3 (REPL) '', the current directory

So a script always searches beside itself first, whatever directory it was started from — which is why changing the working directory, in a shell or with WorkingDirectory= in a unit, changes nothing here. (python3 -P and PYTHONSAFEPATH=1 remove that entry; -I isolates further.)

The standard library does not win

There is no precedence for the standard library: it is simply later in sys.path. Pure-Python modules such as calendar, json, logging, queue, random, select, socket, types and email are all shadowable. Built-in modules compiled into the interpreter (sys, builtins, and whatever sys.builtin_module_names lists) are not, because they never reach the path search.

The damage is not limited to your own code. Everything that runs in the process — the standard library itself, a library you installed — gets your file when it imports that name. A logging.py in an application directory can break code that has nothing to do with it.

Modern Python helps: since 3.11 the error message says so outright.

AttributeError: module 'calendar' has no attribute 'monthrange' (consider renaming
'/opt/digest/calendar.py' since it has the same name as the standard library module named
'calendar' and prevents importing that standard library module)

When the message is less kind — an ImportError from deep inside a library — module.__file__ is the answer: it says exactly which file was imported.

Names to avoid, and how to stop guessing

sys.stdlib_module_names is the whole list, in the interpreter you are using (297 names in Python 3.14 on this machine). A one-line check is enough to keep a directory clean:

$ python3 -c 'import sys, pathlib; print([p.name for p in pathlib.Path(".").glob("*.py") if p.stem in sys.stdlib_module_names])'

Three ways to avoid the collision for good:

__pycache__ outlives the file

Python caches compiled modules in __pycache__/<name>.cpython-<version>.pyc. The cache is validated against the source file it came from, so a .pyc alone does not shadow anything in a normal layout — but a stale __pycache__ is confusing to read during debugging, and removing the source without removing the directory leaves evidence that looks like the bug. Clearing it after a rename costs nothing.

A failure, walked through

Run on the lab’s own machine: ubuntu-26.04-devops, Python 3.14.4, before any change.

$ systemctl status digest.service --no-pager -n 6 | head -10
× digest.service - One line about today's events, for the morning mail
     Loaded: loaded (/etc/systemd/system/digest.service; enabled; preset: enabled)
     Active: failed (Result: exit-code) since Wed 2026-09-16 17:08:13 UTC; 167ms ago
    Process: 1175 ExecStart=/usr/bin/python3 /opt/digest/digest.py (code=exited, status=1/FAILURE)
$ sudo journalctl -u digest.service -b --no-pager -o cat | tail -6
    days = calendar.monthrange(today.year, today.month)[1]
           ^^^^^^^^^^^^^^^^^^^
AttributeError: module 'calendar' has no attribute 'monthrange' (consider renaming '/opt/digest/calendar.py' since it has the same name as the standard library module named 'calendar' and prevents importing that standard library module)

The directory says the rest:

$ ls /opt/digest
__pycache__
calendar.py
digest.py

The working directory makes no difference, because the script’s own directory is what counts:

$ cd / && python3 /opt/digest/digest.py 2>&1 | tail -3; echo "exit status: ${PIPESTATUS[0]}"
    days = calendar.monthrange(today.year, today.month)[1]
           ^^^^^^^^^^^^^^^^^^^
AttributeError: module 'calendar' has no attribute 'monthrange' (consider renaming '/opt/digest/calendar.py' since it has the same name as the standard library module named 'calendar' and prevents importing that standard library module)

__file__ confirms which file was imported, and the cache shows the helper has been compiled:

$ cd /opt/digest && python3 -c 'import sys; print(sys.path[0]); import calendar; print(calendar.__file__)'

/opt/digest/calendar.py
$ ls /opt/digest/__pycache__ 2>&1
calendar.cpython-314.pyc

(The empty first line is sys.path[0] for python3 -c: the current directory, written as ''.) The list of names not to use is in the interpreter:

$ python3 -c 'import sys; print(len(sys.stdlib_module_names)); print(sorted(n for n in sys.stdlib_module_names if len(n)<7)[:12])'
297
['_abc', '_ast', '_bz2', '_csv', '_dbm', '_gdbm', '_heapq', '_hmac', '_imp', '_io', '_json', '_lzma']

After the fix — the helper renamed to office_calendar.py, imported under that name, and the cache cleared — the standard library is itself again and the service runs:

$ cd /opt/digest && python3 -c 'import calendar, json; print(calendar.__file__); print(calendar.monthrange(2026, 9))'
/usr/lib/python3.14/calendar.py
(calendar.TUESDAY, 30)
$ systemctl show digest.service -p Result -p ExecMainStatus; cat /var/lib/digest/today.txt
Result=success
ExecMainStatus=0
2026-09-16: 3 events, 30 days in the month, 22 open

Common wrong turns

Cheat sheet

python3 -c 'import sys; print(sys.path)'                     # the search path, in order
python3 -c 'import calendar; print(calendar.__file__)'       # which file an import used
python3 -c 'import sys, pathlib; print([p.name for p in pathlib.Path(".").glob("*.py")
    if p.stem in sys.stdlib_module_names])'                  # names that will shadow
python3 -P script.py            # do not put the script's directory on the path
find . -name __pycache__ -prune -exec rm -rf {} +            # after a rename
Started as sys.path[0]
python3 /path/script.py /path — the script’s directory
python3 -c · python3 -m · the REPL the current directory

Going deeper

Review

  1. What is sys.path[0] when you run python3 /opt/digest/digest.py from /?

    /opt/digest — the script’s own directory. For python3 -c and python3 -m it is the current directory instead.

  2. Why does a file named calendar.py beside a script break calendar.monthrange?

    That directory is searched before the standard library, so import calendar finds the local file, and it has no monthrange.

  3. Which modules cannot be shadowed this way?

    The ones built into the interpreter (sys.builtin_module_names), because they are never looked up on sys.path.

  4. How do you find out which file an import actually used?

    Print the module’s __file__ — or read the hint modern Python adds to the error message.

  5. Name two ways of keeping a helper without renaming every call site.

    Put it in a package and import it as package.calendar, or install the project as a distribution and import it under its own namespace.

  6. Does WorkingDirectory=/opt/digest in the unit make the shadowing better or worse?

    Neither: for a script the path entry is the script’s directory, so the working directory is irrelevant here. It only adds a false explanation to the unit.