How to Structure a Python Project the Right Way

A Python project layout that works from the first commit: why the src layout catches packaging mistakes, a complete pyproject.toml with dependencies and a command, where tests and configuration go, running the package with python -m, and the files that should never be committed.

GloryTecks Team

Training & Placement Faculty

6 May 2026 6 min read

Last updated: 24 September 2026

Share:

A single script needs no structure. The trouble starts when a second file imports the first, a test needs to import both, and a colleague has to install the thing on their machine. Imports then work from one folder but not another, tests pass locally and fail in CI, and "it works on my machine" becomes a folder-layout problem. A standard layout avoids all of that, and Python's packaging tools now agree on one.

The layout

Here is a small but complete project, invoicer, which totals invoices and offers a command-line command:

text
invoicer/
β”œβ”€β”€ pyproject.toml          # metadata, dependencies, commands, tool settings
β”œβ”€β”€ README.md
β”œβ”€β”€ .gitignore
β”œβ”€β”€ src/
β”‚   └── invoicer/           # the importable package
β”‚       β”œβ”€β”€ __init__.py
β”‚       β”œβ”€β”€ __main__.py     # lets you run: python -m invoicer
β”‚       β”œβ”€β”€ cli.py          # command-line entry point
β”‚       └── core.py         # the actual logic
└── tests/
    └── test_core.py

Everything else is a variation on this: more modules inside src/invoicer/, sub-packages as the code grows, a docs/ folder when there are docs.

Why the code lives under src/

The alternative, the "flat" layout, puts invoicer/ directly in the project root. It looks simpler, but it has a trap: Python adds the current directory to the import path, so from the project root, import invoicer finds the folder, not the installed package. Your tests can pass while the package you ship is broken, for example because a module was left out of the build.

With the src layout, the package is not importable from the project root until it is installed (Python Packaging User Guide: src vs flat layout). In a fresh virtual environment, before and after installing:

text
$ python -c "import invoicer"
ModuleNotFoundError: No module named 'invoicer'

$ python -m pip install -e ".[dev]"
$ python -c "import invoicer; print(invoicer.__version__)"
0.1.0

The error is the point. It forces you to test what users will get. The -e flag makes the install editable, so code changes take effect immediately without reinstalling. pytest's own guidance recommends the src layout for new projects for the same reason (pytest: good practices).

pyproject.toml: one file for the project's metadata

pyproject.toml replaced setup.py, setup.cfg and assorted tool files as the place a project describes itself (Python Packaging User Guide: writing pyproject.toml):

toml
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"

[project]
name = "invoicer"
version = "0.1.0"
description = "Create and total simple invoices"
requires-python = ">=3.10"
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=8"]

[project.scripts]
invoicer = "invoicer.cli:main"

[tool.pytest.ini_options]
testpaths = ["tests"]
  • [build-system] says which tool builds the package. Setuptools, Hatchling and others all work; the rest of the file stays the same.
  • dependencies lists what the package needs to run; optional-dependencies groups extras such as test tools, installed with pip install -e ".[dev]".
  • [project.scripts] creates a real invoicer command on install that calls main() in invoicer/cli.py.
  • [tool.*] tables hold settings for pytest, linters and formatters, so they do not need their own files.

Pinning exact versions of every transitive dependency belongs in a lock file or a requirements file for deployments, not in dependencies; see virtual environments: venv, pip and Poetry.

The code: logic separate from the entry point

python
# src/invoicer/core.py
from decimal import Decimal

def invoice_total(lines: list[tuple[str, int, Decimal]], tax_rate: Decimal = Decimal("0.18")) -> Decimal:
    subtotal = sum((qty * price for _, qty, price in lines), Decimal("0"))
    return (subtotal * (1 + tax_rate)).quantize(Decimal("0.01"))
python
# src/invoicer/cli.py
import sys
from decimal import Decimal

from invoicer.core import invoice_total

