Project structure affects everything: imports, testing, packaging, collaboration. Here's how to do it right.

The Two Layouts

Flat Layout

myproject/
├── myproject/
│   ├── __init__.py
│   └── main.py
├── tests/
│   └── test_main.py
├── pyproject.toml
└── README.md

Simple and common. Works well for most projects.

Src Layout

myproject/
├── src/
│   └── myproject/
│       ├── __init__.py
│       └── main.py
├── tests/
│   └── test_main.py
├── pyproject.toml
└── README.md

Extra src/ directory. Why bother?

The src layout prevents a subtle bug: In flat layout, your local package directory can shadow the installed package. Tests might pass locally but fail after installation.

Use src layout for libraries you'll distribute. Flat layout is fine for applications.

Basic Structure

myproject/
├── src/
│   └── myproject/
│       ├── __init__.py      # Package marker
│       ├── main.py          # Entry point
│       ├── config.py        # Configuration
│       ├── models/          # Data models
│       │   ├── __init__.py
│       │   └── user.py
│       └── utils/           # Helpers
│           ├── __init__.py
│           └── helpers.py
├── tests/
│   ├── __init__.py
│   ├── conftest.py          # pytest fixtures
│   └── test_main.py
├── docs/                    # Documentation
├── scripts/                 # Utility scripts
├── pyproject.toml           # Project config
├── README.md
└── .gitignore

The init.py File

Marks a directory as a Python package. Can be empty or export public API:

# src/myproject/__init__.py
from myproject.main import run
from myproject.config import Config
 
__version__ = "0.1.0"
__all__ = ["run", "Config"]

Now users can:

from myproject import run, Config

pyproject.toml

The modern standard for Python project configuration:

[project]
name = "myproject"
version = "0.1.0"
description = "A useful project"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
    "httpx>=0.24",
    "pydantic>=2.0",
]
 
[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "ruff>=0.1",
]
 
[project.scripts]
myproject = "myproject.main:cli"
 
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
 
[tool.setuptools.packages.find]
where = ["src"]
 
[tool.pytest.ini_options]
testpaths = ["tests"]
 
[tool.ruff]
line-length = 88

One file for everything: dependencies, scripts, tools.

Managing Imports

# src/myproject/models/user.py
from myproject.config import settings
from myproject.utils.helpers import format_name

Always works. Clear where things come from.

Relative Imports

# src/myproject/models/user.py
from ..config import settings
from ..utils.helpers import format_name

Shorter but can be confusing. Use sparingly.

Entry Points

Script Entry Point

In pyproject.toml:

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

The function:

# src/myproject/main.py
def cli():
    """Entry point for CLI."""
    print("Hello from myproject!")
 
if __name__ == "__main__":
    cli()

After pip install, users can run myproject directly.

Module Entry Point

# src/myproject/__main__.py
from myproject.main import cli
 
if __name__ == "__main__":
    cli()

Now python -m myproject works.

Configuration Files

Keep configuration separate:

myproject/
├── src/myproject/
├── config/
│   ├── default.toml
│   └── production.toml
├── .env.example
└── pyproject.toml

Load with environment awareness:

# src/myproject/config.py
import os
from pathlib import Path
 
ENV = os.getenv("ENV", "development")
CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / f"{ENV}.toml"

Testing Structure

tests/
├── __init__.py
├── conftest.py           # Shared fixtures
├── unit/
│   ├── __init__.py
│   └── test_models.py
├── integration/
│   ├── __init__.py
│   └── test_api.py
└── fixtures/
    └── sample_data.json

conftest.py for shared fixtures:

# tests/conftest.py
import pytest
 
@pytest.fixture
def sample_user():
    return {"name": "Owen", "email": "owen@example.com"}

Development Installation

Install in editable mode for development:

pip install -e ".[dev]"

Changes to source are immediately available. No reinstall needed.

What Goes Where

ContentLocation
Source codesrc/myproject/
Teststests/
Documentationdocs/
Scripts/toolsscripts/
Config filesconfig/ or root
Static assetssrc/myproject/data/

Common Mistakes

Circular imports

# Bad - a imports b, b imports a
# models/user.py
from myproject.services import UserService
 
# services/user_service.py  
from myproject.models import User

Fix: Move shared code to a third module, or import inside functions.

Too deep nesting

myproject/core/services/internal/helpers/utils/...

Keep it flat. 2-3 levels max.

No init.py

# Fails without __init__.py
from myproject.models import User

Every directory that's a package needs __init__.py.

My Template

myproject/
├── src/
│   └── myproject/
│       ├── __init__.py
│       ├── __main__.py
│       ├── cli.py
│       └── core.py
├── tests/
│   ├── conftest.py
│   └── test_core.py
├── pyproject.toml
├── README.md
└── .gitignore

Start simple. Add structure as needed.

React to this post: