8.11 FastAPI Project Structure
Table of Contents
Why Project Structure Matters
When a FastAPI project is small, you can fit everything into a single main.py file. As soon as you add more routes, database access, authentication, background tasks, and configuration, that single file becomes hard to read, test, and extend.
A good project structure:
- Keeps related code together.
- Separates concerns such as API, database, configuration, and business logic.
- Makes testing and refactoring easier.
- Makes it easier for new developers to understand the project.
Important rule: As your project grows, never keep all code in one file. Split your FastAPI app into modules by responsibility: API, models, schemas, services, configuration, etc.
In this chapter you will see common patterns for structuring FastAPI projects, from simple to more modular layouts.
A Minimal Single‑File FastAPI App
You may start like this:
project/
main.py# main.py
from fastapi import FastAPI
app = FastAPI()
items = []
@app.get("/items")
def list_items():
return items
@app.post("/items")
def create_item(name: str):
item = {"id": len(items) + 1, "name": name}
items.append(item)
return itemThis is fine for a demo or a very small toy project, but it has several problems when it grows:
- No place for database code.
- No place for Pydantic models.
- No place for configuration.
- Tests cannot easily import and reuse components.
The rest of this chapter shows how to go beyond this.
A Basic Multi‑File Structure
A small but organized project might look like this:
project/
main.py
api.py
schemas.py
models.py
database.py
config.pyEach file has a clear role:
| File | Responsibility |
|---|---|
main.py | Create FastAPI app, include routers, app startup |
api.py | Route definitions (path operations) |
schemas.py | Pydantic models for request and response validation |
models.py | ORM models (for example SQLAlchemy) |
database.py | Database engine, sessions, connection handling |
config.py | Application configuration |
Even this simple split is a big improvement.
Example:
# config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "My API"
debug: bool = True
database_url: str = "sqlite:///./test.db"
class Config:
env_file = ".env"
settings = Settings()# database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from .config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)# schemas.py
from pydantic import BaseModel
class ItemBase(BaseModel):
name: str
class ItemCreate(ItemBase):
pass
class ItemRead(ItemBase):
id: int
class Config:
from_attributes = True# api.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from . import schemas, models
from .database import SessionLocal
router = APIRouter(prefix="/items", tags=["items"])
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@router.get("/", response_model=list[schemas.ItemRead])
def list_items(db: Session = Depends(get_db)):
return db.query(models.Item).all()
@router.post("/", response_model=schemas.ItemRead)
def create_item(item: schemas.ItemCreate, db: Session = Depends(get_db)):
db_item = models.Item(name=item.name)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item# main.py
from fastapi import FastAPI
from .api import router as items_router
from .config import settings
app = FastAPI(title=settings.app_name)
app.include_router(items_router)Notice how:
main.pyis very small.- API logic is in
api.py. - Data shapes are in
schemas.py. - Database code is in
database.py. - Configuration is in
config.py.
A Package‑Based Structure
Once you have more than one API module, it is better to turn your project into a package with subpackages.
A common layout:
project/
app/
__init__.py
main.py
api/
__init__.py
v1/
__init__.py
items.py
users.py
core/
__init__.py
config.py
security.py
db/
__init__.py
base.py
session.py
models/
__init__.py
item.py
user.py
schemas/
__init__.py
item.py
user.py
services/
__init__.py
item_service.py
user_service.py
tests/
__init__.py
test_items.py
test_users.py
pyproject.toml # or setup.cfg / requirements.txtNow, instead of a flat set of files, you have packages:
app.apicontains your API routes.app.modelscontains ORM models.app.schemascontains Pydantic models.app.dbcontains database base class and session.app.corecontains configuration and shared core logic.app.servicescontains business logic.
How `main.py` Looks in a Package
Example:
# app/main.py
from fastapi import FastAPI
from app.api.v1.items import router as items_router
from app.api.v1.users import router as users_router
from app.core.config import settings
app = FastAPI(
title=settings.app_name,
version="1.0.0",
)
app.include_router(items_router)
app.include_router(users_router)Run it with:
uvicorn app.main:app --reloadThe import paths reflect the package structure.
Splitting Routers by Feature
FastAPI encourages using APIRouter. Each feature or resource usually gets its own router module.
Example items router:
# app/api/v1/items.py
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.item import ItemCreate, ItemRead
from app.services.item_service import (
create_item,
list_items,
get_item_or_404,
)
router = APIRouter(
prefix="/items",
tags=["items"],
)
@router.get("/", response_model=list[ItemRead])
def read_items(db: Session = Depends(get_db)):
return list_items(db)
@router.post(
"/",
response_model=ItemRead,
status_code=status.HTTP_201_CREATED,
)
def create_item_endpoint(
item_in: ItemCreate,
db: Session = Depends(get_db),
):
return create_item(db, item_in)
@router.get("/{item_id}", response_model=ItemRead)
def read_item(item_id: int, db: Session = Depends(get_db)):
return get_item_or_404(db, item_id)Users router:
# app/api/v1/users.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.schemas.user import UserCreate, UserRead
from app.services.user_service import (
create_user,
list_users,
)
router = APIRouter(prefix="/users", tags=["users"])
@router.get("/", response_model=list[UserRead])
def read_users(db: Session = Depends(get_db)):
return list_users(db)
@router.post("/", response_model=UserRead)
def create_user_endpoint(
user_in: UserCreate,
db: Session = Depends(get_db),
):
return create_user(db, user_in)
Key pattern: each router module handles only routing and HTTP concerns, while delegating logic to services.
Organizing Schemas (Pydantic Models)
Schemas often live in app/schemas. You can:
- Put all schemas in a single
schemas.pyfile for small projects. - Split by feature:
schemas/item.py,schemas/user.py, etc.
Example:
# app/schemas/item.py
from pydantic import BaseModel
class ItemBase(BaseModel):
name: str
description: str | None = None
class ItemCreate(ItemBase):
pass
class ItemUpdate(BaseModel):
name: str | None = None
description: str | None = None
class ItemRead(ItemBase):
id: int
class Config:
from_attributes = TrueFor users:
# app/schemas/user.py
from pydantic import BaseModel, EmailStr
class UserBase(BaseModel):
email: EmailStr
class UserCreate(UserBase):
password: str
class UserRead(UserBase):
id: int
is_active: bool
class Config:
from_attributes = TrueImportant rule: Do not reuse the same schema for input and output if the fields differ. Keep create, update, and read schemas separate to avoid exposing sensitive fields such as passwords.
Organizing Models (ORM)
Your ORM models usually live in app/models. Example with SQLAlchemy:
# app/db/base.py
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass# app/models/item.py
from sqlalchemy import Column, Integer, String, Text
from app.db.base import Base
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False, index=True)
description = Column(Text, nullable=True)# app/models/user.py
from sqlalchemy import Boolean, Column, Integer, String
from app.db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
is_active = Column(Boolean, default=True)Optionally a convenience import:
# app/models/__init__.py
from .item import Item
from .user import User
This lets migrations or other tools import app.models and get all models.
Database Session and Dependencies
Put database session handling into app/db/session.py:
# app/db/session.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.core.config import settings
engine = create_engine(
settings.database_url,
pool_pre_ping=True,
)
SessionLocal = sessionmaker(
autocommit=False,
autoflush=False,
bind=engine,
)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Then use get_db as a dependency in your routers, as shown earlier.
This keeps all database connection details in one place.
Configuration and Settings
Configuration typically goes into app/core/config.py. For example:
# app/core/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "My FastAPI App"
environment: str = "development"
debug: bool = True
database_url: str = "sqlite:///./db.sqlite3"
secret_key: str = "change-this-in-production"
class Config:
env_file = ".env"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()Now:
settingsis imported from anywhere:from app.core.config import settings.- Values can come from environment variables or a
.envfile. - All configuration is centralized.
Important rule: Never hardcode real secrets (tokens, passwords) in code. Always read secrets from environment variables or a secure secret manager and centralize access in config.py.
Services and Business Logic
The services package holds business logic that should not depend on HTTP details.
Example item_service:
# app/services/item_service.py
from sqlalchemy.orm import Session
from app import models, schemas
def list_items(db: Session) -> list[models.Item]:
return db.query(models.Item).all()
def create_item(db: Session, item_in: schemas.ItemCreate) -> models.Item:
db_item = models.Item(
name=item_in.name,
description=item_in.description,
)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
def get_item_or_404(db: Session, item_id: int) -> models.Item:
item = db.query(models.Item).get(item_id)
if item is None:
# Do not raise HTTPException here, keep this layer HTTP free
raise ValueError("Item not found")
return item
Then in your router, convert ValueError to an HTTP error:
# app/api/v1/items.py
from fastapi import HTTPException, status
from app.services.item_service import get_item_or_404
@router.get("/{item_id}", response_model=ItemRead)
def read_item(item_id: int, db: Session = Depends(get_db)):
try:
return get_item_or_404(db, item_id)
except ValueError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Item not found",
)You do not have to separate HTTP logic from business logic, but it often makes testing and reuse easier, especially for larger applications.
Common Folder Layouts Compared
Here are two typical layouts, one simple and one more modular.
Simple layout (good for small APIs)
app/
__init__.py
main.py
api.py
models.py
schemas.py
database.py
config.pyModular layout (better for growing apps)
app/
__init__.py
main.py
api/
__init__.py
v1/
__init__.py
items.py
users.py
core/
__init__.py
config.py
security.py
db/
__init__.py
base.py
session.py
models/
__init__.py
item.py
user.py
schemas/
__init__.py
item.py
user.py
services/
__init__.py
item_service.py
user_service.py
tests/
__init__.py
test_items.py
test_users.pyYou can adjust names and structure, but the main ideas are:
- Group by feature or layer.
- Keep API, models, schemas, database, config, and services separate.
Example: Adding Middleware and Dependencies
Where do middleware and common dependencies go?
- Middleware: usually defined and added in
app/main.pyor inapp/core/middleware.py. - Dependencies: often in
app/api/deps.pyor separated by feature.
Example:
# app/core/middleware.py
from fastapi import FastAPI
from starlette.middleware.cors import CORSMiddleware
def add_cors(app: FastAPI) -> None:
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)# app/main.py
from fastapi import FastAPI
from app.api.v1.items import router as items_router
from app.core.config import settings
from app.core.middleware import add_cors
app = FastAPI(title=settings.app_name)
add_cors(app)
app.include_router(items_router)
This keeps main.py small and readable.
Testing and Import Paths
With a package structure, tests can import things easily.
Example test:
# tests/test_items.py
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_create_item():
response = client.post("/items/", json={"name": "Book", "description": "A nice book"})
assert response.status_code == 201
data = response.json()
assert data["name"] == "Book"
assert "id" in data
Because app is defined in app/main.py, from app.main import app works with the project as a package.
Make sure your working directory or Python path includes the project root so that import app is valid.
Evolving Your Structure
You do not need to start with a complex structure. A good approach:
- Start with a simple layout:
main.py,api.py,schemas.py,database.py. - When you add a new feature (for example users), consider splitting into
api/items.pyandapi/users.py. - When the root folder becomes crowded, turn it into nested packages:
app/api/v1/items.py, etc. - Add
core,db,models,schemas, andservicesas you see repeated patterns.
Key principle: Refactor your project structure incrementally. As soon as a file becomes too long or mixes multiple concerns, split it into modules or packages, keeping each file focused on one responsibility.
This way your FastAPI project remains readable, testable, and maintainable as it grows.
Views: 7
KAHIBARO