skip to content

src Layout vs Flat Layout

Whether your package sits at the repo root or under src/, and why that decides if tests exercise the installed package or the working copy. Interviewers use it as a proxy for a real accidental-import scar.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

In a flat-layout Python repo, why does an import at the repo root find the working copy instead of the installed distribution?

level: middleimportance: must knowfreq 48%

answer

  1. The first entry decides everything
  2. Something is prepended before your code runs
  3. Script directory, or the working directory
  4. The working copy is found before site-packages
  5. 3.11 added an opt-out flag

basics

~20 s

CPython prepends one entry to sys.path at start-up: the script's directory, or the working directory for python -m and python -c. In a flat layout the package sits there, so it is found before site-packages and shadows the installed copy.

solid answer

~50 s

Import resolution walks `sys.path` in order and takes the first match, and CPython puts one entry at the front before your code runs: for `python script.py` it is the directory containing the script, and for `python -m ...`, `python -c ...` and the interactive prompt it is the current working directory. Run anything from the root of a flat-layout repository and that entry *is* the repository root, where the package lives — so `import indexer` never reaches site-packages. The installed distribution could be stale, incomplete, or absent entirely and nothing would tell you. A src layout removes the shadow because the root holds no importable package. Since 3.11 you can also suppress the prepended entry explicitly with the `-P` option or the `PYTHONSAFEPATH` environment variable, and confirm what actually resolved by printing a module's `__file__`.

code

python · 5 lines
python
import sys
import importlib.util

print(sys.path[0])
print(importlib.util.find_spec("json"))

go deeper

for a junior

Know that an import searches sys.path in order and takes the first match, and that where you launch the interpreter from therefore changes what gets imported. Be able to print a module's __file__ to see which file you actually got.

for a middle

Explain precisely what is prepended and when — the script's directory for a script, the working directory for -m, -c and the prompt — and walk through why that makes a flat-layout checkout shadow its own installed distribution.

for a senior

Demonstrate the diagnosis and the durable fix: compare __file__ against installed metadata, recognise the stale-or-incomplete-install failure, and argue for src layout over relying on -P or a convention people must remember.

for a principal

Frame it as a guarantee you are buying for the organisation. Decide whether CI installs a built wheel into a clean environment before testing, who owns that step, and what it costs newcomers compared with the class of release bug it removes.

