Python Project Structure and pyproject.toml: A Practical Layout

A layout that works from the first commit: src directory, one pyproject.toml, an installable package, tests, and a command-line entry point.

Executive Summary: A clean Python project puts code under src/, tests in tests/, and all metadata and tool settings in one pyproject.toml. This guide builds a complete, installable project with that layout, explains why it prevents common import errors, and shows how setuptools reads metadata from the [project] table instead of a long setup.py.

The tests pass on your laptop. In CI, the same commit fails with ModuleNotFoundError: No module named 'taskapi'. Nothing is wrong with the code. On your machine, Python found the package only because you ran the tests from the folder that happened to contain it.

Python project structure is the arrangement of source files, tests, and configuration in a repository, together with the metadata that tells tools how to install and build the code. The central file is pyproject.toml, a standard configuration file that packaging tools, test runners, and linters all read.

This guide uses Python 3.10, pip 22.x, and setuptools 61 or later. You need a terminal and basic Python. A virtual environment is a per-project folder of installed packages, and every command below assumes you are working inside one.

My position: use the src layout and an installable package for anything larger than a single script. It adds one directory level, and it makes your development setup behave like your users’ setup.

Here is the whole project. The example is a small task tracker called taskapi.

taskapi/
  pyproject.toml           metadata, dependencies, tool settings
  setup.py                 two-line shim, needed only for editable installs
  README.md
  .gitignore
  src/
    taskapi/
      __init__.py          marks the folder as a package
      __main__.py          enables: python -m taskapi
      cli.py               command-line entry point
      models.py            data definitions
      service.py           business logic
  tests/
    test_service.py

Three rules explain the tree. Code that users import lives under src/. Tests live outside the package, so they never ship to users by accident. Configuration lives in one file at the root, not scattered across setup.cfg, pytest.ini, and mypy.ini.

Why the src layout beats the flat layout

The alternative, called the flat layout, puts the taskapi/ package folder directly in the repository root. It looks simpler. However, Python adds the current directory to its import path when you run it from the root, so import taskapi finds the local folder even if the package is not installed or is installed incorrectly.

Question Flat layout src layout
Where is the package? ./taskapi/ ./src/taskapi/
Does import taskapi work without installing? Yes, from the root only No, you must install it
What do tests import? Possibly the local folder, not the installed package Always the installed package
Are packaging mistakes caught early? Often not until a user installs it Yes, on your first test run
Extra setup None One install command

The flat layout hides problems such as a missing __init__.py or a data file left out of the build. With src, your tests run against the same thing your users get. That is the trade-off: a little friction now in exchange for failures that appear on your machine first.

Write the pyproject.toml

The file has three kinds of tables. [build-system] names the tool that builds your package, known as the build backend (PEP 517 and PEP 518). [project] holds standard metadata (PEP 621). [tool.*] tables hold settings for individual tools.

