pytest Cannot Import My GIS Modules: How to Fix It
On this page
Problem statement
The pipeline runs. The tests do not.
$ python -m src.pipeline
processed 40 parcels
$ pytest
ImportError while importing test module 'project/tests/test_transforms.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
...
tests/test_transforms.py:2: in <module>
from src.transforms import buffer_metres
E ModuleNotFoundError: No module named 'src'
Same interpreter, same directory, same import line. The pipeline finds src; pytest does not. And python -m pytest, from the same place, passes.
The reason is that pytest does not run your script β it imports your test file, and it does that with a sys.path it builds itself. Whatever made import src work when you ran python -m src.pipeline is not in effect. Every transcript on this page is from a real project built and tested in the code examples below, with 40 Rutland land parcels as the test fixture.
This is not a GIS-specific problem, but it bites GIS projects harder than most, because the workaround people reach for β sys.path.append at the top of the test β is repeated per file, leaks into notebooks and CI as a different failure, and hides the packaging mistake that the installed version of your pipeline will then ship with.
Quick answer
Make the project an installable package and install it in editable mode. This is the fix; everything else is a workaround.
project/
βββ pyproject.toml
βββ src/
β βββ gispipe/
β βββ __init__.py
β βββ transforms.py
βββ tests/
βββ test_transforms.py
# pyproject.toml
[project]
name = "gispipe"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["geopandas", "rasterio"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pip install -e .
pytest # imports work everywhere: tests, notebooks, CI, the app
from gispipe.transforms import buffer_metres # not src.transforms
The package directory with an __init__.py is not decoration. With the modules loose in src/, pip install -e . still made the tests pass β but the wheel built from the same project contained no code at all (example 2).
| Symptom | Cause | Fix |
|---|---|---|
No module named 'src' |
project not installed, sys.path lacks the root |
pip install -e . |
| works in terminal, fails in the IDE | IDE using a different interpreter | point the IDE at .venv/bin/python |
attempted relative import with no known parent package |
test run as a script, not a module | run pytest, not python tests/x.py |
import file mismatch |
two test files with the same basename | unique names, __init__.py, or --import-mode=importlib |
| passes alone, fails in the suite | shared mutable fixture or import-time state | see step 5 |
| CI only | different working directory, nothing installed | pip install -e . in CI too |
What pytest actually does to sys.path
Why the script imports and pytest does not, and the src layout
1. Understand why it works for the script and not for pytest
When you run python src/pipeline.py, Python puts the script's directory β src/ β on sys.path, so from src.transforms import β¦ fails there too. When you run python -m src.pipeline, it puts the current directory on sys.path, and the import works. When pytest imports tests/test_transforms.py in its default mode, it does neither: it walks up from the test file while it keeps finding __init__.py, and inserts the first directory without one β its basedir. With no __init__.py in tests/, that is tests/ itself.
# see it for yourself
pytest --collect-only -q
python -c "import sys; print('\n'.join(sys.path))"
So the fix is not to persuade pytest to guess your layout. It is to make the import work regardless of who is doing the importing β which means installing the package.
2. Adopt the src layout and install it
project/
βββ pyproject.toml
βββ src/
β βββ gispipe/
β βββ __init__.py
β βββ transforms.py
β βββ pipeline.py
βββ tests/
βββ conftest.py
βββ test_transforms.py
βββ fixtures/
βββ parcels.gpkg
pip install -e .
-e (editable) installs a pointer rather than a copy, so edits take effect without reinstalling. With setuptools and this layout the pointer is a .pth file in site-packages containing the path to src/ β which is why tests, notebooks, the CLI and CI all agree afterwards.
That .pth file is also the layout's blind spot. Because it puts the whole of src/ on the path, anything in src/ imports in an editable install, whether or not the packaging configuration would ship it. The src layout stops tests importing your code from the working directory; it does not stop them importing code the wheel leaves out. Build the wheel, or install with plain pip install . in CI, to check that.
3. If you cannot install it, use pythonpath β not sys.path.append
pytest 7.0 and later can add paths from config:
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
# the same in pytest.ini β still needs pytest 7.0 or later
[pytest]
pythonpath = src
testpaths = tests
This is declarative, applies to every test, and lives in one file. Compare with the version people write instead:
# tests/test_transforms.py β do not do this
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
from gispipe.transforms import buffer_metres
That has to be repeated in every test file, breaks when a file moves, and fixes only pytest: the notebook, the CLI and the next colleague's IDE still cannot import the package.
4. Put shared fixtures in conftest.py, at the right level
# tests/conftest.py β discovered automatically, no import needed
from pathlib import Path
import geopandas as gpd
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture
def parcels():
return gpd.read_file(FIXTURES / "parcels.gpkg")
conftest.py needs no import statement anywhere β pytest finds it by walking up from each test file. Note Path(__file__).parent for the fixture directory: a relative path like "tests/fixtures/parcels.gpkg" resolves against the working directory, so it works from the project root and fails from anywhere else (example 3). That is the same bug as relative paths breaking in a scheduled script, one context over.
5. Fix "passes alone, fails in the suite"
If pytest tests/test_a.py passes and pytest fails, the import itself is fine and something is shared. The usual causes:
# a) two test files with the same basename and no __init__.py
tests/unit/test_io.py
tests/integration/test_io.py # pytest stops with "import file mismatch"
# b) a session-scoped fixture that a test mutates
@pytest.fixture(scope="session")
def parcels():
return gpd.read_file(FIXTURE) # test 1 adds a column, test 2 sees it
# c) a module-level import with a side effect
from gispipe.config import load # reads an env var at import time
Fixes: give test files unique names (or add __init__.py to the test directories, or use --import-mode=importlib), make mutable fixtures function-scoped, and keep import-time side effects out of modules.
@pytest.fixture # function scope: fresh copy per test
def parcels(parcels_source):
return parcels_source.copy()
@pytest.fixture(scope="session") # the expensive read happens once
def parcels_source():
return gpd.read_file(FIXTURE)
That pairing gives you one file read and independent tests, which is what people are usually reaching for when they set scope="session" (example 5).
6. Make CI do exactly what you do
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- run: pip install --only-binary=:all: -e ".[dev]"
- run: pytest -q
The -e . is the important line β without it, CI hits the same ModuleNotFoundError on a fresh checkout that a new colleague hits on clone. --only-binary=:all: refuses source builds of the GIS dependencies, and it does not stop pip building your own project's editable install. To catch the empty-wheel mistake as well, add a job that runs the tests against pip install . without -e.
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov"]
The import failure reproduced in a fresh project
The data used on this page
Each example builds a small project in project/, runs real commands in a fresh virtual environment (geopandas and pytest installed from PyPI), and prints what they printed, with the project's absolute path shortened to project. The test fixture is the first 40 land parcels in HM Land Registry's INSPIRE Index Polygons for Rutland (Β© Crown copyright and database rights, HM Land Registry; Open Government Licence).
import re
import shlex
import shutil
import subprocess
import sys
import textwrap
import venv
import zipfile
from pathlib import Path
import geopandas as gpd
import requests
LAB, ENV = Path("project").resolve(), Path("env").resolve()
if not (ENV / "bin" / "pytest").exists():
venv.create(ENV, with_pip=True)
subprocess.run([ENV / "bin/pip", "install", "-q", "geopandas", "pytest"], check=True)
zip_path = Path("data/Rutland.zip")
if not zip_path.exists():
zip_path.parent.mkdir(exist_ok=True)
zip_path.write_bytes(requests.Session().get(
"https://use-land-property-data.service.gov.uk/datasets/inspire/download/Rutland.zip", timeout=300).content)
sample = gpd.read_file(f"/vsizip/{zip_path}/Land_Registry_Cadastral_Parcels.gml", rows=40)
def fresh():
shutil.rmtree(LAB, ignore_errors=True)
(LAB / "tests/fixtures").mkdir(parents=True)
sample.to_file(LAB / "tests/fixtures/parcels.gpkg")
def write(rel, text):
path = LAB / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(textwrap.dedent(text).lstrip())
def run(*cmd, cwd=LAB, pick=None):
"""Run a command with the environment's python/pip/pytest; print the telling lines."""
exe = [str(ENV / "bin" / cmd[0]) if cmd[0] in ("python", "pip", "pytest") else cmd[0], *cmd[1:]]
out = subprocess.run(exe, cwd=cwd, capture_output=True, text=True)
text = (out.stdout + out.stderr).replace(str(LAB), "project").replace(str(ENV), ".venv")
lines = [l for l in text.splitlines() if l.strip()]
shown = [l for l in lines if pick and re.search(pick, l)] + lines[-1:]
print(f"$ {shlex.join(cmd)}" + ("" if cwd == LAB else " (from the directory above project/)"))
for line in dict.fromkeys(shown):
print(" ", line)
versions = subprocess.run([ENV / "bin/python", "-c", "import geopandas, pytest, sys; "
"print(sys.version.split()[0], geopandas.__version__, pytest.__version__)"],
capture_output=True, text=True).stdout.split()
print("Python {}, geopandas {}, pytest {}".format(*versions))
print(len(sample), "Rutland parcels,", sample.crs.to_string())
Python 3.14.4, geopandas 1.2.0, pytest 9.1.1
40 Rutland parcels, EPSG:27700
Example 1: The failure, reproduced four ways
fresh()
write("src/transforms.py", '''
def buffer_metres(gdf, metres):
"""Buffer in British National Grid, whatever CRS the input arrived in."""
return gdf.to_crs(27700).buffer(metres)
''')
write("src/pipeline.py", '''
import geopandas as gpd
from src.transforms import buffer_metres
parcels = gpd.read_file("tests/fixtures/parcels.gpkg")
print(f"processed {len(buffer_metres(parcels, 10))} parcels")
''')
write("tests/test_transforms.py", '''
import geopandas as gpd
from src.transforms import buffer_metres
def test_buffer_grows_every_parcel():
parcels = gpd.read_file("tests/fixtures/parcels.gpkg")
assert (buffer_metres(parcels, 10).area > parcels.area).all()
''')
run("python", "src/pipeline.py", pick=r"^\w*Error")
run("python", "-m", "src.pipeline")
run("pytest", "-q", pick=r"^E ")
run("python", "-m", "pytest", "-q")
$ python src/pipeline.py
ModuleNotFoundError: No module named 'src'
$ python -m src.pipeline
processed 40 parcels
$ pytest -q
E ModuleNotFoundError: No module named 'src'
1 error in 0.33s
$ python -m pytest -q
1 passed in 0.25s
The script run directly fails too, because its own directory is src/. python -m and python -m pytest both put the working directory on the path, which is why they work β and why a teammate who types pytest instead gets a different answer from the same checkout.
Example 2: The editable install that hides an empty wheel
The obvious fix: add a pyproject.toml, point setuptools at src/, install editable, and drop the src. prefix.
write("pyproject.toml", '''
[project]
name = "gis-pipeline"
version = "0.1.0"
dependencies = ["geopandas"]
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
where = ["src"]
''')
for rel in ("src/pipeline.py", "tests/test_transforms.py"):
(LAB / rel).write_text((LAB / rel).read_text().replace("from src.transforms", "from transforms"))
run("pip", "install", "-q", "-e", ".")
run("pytest", "-q")
print(".pth:", *(p.read_text().strip().replace(str(LAB), "project")
for p in (ENV / "lib").glob("python3*/site-packages/__editable__*gis_pipeline*.pth")))
run("pip", "wheel", "-q", "--no-deps", "-w", "dist", ".")
wheel = next((LAB / "dist").glob("*.whl"))
print(wheel.name, "contains:", [n for n in zipfile.ZipFile(wheel).namelist() if not n.startswith("gis_pipeline-")])
$ pip install -q -e .
$ pytest -q
1 passed in 0.24s
.pth: project/src
$ pip wheel -q --no-deps -w dist .
gis_pipeline-0.1.0-py3-none-any.whl contains: []
The tests pass, and the wheel has nothing in it: packages.find looks for packages, found none among loose modules, and the editable install worked only because its .pth file put all of src/ on the path. Anyone who installed this project normally would get No module named 'transforms'. Moving the modules into a named package fixes both:
run("pip", "uninstall", "-q", "-y", "gis-pipeline")
shutil.rmtree(LAB / "dist")
(LAB / "src/gispipe").mkdir()
for name in ("transforms.py", "pipeline.py"):
(LAB / "src" / name).rename(LAB / "src/gispipe" / name)
(LAB / "src/gispipe/__init__.py").write_text("")
for rel in ("src/gispipe/pipeline.py", "tests/test_transforms.py"):
(LAB / rel).write_text((LAB / rel).read_text().replace("from transforms", "from gispipe.transforms"))
(LAB / "pyproject.toml").write_text((LAB / "pyproject.toml").read_text().replace('"gis-pipeline"', '"gispipe"'))
run("pip", "install", "-q", "-e", ".")
run("pytest", "-q")
run("python", "-c", 'import gispipe; print("gispipe at:", gispipe.__file__)')
run("pip", "wheel", "-q", "--no-deps", "-w", "dist", ".")
wheel = next((LAB / "dist").glob("*.whl"))
print(wheel.name, "contains:", [n for n in zipfile.ZipFile(wheel).namelist() if not n.startswith("gispipe-")])
$ pip uninstall -q -y gis-pipeline
$ pip install -q -e .
$ pytest -q
1 passed in 0.27s
$ python -c 'import gispipe; print("gispipe at:", gispipe.__file__)'
gispipe at: project/src/gispipe/__init__.py
$ pip wheel -q --no-deps -w dist .
gispipe-0.1.0-py3-none-any.whl contains: ['gispipe/__init__.py', 'gispipe/pipeline.py', 'gispipe/transforms.py']
gispipe.__file__ pointing into src/ rather than site-packages is how you know the install is editable; the wheel's file list is how you know it would work if it were not.
Example 3: Fixture paths that only work from one directory
The test above opens "tests/fixtures/parcels.gpkg". Run the same suite from the directory above the project, as an IDE or a CI step with a different working directory will:
run("pytest", "-q", "project/tests", cwd=LAB.parent, pick=r"^E .*Error")
write("tests/conftest.py", '''
from pathlib import Path
import geopandas as gpd
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture
def parcels():
return gpd.read_file(FIXTURES / "parcels.gpkg")
''')
write("tests/test_transforms.py", '''
from gispipe.transforms import buffer_metres
def test_buffer_grows_every_parcel(parcels):
assert (buffer_metres(parcels, 10).area > parcels.area).all()
''')
run("pytest", "-q", "project/tests", cwd=LAB.parent)
$ pytest -q project/tests (from the directory above project/)
E pyogrio.errors.DataSourceError: tests/fixtures/parcels.gpkg: No such file or directory
1 failed in 0.29s
$ pytest -q project/tests (from the directory above project/)
1 passed in 0.04s
The import is fine from anywhere now that the package is installed. The data path was the remaining dependency on the working directory, and the conftest fixture removes it.
Example 4: Two test files with the same name
for kind in ("unit", "integration"):
write(f"tests/{kind}/test_io.py", f'''
def test_{kind}_reads(parcels):
assert len(parcels) == 40
''')
run("pytest", "-q", pick=r"import file mismatch")
run("pytest", "-q", "--import-mode=importlib")
for kind in ("unit", "integration"):
(LAB / f"tests/{kind}/__init__.py").write_text("")
(LAB / "tests/__init__.py").write_text("")
run("pytest", "-q")
$ pytest -q
import file mismatch:
1 error in 0.09s
$ pytest -q --import-mode=importlib
3 passed in 0.06s
$ pytest -q
3 passed in 0.07s
pytest does not silently use the wrong module; it stops collection with import file mismatch. Either of the other two modes fixes it. Adding __init__.py makes pytest import tests.unit.test_io and tests.integration.test_io as distinct packages; importlib mode stops inserting directories into sys.path at all, which is also why, in that mode, a test cannot import a sibling helper module unless it is part of an installed package.
Example 5: A shared fixture that passes alone and fails in the suite
shutil.rmtree(LAB / "tests/unit"), shutil.rmtree(LAB / "tests/integration")
write("tests/conftest.py", '''
from pathlib import Path
import geopandas as gpd
import pytest
FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture(scope="session")
def parcels():
return gpd.read_file(FIXTURES / "parcels.gpkg")
''')
write("tests/test_a_area.py", '''
def test_area_column(parcels):
parcels["area_m2"] = parcels.area # mutates the shared frame
assert parcels["area_m2"].gt(0).all()
''')
write("tests/test_b_schema.py", '''
def test_schema_is_as_delivered(parcels):
assert "area_m2" not in parcels.columns
''')
run("pytest", "-q", "tests/test_b_schema.py")
run("pytest", "-q", pick=r"^FAILED")
(LAB / "tests/conftest.py").write_text((LAB / "tests/conftest.py").read_text().replace(
'@pytest.fixture(scope="session")\ndef parcels():',
'@pytest.fixture(scope="session")\ndef parcels_source():') + textwrap.dedent('''
@pytest.fixture
def parcels(parcels_source):
return parcels_source.copy()
'''))
run("pytest", "-q")
$ pytest -q tests/test_b_schema.py
1 passed in 0.04s
$ pytest -q
FAILED tests/test_b_schema.py::test_schema_is_as_delivered - AssertionError: ...
1 failed, 2 passed in 0.09s
$ pytest -q
3 passed in 0.06s
The file is read once per session either way; only the copy is per test.
Example 6: What GIS imports cost the suite
write("tests/test_slow.py", '''
import pytest
@pytest.mark.slow
def test_full_county(parcels):
assert parcels.union_all().area > 0
''')
(LAB / "pyproject.toml").write_text((LAB / "pyproject.toml").read_text() + textwrap.dedent('''
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = ["slow: needs real I/O or a database"]
'''))
run("pytest", "-q", "-m", "not slow")
run("python", "-c", 'import time; t = time.perf_counter(); import geopandas; '
'print(f"import geopandas: {time.perf_counter() - t:.2f} s")')
$ pytest -q -m 'not slow'
3 passed, 1 deselected in 0.08s
$ python -c 'import time; t = time.perf_counter(); import geopandas; print(f"import geopandas: {time.perf_counter() - t:.2f} s")'
import geopandas: 0.33 s
Importing geopandas cost well under a second here, and reading a 40-parcel fixture is nothing. The case for a slow marker in a GIS suite is not the imports; it is the test that reads a county, or a database, or a raster tile set, and turns a suite you run on every save into one you run before lunch.
How sys.path is filled
Python resolves import x by searching sys.path in order. What populates sys.path depends entirely on how the process started:
python script.pyβ the script's directory goes firstpython -m package.moduleβ the current working directory goes firstpytestβ pytest inserts the basedir it computes for each test file- a notebook β the notebook's directory
- an installed console script β only
site-packages
Five entry points, five different answers. Any fix that targets one of them β sys.path.append in a test file, or running pytest only from the project root β leaves the other four broken. That is why "make the package installable" is the real answer rather than a purist one: an installed package is on sys.path for every entry point, so the question stops being asked.
pytest's default import mode, prepend, explains the weirder symptoms. For each test file it walks up the directory tree as long as it keeps finding __init__.py, inserts the first directory that does not have one, and imports the file under the dotted name from there. With no __init__.py anywhere in tests/, tests/unit/test_io.py and tests/integration/test_io.py are both the top-level module test_io, and pytest refuses the second rather than guess.
The src layout addresses a subtler failure than it is usually credited with, and less of one. With packages at the repository root, import gispipe succeeds from the project directory whether or not the package is installed β so tests can pass against code the installation never sees. Moving the code under src/ removes the working directory from the equation. But an editable install puts src/ itself on the path, so the tests still see everything there, packaged or not. The last check has to be against a real build.
Editable installs, loose modules and import modes
pip install -e .needspyproject.tomlorsetup.py. A bare folder of scripts is not a package; that is the actual problem being fixed.- Loose modules in
src/are invisible topackages.find. List them with[tool.setuptools] py-modules = ["transforms"], or put them in a package β otherwise the editable install works and the wheel is empty. - A
conftest.pyat the repository root puts the root onsys.pathin the default import mode, which makesfrom src.transforms import β¦work again. It is a common accidental fix; it is still not an install. pytestandpython -m pytestdiffer. The second adds the current directory tosys.path, which can mask the problem locally and fail in CI.--import-mode=importlib(pytest 6.0 and later) avoids thesys.pathinsertion entirely and is the cleanest mode for an installed package.- Jupyter needs the same install.
pip install -e .from inside the kernel's environment, not from a terminal that may be using a different one. tmp_pathis per-test;tmp_path_factoryis session-scoped when a fixture builds something expensive.
Internal links
- How to test a GIS pipeline with pytest β the suite this page unblocks
- What to test in a GIS pipeline β what to put in it once imports work
- GIS tests pass locally but fail in CI β the next failure after this one
- How to set up a Python GIS environment that actually works β the interpreter the IDE should be pointing at
- ModuleNotFoundError in a scheduled GIS job β the same import problem under cron
- Relative paths break when a GIS script runs automatically β why fixture paths need
Path(__file__) - How to turn a GIS script into a command-line tool with argparse β the CLI that the same package layout gives you
- How to run a Python GIS pipeline in CI with GitHub Actions β where
pip install -e .belongs
FAQ
Why does my script find the module but pytest does not?
python -m package.module puts the current directory on sys.path, and python script.py puts the script's own directory there. pytest imports test files with a path it computes itself, which does not include your source directory. Installing the package makes every entry point agree.
Is sys.path.append in a test file ever acceptable?
For a one-off spike, maybe. In a repository, no β it has to be repeated in every file, breaks on moves, and behaves differently depending on collection order. Use pythonpath in config, or install the package.
Do I need __init__.py in my tests directory?
Only if you have test files with the same basename in different directories β without it pytest stops with import file mismatch. Adding them changes the dotted name each test module is imported under; --import-mode=importlib is the alternative that needs none.
What is the src layout for?
It stops tests importing your package from the working directory, so they only pass once the package is installed. An editable install still exposes everything under src/, so build the wheel or test a normal install in CI to prove the package ships what the tests used.
Why does the IDE fail when the terminal works?
The IDE is almost certainly using a different interpreter. Compare sys.executable in both and point the IDE at your .venv.
Does pip install -e . need repeating after every edit?
No. An editable install links to your source, so changes take effect immediately. Reinstall only when dependencies or entry points change.
How do I stop the test suite from being slow?
Mark tests that touch real files or databases with @pytest.mark.slow and run pytest -m "not slow" during development. I/O is the usual cost; importing geopandas took well under a second in the run on this page.