There's a specific, well-tested way professional Python projects are laid out on disk, and it isn't the first structure most people reach for. This lesson covers the 'src layout', why a flat layout causes subtle import bugs, and where tests, configuration, and docs fit around it.
1Flat Layout vs src Layout: A Subtle but Consequential Choice
A 'flat layout' places your package directory directly at the project root, next to pyproject.toml: my_project/mypackage/__init__.py. It reads naturally and is what most tutorials show first. The problem surfaces specifically when you run tests: if you cd my_project && pytest, Python's import system can find and import mypackage directly from the current working directory, completely independent of whether the package was ever actually pip install-ed ā because the current directory is implicitly importable.
That might sound harmless, but it means your test suite is silently testing 'the code as it sits on disk in this exact directory', not 'the code as it would actually be installed and imported by a real user via pip install my-package'. A broken pyproject.toml, a missing file that should have been included in the built package, or an accidentally-excluded submodule can all pass a full flat-layout test suite locally and still fail the moment a real user installs the published package ā a gap discovered far too late, often after a broken release has already shipped.
The 'src layout' ā nesting the package one level deeper, under src/mypackage/ ā closes that gap by construction. src/ is not on Python's default import path, so mypackage is *not* importable just by being in the current directory; the only way to import it is to actually install it (even in editable mode), which forces your test suite to exercise the real installation and import-resolution path every single time.
my_project/
pyproject.toml
mypackage/
__init__.py
core.py
tests/
test_core.pysrc layout: import requires real install
2Editable Installs: Fast Iteration Without Losing the Guarantee
The obvious objection to the src layout is friction: if the package must be installed to be importable, does that mean reinstalling after every code change during development? pip install -e . (an 'editable install') solves exactly this ā it registers your package as installed, pointing back at your live source tree in src/, so edits to source files take effect immediately without a reinstall step, while still going through the genuine package-resolution machinery Python uses for any installed package.
Modern editable installs (via pyproject.toml-based builds, using setuptools' PEP 660 support or similar from other build backends like Hatchling) are implemented with an import hook or a small .pth-style redirect rather than copying files, so they stay lightweight and fast, and ā importantly ā they still validate that your package's declared structure (what's in [tool.setuptools.packages.find] or equivalent) actually resolves correctly, catching packaging misconfigurations the flat layout would have hidden.
The workflow this enables in practice: pip install -e ".[dev]" once, at the start of working on a project, installs the package editable plus its development dependencies (test runners, linters), and from then on pytest, running from anywhere, exercises the real installed-and-importable package while still reflecting every source edit instantly.
# With a flat layout, this can succeed even if your package
# has a broken pyproject.toml that would fail for real users:
$ cd my_project
$ pytest # imports ./mypackage directly, bypassing installationEditable, real install ā live source, real import resolution
3Where Tests, Config, and Docs Fit Around the Package
tests/ lives as a sibling to src/, not inside the package itself ā this keeps test code out of what actually gets shipped to users when the package is built and published, and it's the layout pytest's default discovery and most CI templates expect without extra configuration. Test files typically mirror the package's internal module structure (tests/test_core.py testing src/mypackage/core.py), which keeps navigation between a module and its tests predictable as the codebase grows.
Configuration lives almost entirely in pyproject.toml at the project root in modern Python projects ā build system metadata, dependencies, tool configuration for ruff, mypy, and pytest can all coexist in different [tool.X] sections of that single file, replacing what used to be a scattered collection of setup.py, setup.cfg, requirements.txt, pytest.ini, and .flake8 files. The next lesson covers pyproject.toml's structure directly.
A typical complete professional layout looks like: pyproject.toml, README.md, LICENSE, src/mypackage/ (the actual package), tests/ (mirroring the package structure), and optionally docs/ for Sphinx or MkDocs documentation sources ā with a .gitignore covering __pycache__/, *.egg-info/, .venv/, and build artifacts, none of which belong in version control.
my_project/
pyproject.toml
src/
mypackage/
__init__.py
core.py
tests/
test_core.pyThe professional standard
4Step-by-Step Breakdown
Two projects can have identical code and wildly different reliability, just from how the files are arranged on disk. Let's build the layout professional teams standardize on.
A 'flat layout' puts your package directly in the project root, alongside pyproject.toml. It looks simple, but has a subtle trap.
The trap: running pytest from the project root can import mypackage directly from the source tree, even if it isn't properly installed ā masking packaging bugs.
Checkpoint: In a flat layout, why can pytest accidentally pass even if the package has a broken pyproject.toml?
- āPython can import the package directly from the current directory, bypassing installation entirely
- āThis is a known bug in pytest that will be fixed in a future version
The 'src layout' nests your package under src/, so it can ONLY be imported if properly installed ā tests then verify what users actually get.
pip install -e . installs the src-layout package in editable mode, making it importable everywhere while still reading live from source.
Checkpoint: What does pip install -e . do?
- āInstalls the package in "editable" mode, linking to the source so changes are reflected immediately
- āCopies the entire project into site-packages, disconnected from the source
With a solid project skeleton in place, the next question is what goes inside it ā packages and modules, next.
Detect a Real src Layout. Finish is_src_layout(): the src layout nests the package one directory deeper.
Level Up š
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Fully supported (via server-side Python execution).
Best Practices
Default to the src layout for any package you intend to publish or distribute
It forces your test suite to exercise the real, installed import path, catching packaging bugs before a user ever encounters them ā worth the minor extra directory nesting.
Keep tests/ as a sibling of src/, never nested inside the package itself
This keeps test code out of the built distribution, matches default pytest discovery, and is what virtually every CI template and tutorial in the ecosystem now assumes.
Frequent Bugs
Using a flat layout for a published package, where local tests pass because the package is importable directly from the working directory, but a fresh pip install of the published package fails for real users due to a packaging misconfiguration.
Switch to the src layout so tests can only run against a genuinely installed (even if editable) version of the package, surfacing packaging bugs locally instead of after publishing.
Real-World Examples
Setting Up a New Publishable Library
A team is starting a new internal Python library meant to be pip-installed by several other internal services, and wants to avoid packaging bugs reaching those consumers.
my_library/
pyproject.toml
README.md
src/
my_library/
__init__.py
client.py
tests/
test_client.py
# Setup: pip install -e ".[dev]"
# Then: pytest ā exercises the real installed package