Building a REST API with FastAPI: What I Wish I'd Known Before Starting

By James Nguyen Updated September 24, 2026
Building a REST API with FastAPI: What I Wish I'd Known Before Starting

I'd built REST APIs in Flask and Django REST Framework for years before a client project pushed me toward FastAPI, and I went in expecting a thinner, faster Flask with nicer docs. What I actually got was a framework that changes how you think about request validation from the first route you write, and it took me longer than I'd like to admit to stop fighting that and start using it properly.

Why I Switched in the First Place

The deciding factor wasn't performance benchmarks, it was type hints. FastAPI builds on Starlette for the ASGI layer and Pydantic v2 for validation, and once you accept that your function signatures are the schema, you stop writing the boilerplate validation code that ate a surprising chunk of my time in Flask projects. The 0.1xx version numbering looks alarming for a framework this widely used in production, but that's a deliberate signal from the maintainer about API stability commitments, not a sign of immaturity.

The Project Structure I Settled On

Every FastAPI tutorial puts everything in one main.py file, and every real project outgrows that within a week. I landed on separate routers per resource, a dedicated schemas module for Pydantic models, and a services layer that keeps business logic out of route handlers entirely. Route handlers in my projects now rarely exceed ten lines, they call a service function and return the result, which made testing dramatically easier once I stopped mixing HTTP concerns with logic.

Pydantic v2 Schemas Are Not Optional Ceremony

I initially treated request/response models as busywork the framework demanded. That was a mistake. Defining separate Create, Update, and Read schemas for the same resource, rather than reusing one model everywhere, is what actually prevents you from accidentally exposing a password hash field or accepting an id in a POST body that should be server-generated. Once I split these out consistently, an entire category of bugs I used to catch in code review just stopped happening.

class UserCreate(BaseModel):
    email: EmailStr
    password: str

class UserRead(BaseModel):
    id: int
    email: EmailStr
    model_config = ConfigDict(from_attributes=True)

Dependency Injection Took Me Longer to Appreciate

FastAPI's Depends() system looked like unnecessary indirection the first time I saw it, compared to just importing a database session directly. It clicked once I needed to swap a real database dependency for a test double without touching route code, and again when I needed the same current-user check across forty different endpoints. Nesting dependencies, a get_current_user that itself depends on get_db, replaced a pile of repeated decorator logic from my Flask days.

Mixing Sync and Async Routes Bit Me Once

FastAPI happily lets you define both async def and regular def route handlers, and I assumed that flexibility was free. It isn't, quite. A sync route runs in a thread pool, which is fine in isolation, but a blocking database call sitting inside what I thought was an async route stalled the entire event loop under load until I traced it down. The rule I follow now is simple: if you're using an async database driver, stay async all the way through the request, don't call a sync ORM method from inside an async handler and assume it's harmless.

The Auto-Generated Docs Are a Real Workflow Tool

I expected the Swagger UI at /docs to be a nice-to-have for demos and not much else. In practice, it became the thing my frontend teammates actually used to check request shapes instead of asking me in Slack, and the OpenAPI 3.1 schema it generates feeds directly into client code generation tools without extra annotation work. That alone saved more integration back-and-forth than any performance benchmark FastAPI publishes.

Testing Setup I Wish I'd Started With

Using TestClient (built on httpx) plus dependency overrides for the database session is the pattern that should be in every tutorial's first chapter instead of its last. Overriding get_db to point at a test database or an in-memory fixture, rather than mocking individual service calls, let my test suite exercise real request/response validation instead of just business logic in isolation, which caught schema mismatches that pure unit tests missed entirely.

Deployment Is Where Tutorials Stop Being Honest

Most guides end at uvicorn main:app --reload and leave production deployment as an exercise for the reader. Running behind Gunicorn with Uvicorn workers, or a plain multi-worker Uvicorn process behind a reverse proxy, handles the concurrency model correctly, but getting graceful shutdown and health checks right for container orchestration took genuine trial and error that no tutorial walked me through.

Background Tasks vs a Real Queue

FastAPI's built-in BackgroundTasks looked like it could replace a proper job queue for anything "fire and forget," like sending a confirmation email after signup. It's genuinely convenient for lightweight work, but it runs in the same process as the request, with no retry, no persistence if the process restarts mid-task, and no visibility into failures beyond whatever you log yourself. The distinction I now apply: BackgroundTasks for anything truly disposable, a real queue like Celery or arq backed by Redis for anything where losing the task silently would actually matter.

Versioning an API That's Already Live

None of the getting-started material covers what happens once a breaking change is unavoidable and clients are already depending on the old shape. Prefixing routers by version, /v1 and /v2 as separate APIRouter instances mounted under the same app, let old and new clients coexist while we migrated consumers over gradually. The Pydantic schema duplication this creates between versions is real overhead, but it beats breaking a mobile client that can't be force-updated on your schedule.

Final Verdict

My honest take after multiple production FastAPI services: the framework earns its popularity specifically because the type-hint-as-schema model forces good habits early rather than punishing you for skipping them later. The learning curve on dependency injection and async discipline is real, but it's a curve that pays for itself the first time you refactor a large route file without breaking a client contract you forgot existed.

Daniel Justin

About the Author

James Nguyen is a full-stack programmer with more than ten years of experience engineering software systems. Specializing in the Node.js and Python ecosystems, he focuses on backend architecture, API design, and clean data integration. Follow me on YouTube and Instagram.

More Articles