FastAPI makes it easy to get an API running in a single file. A few routes, a Pydantic model, and you have a working endpoint in under a minute. But once your project grows past a handful of endpoints, that single main.py becomes a liability. Routes blur into business logic, database queries sit next to validation code, and testing anything in isolation becomes an exercise in frustration. This guide walks through a practical project structure that separates concerns into routers, schemas, services, and models—so your FastAPI application stays organized whether it has ten endpoints or ten thousand.
FastAPI has quickly become one of the leading Python frameworks for building APIs. Its combination of automatic documentation, Pydantic-based validation, dependency injection, and native async support makes it a strong choice for everything from quick prototypes to production-grade backends. But the framework is intentionally lightweight when it comes to project layout. There is no enforced directory convention the way Django organizes apps. That freedom is powerful, but it also means the burden of structure falls entirely on you.
The approach covered here draws from patterns used in production by teams at companies like Netflix (whose Dispatch project inspired a widely adopted layout), as well as the community-maintained fastapi-best-practices repository and FastAPI's own official documentation on building larger applications.
The Problem With a Single-File FastAPI App
Every FastAPI tutorial starts the same way:
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
def read_root():
return {"message": "Hello, World"}
This works perfectly for learning or for a tiny microservice with two or three routes. The trouble starts when you begin adding real features: user authentication, database queries, input validation, error handling, and background tasks. Without any organizational boundaries, you end up with a file that handles routing, data access, business logic, and configuration all in the same place. The consequences show up quickly:
- Merge conflicts increase because every developer is editing the same file.
- Testing becomes painful because you cannot isolate a service function from the route that calls it.
- Onboarding slows down because new team members have to read through hundreds of lines to find the code they need.
- Reuse drops because logic is tangled into route handlers instead of sitting in standalone functions.
The fix is straightforward: split your application into distinct layers, each with a clear responsibility.
Two Approaches: File-Type vs. Feature-Based
Before settling on a layout, it helps to understand the two main schools of thought.
File-Type Structure
This approach groups files by what they are. All routers go in a routers/ folder. All schemas go in a schemas/ folder. All models go in a models/ folder.
app/
main.py
routers/
users.py
items.py
orders.py
schemas/
users.py
items.py
orders.py
models/
users.py
items.py
orders.py
services/
users.py
items.py
orders.py
This is the structure you will find in the official FastAPI tutorial and in many beginner-focused guides. It works well for smaller projects or microservices with a narrow scope. However, as a project grows to cover many domains, the folders become crowded and the relationship between related files becomes harder to trace. Adding a new feature means touching four or five separate directories.
Feature-Based Structure
This approach groups files by what they do. Everything related to authentication lives in an auth/ package. Everything related to items lives in an items/ package.
app/
main.py
auth/
router.py
schemas.py
models.py
service.py
dependencies.py
items/
router.py
schemas.py
models.py
service.py
dependencies.py
This is the pattern that scales better for larger applications. When you need to work on the authentication system, everything you need is in one directory. When you need to add a new domain, you create a single new package with a predictable set of files inside it.
| Criteria | File-Type Structure | Feature-Based Structure |
|---|---|---|
| Best suited for | Small projects, microservices | Large applications, monoliths |
| Adding a new feature | Touch multiple directories | Create one new package |
| Code discoverability | Easy at small scale, noisy at large scale | Predictable at any scale |
| Coupling risk | Higher (shared folders invite cross-imports) | Lower (each feature is self-contained) |
| Team collaboration | Frequent merge conflicts in shared files | Teams can own individual packages |
The rest of this article uses the feature-based approach, since the goal is to handle a project that has outgrown a single file.
The Recommended Project Layout
Here is the full directory tree for a production-oriented FastAPI project. Each piece is explained in detail in the sections that follow.
my_fastapi_project/
app/
__init__.py
main.py # FastAPI app instance, router registration
core/
__init__.py
config.py # Pydantic Settings for environment variables
security.py # JWT helpers, password hashing
db/
__init__.py
database.py # Engine, session factory, get_db dependency
auth/
__init__.py
router.py # Auth endpoints (login, register)
schemas.py # Pydantic request/response models
models.py # SQLAlchemy User model
service.py # Business logic (create user, verify password)
dependencies.py # get_current_user dependency
items/
__init__.py
router.py
schemas.py
models.py
service.py
dependencies.py
alembic/ # Database migrations
versions/
tests/
__init__.py
conftest.py # Shared fixtures
test_auth.py
test_items.py
.env
alembic.ini
requirements.txt
README.md
Each feature package (auth/, items/, etc.) follows the same internal file naming convention: router.py, schemas.py, models.py, service.py, and dependencies.py. Consistency matters more than any specific name. When the pattern is predictable, you always know where to look.
Routers: Organizing Your Endpoints
FastAPI provides the APIRouter class specifically for splitting your application across multiple files. Each router handles the endpoints for a single feature area and is then included in the main application.
# app/items/router.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.auth.dependencies import get_current_user
from app.items import schemas, service
router = APIRouter(
prefix="/items",
tags=["Items"],
)
@router.get("/", response_model=list[schemas.ItemOut])
def list_items(
skip: int = 0,
limit: int = 20,
db: Session = Depends(get_db),
):
return service.get_items(db, skip=skip, limit=limit)
@router.post("/", response_model=schemas.ItemOut, status_code=status.HTTP_201_CREATED)
def create_item(
item_in: schemas.ItemCreate,
db: Session = Depends(get_db),
current_user=Depends(get_current_user),
):
return service.create_item(db, item_in, owner_id=current_user.id)
Notice how thin the route handlers are. They accept the request data, call into the service layer, and return the result. There is no database query logic, no password hashing, and no business rules inside the handler itself. This is intentional. Route handlers are the presentation layer of your API. Their job is to receive input, delegate work, and format output.
Set the prefix and tags on the router itself rather than repeating them on every route. This keeps endpoint definitions clean and ensures the auto-generated OpenAPI docs group your routes logically.
Schemas: Separating API Contracts From Database Models
One of the strengths of FastAPI is its tight integration with Pydantic. Schemas (Pydantic models) define the exact shape of the data your API accepts and returns. Keeping them separate from your database models is important because the two serve different purposes.
Your database model represents how data is stored. Your schema represents how data crosses the API boundary. They will often look similar, but they diverge in practice. A UserCreate schema includes a plain-text password field. The database model stores a hashed password instead. A UserOut schema omits the password entirely.
# app/items/schemas.py
from pydantic import BaseModel, ConfigDict
class ItemBase(BaseModel):
title: str
description: str | None = None
class ItemCreate(ItemBase):
"""Schema for creating a new item. Sent by the client."""
pass
class ItemUpdate(BaseModel):
"""Schema for partial updates. All fields optional."""
title: str | None = None
description: str | None = None
class ItemOut(ItemBase):
"""Schema returned to the client. Includes database-generated fields."""
id: int
owner_id: int
model_config = ConfigDict(from_attributes=True)
The model_config = ConfigDict(from_attributes=True) line (replacing the older orm_mode = True in Pydantic v2) tells Pydantic to read data from ORM model attributes, not just dictionaries. This allows you to return a SQLAlchemy object directly from your route and let Pydantic serialize it.
A common pattern is to define a base schema with shared fields, then extend it for creation, update, and response purposes. This avoids duplicating field definitions across schemas while keeping each one tailored to its specific role.
Services: Where Business Logic Lives
The service layer is the core of your application. It contains the business rules, data transformations, and orchestration logic that sits between your route handlers and your database.
# app/items/service.py
from sqlalchemy.orm import Session
from app.items import models, schemas
def get_items(db: Session, skip: int = 0, limit: int = 20) -> list[models.Item]:
return db.query(models.Item).offset(skip).limit(limit).all()
def get_item_by_id(db: Session, item_id: int) -> models.Item | None:
return db.query(models.Item).filter(models.Item.id == item_id).first()
def create_item(db: Session, item_in: schemas.ItemCreate, owner_id: int) -> models.Item:
db_item = models.Item(**item_in.model_dump(), owner_id=owner_id)
db.add(db_item)
db.commit()
db.refresh(db_item)
return db_item
def delete_item(db: Session, item_id: int) -> None:
item = get_item_by_id(db, item_id)
if item:
db.delete(item)
db.commit()
By isolating this logic in service functions, you gain several advantages. First, you can unit test create_item by passing in a mock database session without starting a server. Second, you can call the same function from a route handler, a background task, or a CLI command without duplicating code. Third, your route handlers stay short and readable because they only handle HTTP-specific concerns like status codes and response models.
Avoid putting database queries directly inside route handlers. It works at first, but it creates tight coupling between your API layer and your data access layer. When you later need to add caching, switch databases, or write tests, that coupling becomes a real obstacle.
Models, Database, and Configuration
SQLAlchemy Models
Each feature package contains a models.py file with the SQLAlchemy ORM classes for that domain. These map directly to database tables.
# app/items/models.py
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship
from app.db.database import Base
class Item(Base):
__tablename__ = "items"
id = Column(Integer, primary_key=True, index=True)
title = Column(String, index=True, nullable=False)
description = Column(String, nullable=True)
owner_id = Column(Integer, ForeignKey("users.id"), nullable=False)
owner = relationship("User", back_populates="items")
Database Session
The database connection and session factory live in a shared db/ package. The get_db function is a FastAPI dependency that yields a session and closes it when the request finishes.
# app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import settings
engine = create_engine(settings.DATABASE_URL)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
Configuration With Pydantic Settings
Pydantic's BaseSettings class reads environment variables and validates them at startup. This keeps secrets out of your codebase and gives you type-safe access to configuration values.
# app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
PROJECT_NAME: str = "My FastAPI App"
DATABASE_URL: str
SECRET_KEY: str
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
model_config = {"env_file": ".env"}
settings = Settings()
Import settings anywhere in your application to access these values. When deploying to staging or production, you only need to swap the .env file or set environment variables in your container orchestrator.
Wiring It All Together in main.py
The entry point of your application should be minimal. Its job is to create the FastAPI instance, register routers, and add any global middleware or exception handlers.
# app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.auth.router import router as auth_router
from app.items.router import router as items_router
app = FastAPI(
title=settings.PROJECT_NAME,
version="1.0.0",
)
app.include_router(auth_router, prefix="/api/v1")
app.include_router(items_router, prefix="/api/v1")
@app.get("/health")
def health_check():
return {"status": "ok"}
Notice the /api/v1 prefix applied at registration time. This is a common convention for API versioning. If you later introduce breaking changes, you can create a v2 set of routers and register them alongside the existing ones without disrupting current clients.
Including routers does not affect runtime performance. FastAPI processes all include_router calls at startup in microseconds. The main benefit is organizational, not computational.
Frequently Asked Questions
What is the best way to structure a large FastAPI project?
Organize code by feature (also called domain-based or modular structure) rather than by file type. Each feature package contains its own router, schemas, models, services, and dependencies. This pattern keeps related code together, reduces cross-directory navigation, and makes it straightforward for teams to own individual modules.
What is the difference between schemas and models in FastAPI?
Schemas are Pydantic models that define the shape of data crossing your API boundary—what the client sends and what the server returns. Models are ORM classes (typically SQLAlchemy) that define how data is persisted in your database. They often have overlapping fields, but they serve different roles. A creation schema might include a plain-text password, while the database model stores a hashed version. A response schema might exclude sensitive fields entirely.
Why should I use a service layer in FastAPI?
A service layer separates business logic from your route handlers. This makes individual functions easier to test in isolation, prevents endpoint handlers from growing into unmanageable blocks of code, and allows you to call the same logic from different entry points (HTTP routes, CLI commands, background workers) without duplication.
Can I use this structure for small projects?
Yes. Even a project with a small number of endpoints benefits from having a predictable layout. Starting with a modular structure costs very little upfront and saves significant refactoring time later if the project grows.
Key Takeaways
- Feature-based over file-type: Group files by what they do (auth, items, orders) rather than what they are (routers, models, schemas). This scales better as your application grows and makes it easier for teams to work independently.
- Keep route handlers thin: Routers should receive input, call the service layer, and return output. Business logic, database queries, and data transformations belong in service functions.
- Separate schemas from models: Pydantic schemas define your API contract. SQLAlchemy models define your database structure. Keeping them independent gives you the flexibility to evolve each without breaking the other.
- Centralize configuration: Use Pydantic Settings to load environment variables with type validation. This keeps secrets out of your code and gives you a single source of truth for configuration across all environments.
- Use dependencies for shared concerns: FastAPI's dependency injection system is designed for cross-cutting concerns like database sessions, authentication, and permissions. Lean on it instead of scattering setup code across your route handlers.
A well-structured FastAPI project is not about following a rigid template. It is about establishing clear boundaries between layers so that each piece of your application has a defined role. Routers handle HTTP. Schemas define contracts. Services contain logic. Models manage persistence. When those boundaries are respected, your codebase stays navigable, your tests stay focused, and your team can move quickly without stepping on each other's work.