DEV Community

Taylor Wang
Taylor Wang

Posted on

I Trusted the IDE Import for 48 Hours. sys.path[0] Was an Empty String.

I spent forty-eight hours sure my package layout was wrong, because only one environment could import it. The editor ran the file and printed a clean, traceback-free startup, so I kept editing the wrong module. Why would a venv that used the same interpreter refuse a name the Run button accepted? That question sat in the terminal while I chased missing __init__.py files that were never missing.

This is a field-notes writeup of that loop: what I tried, what broke, and what I would repeat. You can rerun the lab without my machine, because the failure is about sys.path[0], not about a secret dependency. If you have ever said it imports fine on the laptop, you already know the smell.

Hour 0–6: I believed the green Run button

The project looked almost boring on the surface, which is how these import bugs like to dress. A script named app.py imported helper, and helper.py lived next to a package I actually cared about. I treated that layout as obvious, and obvious layouts are where I stop reading my own code.

repro_syspath/
  app.py
  helper.py
  payments/
    __init__.py
    helper.py
  dump_import_env.py
  tests/
    test_import_mode.py
Enter fullscreen mode Exit fullscreen mode

From the editor, import helper resolved, tests were not running yet, and I called that progress. Did I even check whether helper was the payments helper or the leftover root file? I skipped that check because no traceback arrived, and a missing question cannot get answered. I also trusted a virtualenv whose name and Python minor version matched the production host. It had the same minor version and a similar freeze file, but it used a different launch method. That last difference is the entire story, and I still ignored it for several hours.

Hour 6–18: I copied a sys.path.insert from a chat

When the clean shell finally failed, I did the worst reasonable thing a tired person does. I asked a coding model for an import fix, and it handed me the classic three-line patch. You have seen this patch, and you have probably pasted it into a script that already had the wrong name.

import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, os.getcwd())

import helper  # still ambiguous
Enter fullscreen mode Exit fullscreen mode

The local run went green again, which felt like skill instead of a narrower accident. On the next machine the extra entries pointed at a different working directory, so helper became whichever file happened to sit in cwd. Have you noticed how often it works in the IDE just means cwd and sys.path[0] are doing charity work? I had not fixed an import at all. I had ranked two accidental directories above the package I meant to load, then celebrated the quieter traceback.

Hour 18–36: I blamed pip, then the venv

The next wrong turn was packaging folklore, because folklore is cheaper than reading sys.path. I reinstalled the project with pip install -e ., then without -e, then from a wheel I built in a hurry. None of that mattered, because app.py never imported payments.helper in the first place. It imported a top-level name, and Python was happy to please the first match.

I printed helper.__file__ only after deleting the venv twice, which is an expensive way to read one attribute. The path was the root helper.py, not the one under payments/. How many hours would that one attribute have saved if I had printed it at hour one? More than I want to admit in a public notes file, and enough that I now keep the print in the script.

Commands I ran, in the order that wasted the afternoon:

  1. python -c "import helper; print(helper.__file__)" from the repo root
  2. cd /tmp && python /path/to/repro_syspath/app.py
  3. python -m app from the repo root, which is a different search rule
  4. python -I app.py, after I remembered isolated mode exists

Step 2 was the first honest failure I could not talk around. The script directory became sys.path[0], cwd became /tmp, and my mental model of the project root is always visible died. Step 3 then changed the first search entry again, which is why comparing one green run against another green run taught me nothing.

Hour 36–48: I dumped sys.path on a clean interpreter

I needed an interpreter that had never read my editor launch config, leftover .envrc, or injected PYTHONPATH. I ran the same dump on MonkeyCode's free server option so I could compare JSON from a machine that was not my laptop. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Free model access drafted the dump boilerplate, then recommended inserting cwd onto sys.path, which is exactly how I lost half a day.

The dump is the artifact I actually trust now. Run it before you trust any import that just works, especially one that only works under a Run button.

# dump_import_env.py
from __future__ import annotations

import json
import os
import site
import sys


def main() -> None:
    path0 = sys.path[0] if sys.path else None
    payload = {
        "executable": sys.executable,
        "cwd": os.getcwd(),
        "argv": sys.argv,
        "sys_path0": path0,
        "sys_path0_repr": repr(path0),
        "sys_path0_is_empty": path0 == "",
        "sys_path": sys.path,
        "pythonpath": os.environ.get("PYTHONPATH"),
        "user_site_enabled": site.ENABLE_USER_SITE,
        "user_site": site.getusersitepackages(),
        "sitecustomize_loaded": "sitecustomize" in sys.modules,
        "usercustomize_loaded": "usercustomize" in sys.modules,
        "isolated": bool(getattr(sys.flags, "isolated", 0)),
        "no_user_site": bool(sys.flags.no_user_site),
        "ignore_environment": bool(sys.flags.ignore_environment),
        "prefix": sys.prefix,
        "base_prefix": getattr(sys, "base_prefix", sys.prefix),
    }
    print(json.dumps(payload, indent=2))


if __name__ == "__main__":
    main()
Enter fullscreen mode Exit fullscreen mode

Run these four and save the files. Then ask a boring question: which sys.path[0] did the IDE hide from you?

python dump_import_env.py > dump_script.json
python -m dump_import_env > dump_module.json
python -I dump_import_env.py > dump_isolated.json
PYTHONPATH=/tmp/not-the-project python dump_import_env.py > dump_pythonpath.json
Enter fullscreen mode Exit fullscreen mode

On my machine, the script form stored a real directory in sys.path[0]. The -m form stored an empty string, which means search cwd first, and that is a behavioral change, not a cosmetic one. Isolated mode dropped PYTHONPATH and user site, which is how I finally saw the import graph without editor makeup. If two of those JSON files disagree, you do not have one program. You have a family of programs that share a filename.

