5.5 Python Project Structure
Table of Contents
Why Project Structure Matters
When you build small scripts, you can put everything in a single file. For real backend services, that quickly becomes painful.
A clear project structure helps you:
- Find code quickly.
- Reuse code instead of copying it.
- Test and debug more easily.
- Work in a team without stepping on each other’s toes.
In this chapter you will see:
- How a simple Python project is laid out.
- How that grows into a typical backend project structure.
- Where to put configuration, tests, and dependencies.
- How to run your code as a package, not just as a loose script.
We will keep examples framework neutral, but everything here works very well with FastAPI and other backend frameworks.
Minimal Python Project Layout
Start with the smallest layout that is still “real”:
my_project/
my_project/
__init__.py
main.py
tests/
test_example.py
README.md
pyproject.toml # or requirements.txt + setup.cfg, etc.This is called a “src layout” or “package layout”. The important parts:
- Outer
my_project/
The root folder. Git repository usually lives here. - Inner
my_project/
A Python package. The name is the same as the project. It contains your actual code. __init__.py
Tells Python that this directory is a package. Can be empty.main.py
Entry point or starting module.tests/
All tests go here.pyproject.tomlorrequirements.txt
List of dependencies and project metadata.
This is better than putting code directly in the root folder, because it avoids import problems and makes packaging easier.
Example: A Tiny App
my_project/main.py:
def greet(name: str) -> str:
return f"Hello, {name}!"
def main() -> None:
message = greet("Backend Developer")
print(message)
if __name__ == "__main__":
main()
tests/test_example.py:
from my_project.main import greet
def test_greet():
assert greet("World") == "Hello, World!"You can run tests with:
pytestThis simple structure already gives you:
- A dedicated place for code.
- A dedicated place for tests.
- Importable modules like
from my_project.main import greet.
Package vs Script: Why You Want a Package
A single script file can be useful:
python my_script.pyBut for backends you need multiple modules, and you need imports to work reliably.
Script Layout
project/
app.py
app.py:
from utils import helper_function # This will often fail
Now if you move utils.py around or run the script from a different directory, imports break.
Package Layout
project/
my_app/
__init__.py
app.py
utils.py
app.py:
from my_app.utils import helper_functionYou can now run things using the module syntax:
python -m my_app.appHere Python knows where the package is and resolves imports correctly.
python -m package_name.module_name
Important rule
Always structure non-trivial backends as a package, not as a single loose script. Put code inside a named directory with an __init__.py file, and run it with:
This avoids many hard-to-debug import issues as your project grows.
Typical Backend Project Structure
Let us grow the minimal layout into something closer to a real backend:
my_backend/
my_backend/
__init__.py
main.py # Application entry point
config.py # App configuration helpers
db.py # Database connection setup
api/
__init__.py
routes.py # Group of API routes
schemas.py # Pydantic or data models for requests/responses
core/
__init__.py
settings.py # Central settings
security.py # Security related helpers
services/
__init__.py
user_service.py
email_service.py
models/
__init__.py
user.py # Database models
order.py
tests/
__init__.py
test_routes.py
test_services.py
requirements.txt or pyproject.toml
README.mdHigh Level Purpose of Folders
| Folder | Purpose |
|---|---|
my_backend/ (inner) | All application code as a package. |
api/ | Web layer, request handlers, route definitions. |
core/ | Core infrastructure, settings, security utilities. |
services/ | Business logic, independent of HTTP and database details. |
models/ | Database models or domain entities. |
tests/ | Automated tests for all components. |
You do not have to use these exact names, but separating “layers” is very helpful.
Grouping Code by Responsibility
There are two common styles:
- Layered by technical responsibility
Example folders:api,services,repositories,models. - Grouped by feature or module
Example folders:users,orders,payments, each containing routes, services, models.
For a beginner, layered structure is easier to start with. Later you can move to feature modules if needed.
Example: Layered Structure
my_backend/
my_backend/
api/
users.py
auth.py
services/
user_service.py
auth_service.py
models/
user.py
db.py
main.pyHere:
api/*knows HTTP (e.g. FastAPI routes).services/*knows business rules, but not HTTP.models/*defines how data is stored or represented.db.pycreates a shared database connection or session.main.pywires it all together.
Example Files
models/user.py:
from dataclasses import dataclass
@dataclass
class User:
id: int
email: str
is_active: bool
services/user_service.py:
from my_backend.models.user import User
class UserService:
def __init__(self, user_repository):
self.user_repository = user_repository
def get_user_by_id(self, user_id: int) -> User | None:
return self.user_repository.find_by_id(user_id)
def deactivate_user(self, user_id: int) -> None:
user = self.user_repository.find_by_id(user_id)
if not user:
return
user.is_active = False
self.user_repository.save(user)
api/users.py (using a fake “framework-like” interface):
from my_backend.services.user_service import UserService
from my_backend.repositories.user_repository import UserRepository
user_service = UserService(user_repository=UserRepository())
def get_user_handler(request):
user_id = int(request.path_params["user_id"])
user = user_service.get_user_by_id(user_id)
if not user:
return {"status": 404, "body": {"detail": "User not found"}}
return {"status": 200, "body": {"id": user.id, "email": user.email}}Even without a specific framework, you can see:
- HTTP-specific logic is in
api/users.py. - Business logic is in
services. - Data structures are in
models.
This separation will matter a lot once your app grows.
Where to Put Configuration
Backends need configuration:
- Database URLs.
- Secret keys.
- Debug flags.
- Third-party API keys.
You should:
- Keep defaults in code.
- Override with environment variables per environment.
A simple pattern is to have a config.py or core/settings.py file.
Example: Settings Module
my_backend/core/settings.py:
import os
from dataclasses import dataclass
@dataclass
class Settings:
debug: bool = False
database_url: str = "sqlite:///./dev.db"
secret_key: str = "CHANGE_ME"
@classmethod
def from_env(cls) -> "Settings":
return cls(
debug=os.getenv("APP_DEBUG", "false").lower() == "true",
database_url=os.getenv("DATABASE_URL", "sqlite:///./dev.db"),
secret_key=os.getenv("SECRET_KEY", "CHANGE_ME"),
)
settings = Settings.from_env()Use it anywhere:
from my_backend.core.settings import settings
def connect_db():
print(f"Connecting to {settings.database_url}")Organizational rules:
- Put configuration logic in one place.
- Import settings where needed, instead of reading environment variables all over your code.
- Do not hardcode secrets in code in real projects.
Entry Points: `main.py` and `__main__`
Your project needs a clear “start here” file.
Common patterns:
Simple CLI style entry
my_backend/main.py:
def main() -> None:
print("Starting backend...")
if __name__ == "__main__":
main()Run:
python -m my_backend.mainFramework entry
For a web backend, you usually have an “app” object.
Example with a fake framework:
# my_backend/main.py
from my_backend.api.routes import app
def run():
# Imagine `app.run()` starts a server
app.run()
If your server process (for example, uvicorn) needs the app, you point it to my_backend.main:app.
uvicorn my_backend.main:app --reloadKey idea:
Important rule
Have a single, small entry point file, such as main.py, where you create the app, load configuration, and start the server. Do not scatter “startup” code all over the project.
Tests and Their Structure
Always keep tests in a separate tests/ folder at the project root:
my_backend/
my_backend/
...
tests/
__init__.py
test_users.py
test_auth.py
test_services/
test_user_service.pyYou can mirror the application structure:
tests/test_users.pytestsapi/users.py.tests/test_services/test_user_service.pytestsservices/user_service.py.
Simple Service Test Example
tests/test_user_service.py:
from my_backend.services.user_service import UserService
from my_backend.models.user import User
class FakeUserRepository:
def __init__(self):
self.users = {
1: User(id=1, email="user@example.com", is_active=True)
}
def find_by_id(self, user_id: int) -> User | None:
return self.users.get(user_id)
def save(self, user: User) -> None:
self.users[user.id] = user
def test_deactivate_user():
repo = FakeUserRepository()
service = UserService(user_repository=repo)
service.deactivate_user(1)
assert repo.users[1].is_active is FalseKeeping tests organized:
- Avoid mixing test files and application files in the same directories.
- Use clear file names like
test_<module>.py.
Handling Dependencies: `requirements.txt` and `pyproject.toml`
Your project should declare which packages it needs. Two common approaches:
1. Using `requirements.txt`
my_backend/
requirements.txtExample content:
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.35
psycopg2-binary==2.9.9Install with:
pip install -r requirements.txt2. Using `pyproject.toml` (modern way)
pyproject.toml:
[project]
name = "my-backend"
version = "0.1.0"
description = "Example backend project"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.115.0",
"uvicorn[standard]==0.30.0",
"sqlalchemy==2.0.35",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
Then you can use tools such as pip, pip-tools, or uv to install from pyproject.toml.
Organizational rules:
- Keep one central place where dependencies are listed.
- Use a virtual environment for each project.
- Pin versions for reproducible builds when your project becomes serious.
Example: Simple Backend Structure from Scratch
Let us outline a tiny but realistic project that could later become a FastAPI app.
todo_backend/
todo_backend/
__init__.py
main.py
core/
__init__.py
settings.py
api/
__init__.py
todos.py
models/
__init__.py
todo.py
services/
__init__.py
todo_service.py
tests/
test_todo_service.py
pyproject.toml
README.mdCore Domain Model
todo_backend/models/todo.py:
from dataclasses import dataclass
from datetime import datetime
@dataclass
class Todo:
id: int
title: str
completed: bool = False
created_at: datetime = datetime.utcnow()Business Logic Service
todo_backend/services/todo_service.py:
from typing import List, Dict
from todo_backend.models.todo import Todo
class InMemoryTodoRepository:
def __init__(self) -> None:
self._todos: Dict[int, Todo] = {}
self._next_id = 1
def list_todos(self) -> List[Todo]:
return list(self._todos.values())
def create_todo(self, title: str) -> Todo:
todo = Todo(id=self._next_id, title=title)
self._todos[self._next_id] = todo
self._next_id += 1
return todo
def mark_done(self, todo_id: int) -> None:
todo = self._todos.get(todo_id)
if todo:
todo.completed = True
class TodoService:
def __init__(self, repository: InMemoryTodoRepository) -> None:
self.repository = repository
def get_all(self) -> List[Todo]:
return self.repository.list_todos()
def add(self, title: str) -> Todo:
return self.repository.create_todo(title)
def complete(self, todo_id: int) -> None:
self.repository.mark_done(todo_id)API Layer (Pseudocode)
todo_backend/api/todos.py:
from todo_backend.services.todo_service import TodoService, InMemoryTodoRepository
repo = InMemoryTodoRepository()
service = TodoService(repository=repo)
def list_todos_handler(request):
todos = service.get_all()
return {
"status": 200,
"body": [{"id": t.id, "title": t.title, "completed": t.completed} for t in todos],
}
def create_todo_handler(request):
data = request.json()
new_todo = service.add(title=data["title"])
return {
"status": 201,
"body": {"id": new_todo.id, "title": new_todo.title, "completed": new_todo.completed},
}You could plug these handlers into any HTTP framework, but the structure of your project would stay almost the same.
Entry Point
todo_backend/main.py:
# Here you would create a real app with a framework.
# For illustration we just show where that would live.
def main() -> None:
print("Todo backend starting...")
if __name__ == "__main__":
main()You can see how each part has a clear place:
- Domain, models:
models/. - Business logic:
services/. - HTTP layer:
api/. - Settings:
core/. - Start script:
main.py.
Practical Guidelines for Structuring Python Backend Projects
To summarize the most important points:
Key rules for Python project structure
- Use a package layout
Put your code insideproject_name/with an__init__.pyfile. - Separate application code and tests
Application package (for examplemy_backend/) andtests/at the root. - Group by responsibility
For example: api/for routes and controllers.services/for business logic.models/for data models.core/orconfig.pyfor settings and core utilities.- Have a clear entry point
Create amain.pyor similar, and run it withpython -m package.module. - Centralize configuration
Read environment variables in a single settings module, and import that everywhere. - Declare dependencies
Userequirements.txtorpyproject.tomlso others can install your project easily.
If you follow these principles from the beginning, your backend projects will be much easier to grow, test, and maintain as you become a more advanced backend developer.
Views: 8
KAHIBARO