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
Last updated: 24 September 2026
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:
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.pyEverything 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:
$ python -c "import invoicer"
ModuleNotFoundError: No module named 'invoicer'
$ python -m pip install -e ".[dev]"
$ python -c "import invoicer; print(invoicer.__version__)"
0.1.0The 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):
[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.dependencieslists what the package needs to run;optional-dependenciesgroups extras such as test tools, installed withpip install -e ".[dev]".[project.scripts]creates a realinvoicercommand on install that callsmain()ininvoicer/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
# 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"))# 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# 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):
$ python -m invoicer 2x50 1x100
236.00
$ invoicer 3x10
35.40Imports 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
# 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")$ python -m pytest -q
. [100%]
1 passed in 0.02sTests 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
| Thing | Where | Why |
|---|---|---|
| Settings and secrets | Environment variables, read by one settings module | Nothing sensitive in the repository (environment variables and secrets) |
| Notebooks and experiments | notebooks/, importing from the installed package | Exploration stays separate from shipped code |
| Sample or test data | tests/data/, small files only | Real datasets live in storage, not in Git |
| Scripts for developers | scripts/ | Utilities that are not part of the package |
| Docs | README.md, then docs/ | Install and usage instructions first |
What never goes into Git
# .gitignore
.venv/
__pycache__/
*.egg-info/
build/
dist/
.pytest_cache/
.envVirtual 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?
Where should the version number live?
Is the src layout required?
Checklist
- Package code under
src/<name>/, tests undertests/. - One
pyproject.tomlwith metadata, dependencies, commands and tool settings. - Develop in a virtual environment with
pip install -e ".[dev]". - Logic in modules that know nothing about how they are called; thin entry points in
cli.pyand__main__.py. - Configuration from the environment; generated files and secrets in
.gitignore. requires-pythonset 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
- src layout vs flat layout β Python Packaging User Guide
- Writing your pyproject.toml β Python Packaging User Guide
- pytest: Good Integration Practices β pytest documentation
- __main__ β Top-level code environment β Python documentation
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.
Related articles
Learn Python from Scratch: A Complete 2026 Roadmap
How to learn Python from zero: install a current version (including the new Windows install manager), then work through seven stages, from values and loops to files, virtual environments, classes and tests, each ending with a small program you should be able to write on your own.
Python Interview Questions: Top 50 with Answers
Fifty conceptual Python interview questions with short, precise answers, grouped by topic: basics, types and mutability, data structures, functions and scope, OOP, iterators and decorators, errors and files, and internals, with runnable examples for the ones people most often get wrong.
Python for Data Science: The Essential Libraries
The Python data science stack explained by the job each library does: NumPy for arrays, pandas for tables, Matplotlib and seaborn for charts, SciPy and statsmodels for statistics, scikit-learn for models and Jupyter for exploration, with one runnable example that passes a real dataset through them.