def main(argv=None) -> int:
    args = sys.argv[1:] if argv is None else argv
    lines = [("item", int(q), Decimal(p)) for q, p in (a.split("x") for a in args)]
    print(invoice_total(lines))
    return 0
python
# src/invoicer/__main__.py
from invoicer.cli import main

raise SystemExit(main())

core.py knows nothing about the command line, so it can be imported by a web app or a notebook as easily as by the CLI. __main__.py is what runs when you execute the package with python -m invoicer (Python docs: the __main__ module):

text
$ python -m invoicer 2x50 1x100
236.00
$ invoicer 3x10
35.40

Imports inside the package use the package name (from invoicer.core import ...), never paths relative to your current folder. For a fuller command-line interface, see building a CLI tool with argparse.

Tests in their own folder

python
# tests/test_core.py
from decimal import Decimal

from invoicer.core import invoice_total

def test_total_includes_tax():
    assert invoice_total([("pen", 2, Decimal("50"))]) == Decimal("118.00")
text
$ python -m pytest -q
.                                                                        [100%]
1 passed in 0.02s

Tests import the package exactly as a user would, which only works because it is installed. That is the src layout doing its job. Unit testing with pytest covers fixtures and parametrised tests.

Where everything else goes

ThingWhereWhy
Settings and secretsEnvironment variables, read by one settings moduleNothing sensitive in the repository (environment variables and secrets)
Notebooks and experimentsnotebooks/, importing from the installed packageExploration stays separate from shipped code
Sample or test datatests/data/, small files onlyReal datasets live in storage, not in Git
Scripts for developersscripts/Utilities that are not part of the package
DocsREADME.md, then docs/Install and usage instructions first

What never goes into Git

text
# .gitignore
.venv/
__pycache__/
*.egg-info/
build/
dist/
.pytest_cache/
.env

Virtual environments and build output are recreated from pyproject.toml; .env holds secrets.

As the package grows

Split modules by responsibility, not by type. A folder called utils/ that collects every helper becomes the place nobody can navigate; invoicer/pricing.py, invoicer/pdf.py and invoicer/storage.py say what they contain. When a group of modules belongs together, make it a sub-package: a folder with its own __init__.py. Keep __init__.py files light, ideally just a docstring, a version and a few deliberate re-exports, because everything in them runs on every import. And avoid circular imports by keeping dependencies pointing one way: entry points import the core logic, never the reverse.

Do I still need setup.py?

Not for new projects. pyproject.toml holds the metadata and the build configuration; a setup.py is only needed for unusual build steps that a backend cannot express in configuration.

Where should the version number live?

In one place. Either keep it in pyproject.toml and read it at run time with importlib.metadata.version("invoicer"), or let the build backend read it from the package, a dynamic version. Two hand-maintained copies, as in the minimal example above, will eventually disagree.

Is the src layout required?

No. The flat layout works too, and many projects use it. The src layout simply makes one class of packaging mistake impossible, which is why packaging and testing guides recommend it for new projects.

Checklist

  1. Package code under src/<name>/, tests under tests/.
  2. One pyproject.toml with metadata, dependencies, commands and tool settings.
  3. Develop in a virtual environment with pip install -e ".[dev]".
  4. Logic in modules that know nothing about how they are called; thin entry points in cli.py and __main__.py.
  5. Configuration from the environment; generated files and secrets in .gitignore.
  6. requires-python set to the oldest Python version you support, and the tests run on that version as well as the newest.

Once the layout is in place, readable code inside it is the next step; Python best practices covers that. Modules, packages and package management form one module of the Python Programming course, and testing and clean code another.

Sources

GloryTecks Team

Training & Placement Faculty

The GloryTecks faculty team mentors learners across Data Science, AI and Data Engineering, with a focus on real-time projects and placement readiness in Hyderabad.

Want to master Python?

Join GloryTecks’ expert-led Python Programming training in Hyderabad with real-time projects and 100% placement support.

Explore course