When you ask AI to scaffold a FastAPI project, you typically get one of two things: either a 200-line main.py with everything crammed in, or a deeply nested enterprise structure with 15 empty folders. Neither is what you actually need. The right project layout depends on your project's real complexity, and FastAPI gives you clean tools to grow from one to the other.
The single-file starting point
Every FastAPI project starts life as a single file. There is nothing wrong with this for small APIs. Here is what AI typically generates:
# main.py
from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class Item(BaseModel):
name: str
price: float
items = []
@app.get("/items")
def list_items():
return items
@app.post("/items")
def create_item(item: Item):
items.append(item)
return item
@app.get("/health")
def health():
return {"status": "ok"}This is perfectly fine for a prototype, a quick demo, or an APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. with 5-10 endpoints. The problem comes when this file grows. At 200 lines, you are scrolling constantly. At 500 lines, you are losing track of which models belong to which routes. At 1,000 lines, every change risks breaking something unrelated.
main.py if you keep asking. You need to recognize when a single file has outgrown its usefulness and restructure before it becomes painful.APIRouter: the splitting tool
FastAPI's APIRouter is designed for exactly this moment. A router works like a mini FastAPI app, you define endpoints on it, then mount it into the main app. Think of it as a chapter in a book: it has its own topic, its own routes, but it all gets bound together at the end.
# routers/items.py
from fastapi import APIRouter
from schemas.item import ItemCreate, ItemResponse
router = APIRouter()
@router.get("/", response_model=list[ItemResponse])
def list_items():
return get_all_items()
@router.post("/", response_model=ItemResponse)
def create_item(item: ItemCreate):
return save_item(item)
@router.get("/{item_id}", response_model=ItemResponse)
def get_item(item_id: int):
return find_item(item_id)Then in your main app:
# main.py
from fastapi import FastAPI
from routers import items, users, auth
app = FastAPI()
app.include_router(items.router, prefix="/items", tags=["Items"])
app.include_router(users.router, prefix="/users", tags=["Users"])
app.include_router(auth.router, prefix="/auth", tags=["Auth"])Notice what include_router does:
prefix="/items"means every route in that router is prefixed with/items. The@router.get("/")inside becomesGET /items/.tags=["Items"]groups these routes under an "Items" heading in the OpenAPIWhat is openapi?A standard format for describing REST APIs - their endpoints, parameters, and response shapes. Tools can generate documentation and client libraries from it automatically. docs.
This is clean separation. Each file owns one domain. Your main.py becomes a table of contents, not the whole book.
The production layout
When your project grows past a few routers, you need a consistent folder structure. Here is the layout that works for most real FastAPI projects:
project/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app creation + router mounting
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py # Settings and environment variables
│ │ └── security.py # Auth utilities, password hashing
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── items.py
│ │ ├── users.py
│ │ └── auth.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── item.py # SQLAlchemy / ORM models
│ │ └── user.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── item.py # Pydantic schemas (request/response)
│ │ └── user.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── item_service.py # Business logic
│ │ └── user_service.py
│ └── db/
│ ├── __init__.py
│ ├── session.py # Database session setup
│ └── base.py # Base model class
├── migrations/ # Alembic migrations
├── tests/
├── .env
├── .env.example
└── requirements.txtWhy this structure works
| Folder | Responsibility | Changes when... |
|---|---|---|
routers/ | HTTP layer, request parsing, response formatting | You add/change API endpoints |
schemas/ | Pydantic models for validation and serialization | Request/response shapes change |
models/ | Database table definitions (SQLAlchemy) | Your database schema evolves |
services/ | Business logic, orchestration | Core application rules change |
core/ | Cross-cutting: config, security, shared dependencies | Infrastructure or auth changes |
db/ | Database connection and session management | You change databases or connection pooling |
The key insight: each folder changes for a different reason. When you add a new field to an item, you touch schemas/item.py and models/item.py, not the router. When you change how authenticationWhat is authentication?Verifying who a user is, typically through credentials like a password or token. works, you touch core/security.py, not every router file. This is separation of concernsWhat is separation of concerns?Organizing code so each module or layer handles one specific responsibility, making it easier to change one part without breaking others. in practice.
Models vs schemas: the distinction AI gets wrong
One of the most common mistakes in AI-generated FastAPI projects is mixing up SQLAlchemy models and Pydantic schemas. They look similar but serve completely different purposes.
# models/user.py - this talks to the database
from sqlalchemy import Column, Integer, String
from db.base import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
# schemas/user.py - this validates API data
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str # Plain text from client
class UserResponse(BaseModel):
id: int
email: EmailStr
# Notice: NO password field here
model_config = ConfigDict(from_attributes=True)The model defines what goes in the database. The schemaWhat is schema?A formal definition of the structure your data must follow - which fields exist, what types they have, and which are required. defines what comes in from the APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses. and what goes out. The UserResponse deliberately excludes the password, you never want to send that back.
return db_user). This can leak sensitive fields like hashed_password. Always define explicit response schemas that control exactly which fields are exposed.When flat is fine
Not every project needs the full folder structure. Here is a simple rule:
- Under 10 endpoints, 1-2 resources: single file or a flat structure with
main.py,models.py,schemas.pyis fine - 10-30 endpoints, 3-5 resources: router-based split with the production layout
- 30+ endpoints, multiple teams: consider splitting into sub-applications or even separate services
The mistake is structuring for the project you might build instead of the project you are building. If AI generates a 15-folder structure for your 3-endpointWhat is endpoint?A specific URL path on a server that handles a particular type of request, like GET /api/users. APIWhat is api?A set of rules that lets one program talk to another, usually over the internet, by sending requests and getting responses., that is over-engineering. If it puts 40 endpoints in one file, that is under-engineering. Your job is to recognize which situation you are in.
Router-level dependencies
Routers can declare dependencies that apply to all their endpoints. This is cleaner than repeating the same dependencyWhat is dependency?A piece of code written by someone else that your project needs to work. Think of it as a building block you import instead of writing yourself. on every route.
# routers/admin.py
from fastapi import APIRouter, Depends
from core.security import require_admin
router = APIRouter(
dependencies=[Depends(require_admin)] # Every route requires admin
)
@router.get("/stats")
def admin_stats():
return get_stats()
@router.delete("/users/{user_id}")
def delete_user(user_id: int):
return remove_user(user_id)Both endpoints automatically require admin access without repeating Depends(require_admin) on each one. When AI generates a router with the same dependency on every route, this is the refactoringWhat is refactoring?Restructuring existing code to make it cleaner, more readable, or more efficient without changing what it does. opportunity you should look for.