[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"

[project]
name = "taskapi"
version = "0.1.0"
description = "A small task tracker"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=7", "mypy", "build"]

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

[tool.setuptools.packages.find]
where = ["src"]

[tool.pytest.ini_options]
testpaths = ["tests"]

[tool.mypy]
python_version = "3.10"
check_untyped_defs = true

A few lines deserve an explanation:

  • requires-python stops pip from installing the package on an interpreter that cannot run it.
  • dependencies lists what the package needs at runtime. This example needs nothing beyond the standard library.
  • [project.optional-dependencies] defines named extras. The dev group holds tools that only developers need.
  • [project.scripts] creates a taskapi command that calls the main function in taskapi/cli.py.
  • [tool.setuptools.packages.find] tells setuptools to look for packages under src.

The [tool.mypy] table configures a static type checker. You can read more about type hints and mypy configuration separately.

The myth: you still need a full setup.py

Most tutorials still begin with a setup.py that calls setup(name=..., version=..., install_requires=...). That file is executable Python, so tools must run it just to learn the project’s name. Since setuptools 61, released in March of this year, the same metadata goes in the declarative [project] table shown above.

However, one gap remains today, and guides that say “delete setup.py” skip it. An editable install is a development install that points at your source folder, so code changes take effect without reinstalling. With a pyproject-only setuptools project, pip currently refuses:

ERROR: Project file:///home/dev/taskapi has a 'pyproject.toml' and its build backend is
missing the 'build_editable' hook. Since it does not have a 'setup.py' nor a 'setup.cfg',
it cannot be installed in editable mode. Consider using a build backend that supports PEP 660.

The fix is a two-line shim. Keep every piece of metadata in pyproject.toml, and add this setup.py only to enable editable mode:

from setuptools import setup

setup()

Therefore, the accurate statement is this: setup.py as a configuration file is obsolete, and setup.py as an empty shim is still useful with setuptools. Backends such as Hatchling and Flit already support editable installs through PEP 660 and need no shim. Note also that the setuptools documentation still labels the [tool.setuptools] table as beta, so its details may change.

Add the package code

Create the five files under src/taskapi/. Each has one job.

src/taskapi/__init__.py:

"""A small task tracker."""

__version__ = '0.1.0'

src/taskapi/models.py holds the data as a frozen dataclass, which is a class whose fields cannot change after creation:

from dataclasses import dataclass


@dataclass(frozen=True)
class Task:
    id: int
    title: str
    done: bool = False

src/taskapi/service.py:

from taskapi.models import Task


class TaskService:
    def __init__(self) -> None:
        self._tasks: dict[int, Task] = {}
        self._next_id = 1

    def add(self, title: str) -> Task:
        if not title.strip():
            raise ValueError('title must not be empty')
        task = Task(id=self._next_id, title=title.strip())
        self._tasks[task.id] = task
        self._next_id += 1
        return task

    def all_tasks(self) -> list[Task]:
        return list(self._tasks.values())

src/taskapi/cli.py:

import argparse

from taskapi.service import TaskService


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(prog='taskapi', description='Track tasks.')
    parser.add_argument('titles', nargs='+', help='titles of the tasks to add')
    return parser


def main(argv: list[str] | None = None) -> int:
    args = build_parser().parse_args(argv)
    service = TaskService()
    for title in args.titles:
        task = service.add(title)
        print(f'added #{task.id}: {task.title}')
    return 0

src/taskapi/__main__.py:

from taskapi.cli import main

if __name__ == '__main__':
    raise SystemExit(main())

Notice the imports. Every module uses the absolute form from taskapi.service import ..., never a path based on the current directory. Absolute imports work the same in tests, in the command-line script, and after installation.

The main function accepts an optional argv list. That small choice lets a test call main(['Write docs']) directly, without starting a subprocess.

Install in editable mode and run it

Create the environment, activate it, and install the project with its development extras.

python --version
python -m venv .venv
source .venv/bin/activate          # macOS and Linux
.venv\Scripts\Activate.ps1         # Windows PowerShell
python -m pip install --upgrade pip
python -m pip install -e ".[dev]"

The -e flag requests the editable install, and .[dev] means “this directory, plus the dev extras”. Now both entry points work:

taskapi "Write docs" "Ship release"
added #1: Write docs
added #2: Ship release
python -m taskapi "Fix login"
added #1: Fix login

Verify which copy of the package Python imports. The path must point into your src folder:

python -c "import taskapi; print(taskapi.__file__)"
/home/dev/taskapi/src/taskapi/__init__.py

Add tests outside the package

Tests import the package by name, exactly as a user would. Save this as tests/test_service.py.

import pytest

from taskapi.service import TaskService


def test_add_assigns_incrementing_ids():
    service = TaskService()
    first = service.add('Write docs')
    second = service.add('Ship release')
    assert (first.id, second.id) == (1, 2)


def test_add_rejects_empty_title():
    with pytest.raises(ValueError):
        TaskService().add('   ')
python -m pytest
collected 2 items

tests/test_service.py ..                                                 [100%]

2 passed in 0.01s

Because testpaths is set in pyproject.toml, pytest finds the tests without arguments. Because the package is installed, the tests pass from any directory, not just the root.

Build a distributable package

To share the project, build it. The build tool reads [build-system], creates an isolated environment, and produces two files.

python -m build
Successfully built taskapi-0.1.0.tar.gz and taskapi-0.1.0-py3-none-any.whl

The .tar.gz file is a source distribution, and the .whl file is a wheel, a ready-to-install archive. Both land in a dist/ folder, which you should add to .gitignore along with .venv/, __pycache__/, and *.egg-info/.

Here is the check I recommend, and it is the real test of a project structure. Install the wheel into a brand-new environment in a different directory, then run the command:

cd /tmp
python -m venv check-env
source check-env/bin/activate
python -m pip install /home/dev/taskapi/dist/taskapi-0.1.0-py3-none-any.whl
taskapi "It works"

If that prints added #1: It works, your packaging is correct. If it fails, your users would have seen the same failure. This two-minute check catches missing modules and missing data files that every other test on your machine will miss.

Build backends compared

Setuptools is one of several backends. All of them read pyproject.toml, and the differences lie in what else they manage.

Tool Metadata location Also manages Choose it when
setuptools 61+ [project] (PEP 621) Nothing else. C extensions are supported You want the most widely used backend, or you compile extensions
Flit [project] (PEP 621) Publishing to PyPI You ship a simple pure-Python package
Hatch [project] (PEP 621) Environments, versioning, publishing You want one modern tool for the whole workflow
Poetry [tool.poetry] (its own format) Dependency resolution, lock file, environments, publishing You want a lock file and one command for everything

Because three of the four use the standard [project] table, switching between them later mostly means changing [build-system]. Poetry keeps metadata in its own table, so moving to or from it takes more editing.

How real systems structure Python projects

  • One installable package per repository root. CI runs python -m pip install -e ".[dev]" and then the tests. The same two commands work for a new teammate.
  • A thin entry point. cli.py parses arguments and calls functions that live elsewhere. Logic stays testable without a terminal.
  • Layers by responsibility. Models hold data, services hold rules, and outer modules handle I/O. Dependencies point inward: services import models, and never the reverse.
  • Libraries declare ranges, applications pin. A library lists requests>=2.27,<3 in dependencies so it can coexist with other packages. A deployed application also keeps a fully pinned requirements file for repeatable builds.
  • All tool settings in pyproject.toml. pytest, mypy, and Black read their tables from the same file, so a new contributor finds every rule in one place.

We once hit a bug when a release of an internal library installed cleanly and then failed on import in every service that used it. A new subpackage had no __init__.py, so the build left it out of the wheel. Our tests passed, because the flat layout let them import the folder straight from the repository. After that release we moved the code under src/, and the same mistake now fails the first test run.

Choosing a layout: a decision framework

  1. Is it a single file that uses only the standard library? Keep it as one script. A package would be overhead.
  2. Will anyone import it, test it, or install it? Use the src layout with pyproject.toml.
  3. Does it need a command-line command? Add [project.scripts] and a main() function that returns an exit code.
  4. Is it a deployed application? Keep ranges in dependencies, and add a pinned requirements file or a lock file for deployments.
  5. Do you compile C extensions or need a lock file? Pick setuptools for extensions, and Poetry or pip-tools for locking.

When NOT to use this layout

  • One-off scripts and notebooks. A data-cleaning script you run twice does not need metadata, a build backend, or a src directory.
  • Code governed by a framework’s conventions. A Django project generated by django-admin startproject has its own expected structure. Follow the framework first.
  • Large monorepos with a different build system. Repositories built with tools such as Bazel or Pants define targets their own way. Adding per-package pyproject.toml files there can conflict with the build.

Common mistakes

  • Editing sys.path to make imports work. Lines such as sys.path.insert(0, '..') depend on the current directory. They break when the code runs from anywhere else.
  • Naming a module after a standard library module. A local file called random.py or logging.py shadows the real one. Unrelated imports then fail with confusing AttributeError messages.
  • Forgetting __init__.py in a subpackage. Automatic package discovery skips the folder, and the built wheel lacks those modules.
  • Putting tests inside the package. Test files and fixtures then ship to every user and inflate the install.
  • Listing development tools in dependencies. Users of your package get pytest and mypy installed in production. Use an optional dev group.
  • Pinning exact versions in a library. A library that requires requests==2.27.1 conflicts with any other package that needs a different release.

Key takeaways

  • Put importable code in src/yourpackage/ and tests in tests/.
  • Declare metadata in the [project] table of pyproject.toml.
  • With setuptools 61 to 63, keep a two-line setup.py shim so editable installs work.
  • Install your own project with python -m pip install -e ".[dev]".
  • Use absolute imports, and never modify sys.path.
  • Expose commands through [project.scripts] and a main() that returns an exit code.
  • Before a release, install the built wheel in a fresh environment and run it.

FAQ

What is the best project structure for Python?

For most projects, use the src layout: package code in src/yourpackage/, tests in tests/, and a pyproject.toml at the root. Install the project in editable mode during development.

What is pyproject.toml used for?

It is the standard configuration file for Python projects. It names the build backend, holds project metadata such as name, version, and dependencies, and stores settings for tools such as pytest and mypy.

Do I still need setup.py?

Not for metadata. Setuptools 61 and later read it from pyproject.toml. A minimal setup.py that only calls setup() is still needed for editable installs with current setuptools releases.

What is the difference between the src layout and the flat layout?

The flat layout keeps the package folder in the repository root, so Python can import it without installation. The src layout places it under src/, which forces an install and ensures tests run against the installed package.

What does pip install -e do?

It performs an editable install. Instead of copying your code into the environment, pip links to your source folder, so edits take effect immediately without reinstalling.

If it installs cleanly elsewhere, the structure is right

A project layout has one job: make your code importable the same way everywhere. The src directory, a single pyproject.toml, and an editable install achieve that with a handful of files. Everything else is naming.

Rule of thumb: never trust an import that works only from the folder you happen to be standing in.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *