AutoSkills
All items
skillv1.0.0

python-fastapi

FastAPI patterns: async, dependencies, Pydantic v2, project layout, error handling

fastapipythonbackendapi

Install

$npx @autoskills-dev/autoskills --items python-fastapi

Or scan + install everything matching your stack with npx @autoskills-dev/autoskills.

FastAPI

FastAPI looks like Flask in five minutes and burns you in five months if you don't internalize the async model and Pydantic v2 semantics.

Project layout

app/
  main.py             # FastAPI() + router include
  routers/
    users.py          # APIRouter() per resource
    posts.py
  schemas/            # Pydantic models (request/response)
    user.py
  models/             # SQLAlchemy / Tortoise / etc.
    user.py
  services/           # business logic — pure functions over models
    user_service.py
  deps.py             # FastAPI dependencies (Depends)
  config.py           # pydantic-settings BaseSettings
  db.py               # session/connection
  • Routers per resource, not per HTTP method.
  • Schemas separate from models. ORM models leak DB shape into your API contract.
  • Services own business logic. Routers should be thin: parse input → call service → return response.

Async — but only where it pays

  • async def for I/O-bound work: HTTP calls, DB calls (with async drivers), file I/O.
  • def is fine for CPU-bound or sync-only libs. FastAPI runs sync routes in a thread pool — you don't lose throughput.
  • Don't mix sync DB in async routes — requests.get() inside async def blocks the event loop. Either use httpx.AsyncClient or move the call to a def route.

Pydantic v2 essentials

from pydantic import BaseModel, Field, EmailStr, field_validator
 
class UserCreate(BaseModel):
    email: EmailStr
    name: str = Field(min_length=1, max_length=100)
    age: int = Field(ge=0, le=150)
 
    @field_validator('name')
    @classmethod
    def name_no_emojis(cls, v: str) -> str:
        if any(ord(c) > 0x1F000 for c in v):
            raise ValueError('emojis not allowed')
        return v
  • Field() for constraints — ge, le, min_length, pattern. No need for custom validators when a constraint suffices.
  • Separate UserCreate / UserRead / UserUpdate — a single User schema means you leak hashed passwords or accept fields users shouldn't set.
  • model_config = ConfigDict(from_attributes=True) to read from ORM objects (was orm_mode in v1).

Dependency injection

from fastapi import Depends
 
async def get_db() -> AsyncSession:
    async with SessionLocal() as session:
        yield session
 
async def get_current_user(
    token: str = Depends(oauth2_scheme),
    db: AsyncSession = Depends(get_db),
) -> User:
    user = await auth_service.user_from_token(token, db)
    if not user:
        raise HTTPException(401)
    return user
 
@router.get('/me')
async def me(user: User = Depends(get_current_user)):
    return user
  • Dependencies compose — get_current_user itself depends on get_db.
  • Use Depends in the signature, not function-body calls. FastAPI can't infer the dependency tree from runtime calls.
  • Sub-dependency caching — Depends(x, use_cache=True) (default) means x runs once per request even if depended on twice.

Error handling

from fastapi import HTTPException, status
 
raise HTTPException(
    status_code=status.HTTP_404_NOT_FOUND,
    detail='User not found',
)

For domain errors, register an exception handler:

class DomainError(Exception):
    def __init__(self, code: str, message: str):
        self.code = code; self.message = message
 
@app.exception_handler(DomainError)
async def domain_error_handler(request, exc: DomainError):
    return JSONResponse(
        status_code=400,
        content={'error': exc.code, 'message': exc.message},
    )

Don't try/except everywhere in routers — let the framework's handler do it.

Configuration with pydantic-settings

from pydantic_settings import BaseSettings
 
class Settings(BaseSettings):
    database_url: str
    redis_url: str = 'redis://localhost'
    debug: bool = False
 
    model_config = {'env_file': '.env'}
 
settings = Settings()
  • Don't read os.environ directly — BaseSettings validates types and gives you a single source.
  • settings = Settings() at module load is fine; FastAPI starts once.

Background tasks vs. queues

  • BackgroundTasks — run after response, in the same process. Fine for: send-email-after-signup, log-event.
  • Celery / Arq / Dramatiq — separate process, durable, retries. Use for: anything where dropping the task hurts.
@router.post('/signup')
async def signup(data: UserCreate, bg: BackgroundTasks):
    user = await user_service.create(data)
    bg.add_task(send_welcome_email, user.email)
    return user

Testing

from fastapi.testclient import TestClient
 
def test_signup(client: TestClient):
    r = client.post('/users', json={'email': 'a@b.com', 'name': 'A', 'age': 30})
    assert r.status_code == 201
    assert r.json()['email'] == 'a@b.com'
  • TestClient for sync test code (most pytest setups).
  • httpx.AsyncClient(app=app) for async tests.
  • Override Depends in tests with app.dependency_overrides[get_db] = lambda: test_session.

What to avoid

  • Putting business logic in routers — they should orchestrate, not compute.
  • Returning ORM objects from routers without a response schema — leaks fields, breaks contracts.
  • async def everywhere reflexively — costs you readability without throughput gains when the work is CPU-bound.
  • exception_handlers that swallow everything — they should narrow on type, not catch Exception.
  • Reading request.headers['Authorization'] directly — use Depends(oauth2_scheme) so OpenAPI docs reflect the auth scheme.