What the empty string actually means

Python's own docs are blunt once you reread them after a bad night. sys.path[0] is the script directory when you invoke a file. If there is no script directory, because you used -m, -c, or a REPL, sys.path[0] becomes "" and cwd wins. That empty string is not a missing value. It is an instruction.

So three launches are three programs, even when the file contents never change:

  • python app.py searches beside the file, even if you launched it from /tmp.
  • python -m app searches cwd first, so your shell location becomes part of the public API.
  • an IDE Run Python File action may inject workspace roots through PYTHONPATH or a custom sys.path.

Relative imports do not save you if the top-level script is not a package. import helper is not the helper next to payments. It is the first helper on sys.path. Are you sure you wanted the first one, and not the one with the business logic? sitecustomize.py and usercustomize.py are the encore after that. If either loaded, you are not measuring a stock interpreter, and the dump script records that on purpose.

pytest can hide the same lie

I almost shipped the green editor run into CI, then remembered pytest puts the project root on sys.path. That is helpful for collecting tests, and it is hostile to proving script launch behavior. A passing import helper inside tests/ does not prove python app.py from /tmp. If your only import test lives under pytest, you may be testing the test runner.

A lab you can rerun

Here is the smallest pair of modules that made the failure obvious. I am labeling this a lab, not a production postmortem, because I did not keep timestamps from the original mess. The files are still enough to reproduce the disagreement on a clean interpreter.

# helper.py  (the trap at the repo root)
NAME = "root-helper"

# payments/helper.py
NAME = "payments-helper"

# app.py
import helper

def who() -> str:
    return helper.NAME

if __name__ == "__main__":
    print(who())
Enter fullscreen mode Exit fullscreen mode

Expected by me, for two days: payments-helper. Actual from python app.py at the repo root: root-helper. Actual from a test runner that put the project root on sys.path in another order: still root-helper, unless I deleted the trap file. Deleting the trap without renaming the import is not a fix. It is camouflage.

The test I wish I had written at hour one looks like this:

# tests/test_import_mode.py
import runpy
import subprocess
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]


def test_app_script_binds_the_root_helper() -> None:
    ns = runpy.run_path(str(ROOT / "app.py"), run_name="not_main")
    assert ns["who"]() == "root-helper"


def test_explicit_package_import_is_stable() -> None:
    from payments import helper as pkg_helper

    assert pkg_helper.NAME == "payments-helper"


def test_isolated_script_still_sees_script_dir(tmp_path: Path) -> None:
    proc = subprocess.run(
        [sys.executable, "-I", str(ROOT / "dump_import_env.py")],
        check=True,
        cwd=tmp_path,
        capture_output=True,
        text=True,
    )
    assert '"sys_path0_is_empty": false' in proc.stdout
Enter fullscreen mode Exit fullscreen mode

That third test is the one I would keep in CI after the embarrassment faded. It launches with -I and a foreign cwd, so editor state cannot vote on the result. If you only ever collect tests from the IDE, you will not see this class of bug. You will see a green pyramid that still depends on sys.path[0].

Decision table I now keep in the repo

Launch sys.path[0] Cwd matters? Use for
python app.py script directory not for the first entry one-off scripts
python -m app empty string / cwd yes package entry points
IDE Run File whatever launch.json injected usually yes never as proof
python -I app.py script directory, no env path not for PYTHONPATH reproduction
pip install + import payments site-packages no the thing you meant

If the table row is IDE Run File, I no longer accept the result as evidence. Can a green button be a flaky test? In this case it was, and it flaked in the direction that made me feel competent. I would rather have a red isolated run than a green editor run that cannot be named.

What I would repeat

I would print module.__file__ before I reinstall anything, because reinstalling is how I hide from the actual name. I would dump sys.path under -I before I accept a chat-provided sys.path.insert. I would delete accidental top-level modules named utils.py, helper.py, and test.py, because those names are magnets. I would also keep a clean interpreter somewhere that is not my laptop, and I would not paste secrets into that workflow.

Repeatable checklist:

  1. Print repr(sys.path[0]) and module.__file__ in the same process.
  2. Rerun with python -I from a directory that is not the repo root.
  3. Compare python file.py against python -m package.module.
  4. Fail CI if a top-level helper.py or utils.py exists beside the package.
  5. Treat every sys.path.insert as a bug report, not as a fix.

Would I still ask a model to draft boilerplate? Yes, for the dump script shape, and never for the import graph. The second request is how cwd got inserted ahead of the package I actually wanted.

Who should not copy this, and what still breaks

Do not ship sys.path mutation as architecture, even when it quiets a demo. Do not debug proprietary code on a shared remote server if your policy forbids that kind of copy. Do not use model output as evidence of how CPython searches for modules, because the model will sound sure while repeating the cwd insert. This notes file is for people who already have a package, a script, and two launch methods that disagree.

Isolated mode is not a production hardening story by itself, and I am not selling it as one. It will not fix a script that really is a package and should have been executed with -m. It also will not save you from a similarly named stdlib module, which is a cousin of this bug and deserves its own weekend. Shadowing json.py or test.py is the same family with worse manners.

This lab does not measure network installs, lockfiles, or which assistant writes nicer patches. I am not claiming timings, quotas, or hardware for any tool. The only claim I need is smaller, and it still took me two days to believe it: if two launches disagree about sys.path[0], you do not have one program.

I still flinch when an import succeeds too quickly, because that success was the lie hiding sys.path[0]. The empty string was doing real work, and the IDE had been covering for it the entire time.

Top comments (0)