5.16 Python Backend Best Practices
Table of Contents
Writing Maintainable Python Backend Code
This chapter focuses on practical habits and patterns that help you write Python backends that are readable, testable, and ready for production.
You have already seen core Python features and how to build web backends. Here you will connect that knowledge into concrete best practices.
Project and Code Organization
A clear structure makes it easy to find things and to grow your codebase safely.
Prefer a Package Structure
Even for small APIs, structure your project as a package:
myapp/
pyproject.toml # or setup.cfg / requirements.txt
README.md
.env.example
myapp/
__init__.py
config.py
main.py # app entry point (FastAPI / Flask / etc.)
api/
__init__.py
v1/
__init__.py
users.py
items.py
core/
security.py
logging.py
models/
__init__.py
user.py
item.py
db/
__init__.py
session.py
migrations/
tests/
__init__.py
test_users.py
test_items.pyKey ideas:
- Group by responsibility:
api,core,models,db. - Keep files small. A file with thousands of lines is a smell.
- Make your package importable:
myapp.main,myapp.api.users, etc.
Separate Layers
Do not mix HTTP logic, business logic, and database logic in one function.
A common layering:
- API layer: request parsing, HTTP status codes, response models.
- Service layer: business rules, validations, workflows.
- Repository / data access layer: queries, ORM operations.
Example structure:
myapp/
api/
users.py # FastAPI routes
services/
users.py # business logic
repositories/
users.py # DB operationsThis separation keeps each part small, testable, and replaceable.
Code Style and Formatting
Consistent style helps teams read and maintain code.
Use a Formatter
Use a formatter like Black to format Python code automatically.
pip install black
black myappBlack enforces things like:
- 4 spaces indentation.
- Reasonable line length.
- Consistent string quotes.
You focus on logic, not spacing.
Lint Your Code
Use linters to catch problems early:
flake8orrufffor style and simple errors.mypyfor type checking (see below).
pip install ruff mypy
ruff myapp
mypy myappExample of a linter catch:
# Bug: unused variable, shadowed name, etc.
def find_user(id, db):
id = int(id)
return db.get_user()
A linter will point to the suspicious id = int(id) and unused id.
Type Hints and Static Checking
Type hints make your code safer and easier to understand.
Add Type Hints to Functions
from typing import Optional, List
from dataclasses import dataclass
@dataclass
class User:
id: int
email: str
is_active: bool = True
def get_user_by_id(user_id: int) -> Optional[User]:
...Use them for:
- Parameters:
user_id: int - Returns:
-> Optional[User] - Collections:
List[int],dict[str, str](Python 3.9+).
Use mypy or Pyright
Type check your project:
mypy myappExample issue:
def send_email(user_email: str) -> None:
...
user_id: int = 123
send_email(user_id) # mypy: Argument 1 has incompatible type "int"; expected "str"You catch bugs before running the code.
Rule: In backend code, add type hints to all public functions and methods and run a type checker regularly.
Configuration and Environment
Never hardcode secrets or environment-specific values in code.
Use Environment Variables
Typical config values:
- Database URL
- Secret keys
- Debug flag
- External API URLs
Use pydantic or similar to load them once:
from pydantic import BaseSettings
class Settings(BaseSettings):
debug: bool = False
database_url: str
secret_key: str
class Config:
env_file = ".env"
settings = Settings()Then use:
from myapp.config import settings
engine = create_engine(settings.database_url)Do Not Commit Secrets
Maintain an .env.example:
DEBUG=false
DATABASE_URL=postgresql+psycopg2://user:pass@localhost:5432/mydb
SECRET_KEY=your-secret-key-here
And keep real .env files out of version control with .gitignore.
Rule: Never commit real passwords, secret keys, tokens, or private keys to Git.
Dependency Management
Keep dependencies under control and reproducible.
Use a Requirements or pyproject File
Two common approaches:
requirements.txt:
pip freeze > requirements.txt
pip install -r requirements.txtpyproject.tomlwith tools like Poetry or Hatch:
[project]
name = "myapp"
version = "0.1.0"
dependencies = [
"fastapi",
"uvicorn[standard]",
"sqlalchemy",
"psycopg2-binary",
]Pin Versions
For production apps, pin versions:
fastapi==0.115.0
uvicorn[standard]==0.30.0
sqlalchemy==2.0.32
psycopg2-binary==2.9.9This avoids surprises when libraries change.
Error Handling and Exceptions
Backends must fail in controlled, predictable ways.
Raise Domain-Specific Exceptions
Instead of using generic Exception, define your own:
class UserNotFoundError(Exception):
pass
class EmailAlreadyUsedError(Exception):
passService layer:
from .exceptions import UserNotFoundError, EmailAlreadyUsedError
def register_user(email: str, password: str) -> User:
if user_repo.exists(email=email):
raise EmailAlreadyUsedError(f"Email {email} already used")
...Then map these to HTTP errors in the API layer.
Never Hide Errors Silently
Avoid:
try:
do_something()
except Exception:
passBetter:
import logging
logger = logging.getLogger(__name__)
try:
do_something()
except SpecificError as exc:
logger.exception("Failed to do something")
raiseYou can handle known errors, but log them clearly.
Logging
Logging is your main window into a running backend.
Use the Standard Logging Module
Configure logging once, for example in myapp/core/logging.py:
import logging
import sys
def configure_logging() -> None:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
],
)
Call configure_logging() in your entry point:
from myapp.core.logging import configure_logging
def create_app():
configure_logging()
...Use Log Levels Properly
| Level | Use for |
|---|---|
| DEBUG | Detailed info for debugging, not in prod by default |
| INFO | High-level application events |
| WARNING | Something unexpected but continuing |
| ERROR | A failure that affects a request or workflow |
| CRITICAL | Serious errors, application may be unusable |
Example:
logger = logging.getLogger(__name__)
def create_order(...):
logger.info("Creating order for user_id=%s", user_id)
try:
...
except PaymentError as exc:
logger.error("Payment failed for user_id=%s: %s", user_id, exc)
raiseDatabase Access and ORM Best Practices
Databases are often the bottleneck and failure point.
Use a Session per Request
Do not share a single global session for all requests.
With SQLAlchemy:
from sqlalchemy.orm import sessionmaker
SessionLocal = sessionmaker(bind=engine, autocommit=False, autoflush=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()In FastAPI:
@app.get("/users/{user_id}")
def read_user(user_id: int, db: Session = Depends(get_db)):
return user_repo.get(db, user_id)Keep Queries Close to the Repository
In repositories/users.py:
from sqlalchemy.orm import Session
from myapp.models.user import User
def get(db: Session, user_id: int) -> User | None:
return db.query(User).filter(User.id == user_id).first()
def get_by_email(db: Session, email: str) -> User | None:
return db.query(User).filter(User.email == email).first()Service code should rarely write raw queries itself, and API code should never talk to the database directly.
Testing and Testability
A backend without tests is fragile.
Write Tests from the Start
Examples:
- Unit tests for services.
- Integration tests for endpoints.
- Database tests for repository functions.
Simple service test:
def test_register_user_creates_user(user_service, fake_db):
user = user_service.register_user("test@example.com", "password123")
assert user.email == "test@example.com"Prefer Injected Dependencies
Instead of:
# Hard to test
def send_welcome_email(user: User) -> None:
smtp = SmtpClient("smtp.example.com", 587)
smtp.send(...)Use dependency injection:
class EmailSender:
def send_welcome_email(self, user: User) -> None:
...
def create_user(email: str, password: str, mailer: EmailSender) -> User:
user = user_repo.create(email=email, password=password)
mailer.send_welcome_email(user)
return user
In tests, pass a fake EmailSender that records calls.
Rule: Design functions so that external services are passed in as parameters. This makes code testable and reusable.
Performance and Asynchronous Code
Backend performance often depends on I/O, such as database calls or HTTP requests.
Use Async When Appropriate
In async frameworks like FastAPI, define handlers as async def when:
- They await database calls using an async driver.
- They call other async services or APIs.
Example:
@app.get("/items/{item_id}")
async def read_item(item_id: int):
item = await item_service.get_item(item_id)
return itemKeep in mind:
- Do not use
time.sleepin async code, useawait asyncio.sleep(...). - Long CPU tasks block the event loop and should be run in a thread or worker.
Avoid Premature Optimization
Focus on:
- Clear, simple code.
- Correctness and tests.
- Measure performance with profiling or load tests.
- Then optimize real bottlenecks.
Security-Oriented Habits
Security should be part of your everyday coding.
Validate Inputs
For APIs:
- Use request models with validation (for example, Pydantic models in FastAPI).
- Do not trust client input. Always validate type, length, and allowed values.
Example:
from pydantic import BaseModel, EmailStr, constr
class UserCreate(BaseModel):
email: EmailStr
password: constr(min_length=8, max_length=128)Least Privilege Principle
- Limit database user permissions.
- Limit API keys to minimal scopes.
- Restrict admin endpoints.
Rule: Assume all external input can be malicious. Always validate, sanitize, and authorize.
API Design and Error Responses
Design your REST API to be predictable.
Use Consistent URL Patterns
Examples:
/api/v1/users/api/v1/users/{user_id}/api/v1/orders/{order_id}/items
Keep nouns plural, avoid verbs in paths when possible.
Standardize Error Responses
Return structured errors:
{
"detail": "User not found",
"code": "user_not_found"
}For validation errors, use a predictable schema, for example:
{
"detail": [
{
"loc": ["body", "email"],
"msg": "value is not a valid email address",
"type": "value_error.email"
}
]
}This helps frontend and other clients handle errors reliably.
Documentation and Comments
Good documentation lowers the cost of onboarding and maintenance.
Write Docstrings for Public Functions
Example:
def create_user(email: str, password: str) -> User:
"""
Create a new user with the given email and password.
Raises:
EmailAlreadyUsedError: If a user with the same email already exists.
"""
...Keep Comments Focused
- Explain why, not what.
- Remove outdated comments.
Bad:
# Increase x by 1
x = x + 1Good:
# We add 1 to include the current page in the total count
x = x + 1Putting It All Together: Example Flow
A typical request path in a well-structured Python backend:
- Router receives HTTP request:
- Parses path parameters, query, and body.
- Validates request model.
- Service function:
- Applies business rules.
- Calls repositories for DB operations.
- Uses injected dependencies such as mailers or external APIs.
- Raises domain-specific exceptions on errors.
- Repository:
- Executes ORM or SQL queries.
- Maps rows to domain objects or models.
- Exception handlers:
- Translate domain exceptions into HTTP responses.
- Response model:
- Serializes data into JSON with correct schema.
Each step is small, testable, and uses:
- Type hints.
- Structured logging.
- Clear error handling.
- No hardcoded secrets.
- Appropriate configuration.
Checklist of Python Backend Best Practices
Use this as a quick reference when building backends in Python.
Backend Best Practices Checklist
- Project structure:
- Organized by responsibility, not huge files.
- Separate API, service, and repository layers.
- Code quality:
- Automatic formatter (Black).
- Linters (Ruff, Flake8) and type checker (mypy).
- Types:
- Type hints on public functions and models.
- Regular static type checking.
- Configuration:
- Environment variables for secrets and environment-specific values.
- No secrets in Git,
.env.examplecommitted. - Dependencies:
- Requirements or
pyproject.tomlwith pinned versions for production. - Error handling:
- Domain-specific exceptions, not bare
Exception. - No silent exception swallowing, always log or handle.
- Logging:
- Central logging configuration.
- Correct use of log levels.
- Persistence:
- One DB session per request, properly closed.
- Repositories for all DB access.
- Testing:
- Unit tests and integration tests.
- Dependencies injected, not hardcoded, for easy mocking.
- Performance:
- Async interfaces where useful, avoid blocking operations in async code.
- Measure before optimization.
- Security:
- Input validation on all external data.
- Least privilege for secrets and permissions.
- API:
- Consistent URL patterns.
- Structured, predictable error responses.
- Documentation:
- Docstrings and concise comments explaining why.
Following these practices will help you build Python backends that are robust, maintainable, and production ready.
Views: 8
KAHIBARO