## The one entry that causes all of this `import indexer` asks the import machinery to search `sys.path` in order and use the first directory that yields a match. Everything about flat-layout shadowing follows from *which entry is first*. Before your first line runs, CPython prepends one path entry: - `python script.py` — the directory containing `script.py`. - `python -m package.module` — the current working directory. - `python -c "..."` and the interactive prompt — the current working directory. Only then come the environment's `PYTHONPATH` entries, the standard library, and finally site-packages. So in a flat-layout repository, where the package directory sits at the top level next to `pyproject.toml`, running *anything* from that root puts the working copy ahead of every installed distribution in the environment. ```python import sys import importlib.util print(sys.path[0]) print(importlib.util.find_spec("json")) ``` ## What the shadow hides The shadow is not itself a bug — it is what makes a checkout convenient. The problem is what it conceals. **A stale install.** You installed the project weeks ago, have since renamed a function, and every local run keeps working because none of them touched site-packages. The moment a real deployment imports the installed copy, the rename is missing. **An incomplete install.** You added `indexer/locale_formats.py`, but the build never picked it up — a subpackage without `__init__.py`, an include rule that does not match, a backend that needs the module listed. Every local run imports it from the working copy and passes. The installed wheel does not contain it at all. **Two copies of the same code.** `sys.modules` is keyed by module *name*, so within one process only one `indexer` wins; but different processes, different working directories or a subprocess launched from elsewhere can each win differently. A test job that runs from the root and a production entry point that runs from `/` are then executing genuinely different code with the same version number. **A name collision that is not yours.** A top-level `logging.py`, `types.py` or `queue.py` at the repository root shadows a standard-library module for anything started from that directory, producing errors far from their cause. This is the same mechanism, applied to a module you never meant to publish. ## How to see which copy you actually got The diagnosis is short and worth having at your fingertips: - print the module's `__file__` — the definitive answer to "where did this come from"; - print `sys.path[0]` — the entry doing the shadowing; - use `importlib.util.find_spec("indexer")` without importing, to see what *would* be imported; - compare with `importlib.metadata.version("indexer")`, which reads installed distribution metadata rather than the import path — a mismatch between the two is the signature of the problem. ## Turning it off Since **Python 3.11** you can ask CPython not to prepend that entry at all: ```console python -c "import sys; print(sys.path[0])" python -P -c "import sys; print(sys.path[0])" ``` `-P` suppresses the potentially-unsafe prepended path; the `PYTHONSAFEPATH` environment variable does the same for every interpreter started in that environment, and `-I` (isolated mode) implies it. That is a useful hardening flag for entry points, but as a development discipline it is fragile: it protects only the commands you remember to pass it to. ## Why "just install it first" is not the answer The reflex fix is to install the project into the environment and assume the installed copy now wins. It does not: installing changes what is in site-packages, and site-packages is still last. As long as the package sits at the repository root and you launch from there, the working copy is found first no matter how many times you reinstall. Nothing about the install order, the tool used, or the environment changes the search order — only the layout, an explicit flag, or launching from somewhere else does. That is also the reason the problem is so persistent in practice. Every individual developer's experience is that "it works", because for them it genuinely does; the divergence is only ever visible to whoever imports the artefact without a checkout underneath them. ## Why the layout is the durable fix A src layout makes the shadow impossible rather than optional. The repository root contains `src/`, `tests/` and configuration — no importable package — so the prepended entry matches nothing, and `import indexer` resolves through the environment's installed distribution however you launch the interpreter. You cannot forget a flag, and nobody has to remember a convention. That is the actual argument for src layout: not tidiness, but that it converts "the developer must remember to test the installed artefact" into "the developer cannot avoid it". The natural next question is whether an editable install gives the shadow back. Broadly, it points imports at your working tree on purpose, so a file you forgot to declare can still import cleanly; the strong check remains a plain install of a built wheel into a clean environment before the test run. The mechanics of how an editable install wires that up are their own topic.

  • How would you prove, inside a running process, which copy of a package was imported?
    Print the module's `__file__`; it names the exact file the import machinery used. Cross-check it against `importlib.metadata.version("indexer")`, which reads installed distribution metadata rather than the import path — when `__file__` points into your checkout while the metadata reports a different version, you are running the working copy and the installed one is stale.
  • A colleague adds queue.py at the repository root and unrelated code starts failing. What happened?
    Same mechanism, worse blast radius. The repository root is first on `sys.path` for anything launched there, so `import queue` now finds their file instead of the standard-library module, and every consumer of the real one breaks with errors that point nowhere near the new file. Rename it, or move the package under src/ so the root stops being an import surface.
  • Does setting PYTHONSAFEPATH make the layout question moot?
    No. It removes the prepended entry for interpreters started in that environment, which fixes the symptom where it is set — CI, a container entry point — but it is opt-in per environment and easy to lose. A src layout removes the shadow structurally, for every developer and every command, with nothing to remember.

saying these in an interview costs you the question

  • Saying installed packages always take priority
  • Believing PYTHONPATH is what adds the repo root
  • Thinking pyproject.toml influences import resolution
  • Claiming two copies can coexist under one module name
  • Assuming a passing local run proves the wheel is complete

context

open as a page

What is the difference between a src layout and a flat layout in a Python project?

level: juniorimportance: should knowfreq 40%

basics

~20 s

A flat layout keeps the importable package directory at the repository root, beside pyproject.toml. A src layout moves that directory under src/, so nothing at the root is importable and the code must be installed before it can be imported.

open as a page

Under a src layout, how does automatic package discovery differ from a flat layout's?

level: middleimportance: should knowfreq 26%

basics

~20 s

Under a src layout the backend has one unambiguous root: everything under src/ is package content. A flat layout makes it guess among the repository's top-level directories, excluding tests and docs by name, and fail when several look equally plausible.

open as a page

Under a flat layout, how can a nightly rebuilder's tests pass while its installed wheel raises ModuleNotFoundError?

level: seniorimportance: should knowfreq 30%

basics

~20 s

Because the tests never imported the wheel. With the repository root first on sys.path, a flat-layout test run imports the working copy, which contains every file; the built distribution can be missing a module and nothing in the run would notice.

open as a page