KAHIBARO
Discord Login Register

8.11 FastAPI Project Structure

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:

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:

bash
project/
    main.py
python
# 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 item

This is fine for a demo or a very small toy project, but it has several problems when it grows:

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:

bash
project/
    main.py
    api.py
    schemas.py
    models.py
    database.py
    config.py

Each file has a clear role:

FileResponsibility
main.pyCreate FastAPI app, include routers, app startup
api.pyRoute definitions (path operations)
schemas.pyPydantic models for request and response validation
models.pyORM models (for example SQLAlchemy)
database.pyDatabase engine, sessions, connection handling
config.pyApplication configuration

Even this simple split is a big improvement.

Example:

python
# 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()
python
# 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)
python
# 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
python
# 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
python
# 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:

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:

bash
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.txt

Now, instead of a flat set of files, you have packages:

How `main.py` Looks in a Package

Example:

python
# 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:

bash
uvicorn app.main:app --reload

The 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:

python
# 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:

python
# 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:

Example:

python
# 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 = True

For users:

python
# 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 = True

Important 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:

python
# app/db/base.py
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
    pass
python
# 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)
python
# 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:

python
# 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:

python
# 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:

python
# 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:

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:

python
# 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:

python
# 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)

bash
app/
    __init__.py
    main.py
    api.py
    models.py
    schemas.py
    database.py
    config.py

Modular layout (better for growing apps)

bash
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

You can adjust names and structure, but the main ideas are:

Example: Adding Middleware and Dependencies

Where do middleware and common dependencies go?

Example:

python
# 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=["*"],
    )
python
# 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:

python
# 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:

  1. Start with a simple layout: main.py, api.py, schemas.py, database.py.
  2. When you add a new feature (for example users), consider splitting into api/items.py and api/users.py.
  3. When the root folder becomes crowded, turn it into nested packages: app/api/v1/items.py, etc.
  4. Add core, db, models, schemas, and services as 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

Comments

Please login to add a comment.

Don't have an account? Register now!