Home / Courses / FastAPI for Beginners / III — Best Practices
III — Best Practices
Article 6 of 6

Best Practices & Next Steps

Common beginner mistakes in FastAPI, writing effective tests with pytest and `TestClient`, a debugging checklist for when things break, and a roadmap for what to learn next.

July 10, 2026

Congratulations — you now have an API that runs end to end, from a simple endpoint all the way to full CRUD with validation and a real database. In this closing article, we'll cover the things that tend to trip up beginners, how to write effective tests, and where you can go to keep learning after this.

Common Beginner Mistakes

  • Marking a function async def but calling blocking code inside it. This is the most common way to make a FastAPI app slower instead of faster. If a function calls a synchronous library (a database driver that doesn't support async, or time.sleep()), it blocks the entire event loop — not just that request, but EVERY other request the server is currently handling. When in doubt, use plain def; FastAPI automatically runs it in a separate thread pool.
  • Not using response_model. Without response_model, it's easy to accidentally leak internal fields that should never reach the public (a password hash, internal notes, and the like). Always define your response schema explicitly, like the TaskRead we used in Part 4.
  • Writing business logic directly inside the path operation function. For a small app this is still fine, but once the app grows, an endpoint that does database queries, business validation, AND calls an external API all at once becomes hard to test and read. Split more complex logic into its own function/module (often called a service layer), and let the path operation function focus on accepting requests and returning responses.
  • Hardcoding configuration like connection strings or secret keys. Same principle as in the CI/CD for Beginners series — never commit credentials to code. For FastAPI, the common pattern is pydantic-settings, which reads configuration from environment variables with the same type validation as a regular model.
  • Not handling specific errors, just relying on a generic 500 Internal Server Error. If there's a predictable error condition (data not found, conflict, no permission), handle it explicitly with HTTPException and the right status code — don't let a raw traceback leak to API consumers.
  • Not writing any tests at all. Because FastAPI validates so much automatically, it's easy to feel like "Pydantic already validates it, why test at all?" But automatic validation only guarantees the data's shape is correct — it doesn't guarantee our app's logic is correct. Business logic (calculations, authorization rules, interactions between data) still needs explicit tests.
  • Testing With Pytest and TestClient

    FastAPI provides a TestClient that lets us call endpoints without actually running a server — a great fit for pytest. For an app connected to a database, the standard pattern is to override the dependency get_session with a test-only in-memory database, so tests never touch the real tasks.db:

    import pytest
    from fastapi.testclient import TestClient
    from sqlmodel import Session, SQLModel, create_engine
    from sqlmodel.pool import StaticPool
    
    from app.database import get_session
    from app.main import app
    
    
    @pytest.fixture(name="session")
    def session_fixture():
        engine = create_engine(
            "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
        )
        SQLModel.metadata.create_all(engine)
        with Session(engine) as session:
            yield session
    
    
    @pytest.fixture(name="client")
    def client_fixture(session: Session):
        def get_session_override():
            return session
    
        app.dependency_overrides[get_session] = get_session_override
        with TestClient(app) as client:
            yield client
        app.dependency_overrides.clear()
    
    
    def test_create_task(client: TestClient):
        response = client.post("/api/tasks", json={"title": "Learn FastAPI"})
        assert response.status_code == 201
        assert response.json()["title"] == "Learn FastAPI"
    

    The key point: app.dependency_overrides[get_session] = get_session_override swaps the real dependency for a test version, without changing a single line of endpoint code. This is the concrete payoff of the dependency injection we covered in Part 5 — a design that separates "how to get a resource" from "the endpoint's logic" makes the code easy to test in isolation.

    Run it with:

    pip install -r requirements.txt
    pytest -v
    

    Debugging Checklist for When Things Break

    If your API returns an error and you're not sure where to start, try this order:

  • Read the error detail on /docs, not just the status code. A 422 response usually already tells you which field failed validation and why — don't just guess.
  • Tell validation errors (422) apart from business-logic errors (404, 400, etc). If it's a 422, the problem is the shape of the data sent vs. the Pydantic schema. If it's a 404/400/etc that we raise manually, check the logic in the path operation function.
  • Reproduce it via /docs or curl first, before suspecting the frontend/client code. This isolates whether the problem is in the API or on the caller's side.
  • For errors that only show up on some requests (not all), suspect the data condition — e.g. a request targeting an id that genuinely doesn't exist, rather than a bug in the endpoint itself.
  • For database-related errors, check the session.add()session.commit()session.refresh() order. Forgetting commit() is a common cause of data that "looks saved" in code but never actually makes it into the database.
  • Cleaning Up Configuration With pydantic-settings

    As a follow-up to best practice #4, pydantic-settings gives us a consistent pattern for reading environment variables with type validation:

    from pydantic_settings import BaseSettings
    
    
    class Settings(BaseSettings):
        database_url: str = "sqlite:///./tasks.db"
        debug: bool = False
    
    
    settings = Settings()
    

    These values are automatically read from environment variables (DATABASE_URL, DEBUG) or a .env file, with the same type validation as a regular Pydantic model — if DEBUG is set to something that isn't a boolean, the app fails to start with a clear error message, instead of a hidden bug that only surfaces later.

    Roadmap — What to Learn Next?

    This series deliberately focused on the fundamentals so you'd have a solid foundation. If you want to go deeper, here are a few directions worth exploring:

  • Authentication & authorization — FastAPI has built-in support for OAuth2 and JWT via fastapi.security, plus the exact same dependency injection pattern for protecting specific endpoints (Depends(get_current_user)).
  • Async database drivers — to fully take advantage of async performance, swap the synchronous SQLite/PostgreSQL driver for an async version (e.g. asyncpg for PostgreSQL), and turn endpoints into real async def functions.
  • Database migrations with Alembic — once the Task schema changes in production (adding a column, etc.), you need a controlled way to change the table structure without losing data. Alembic is the standard tool for this in the SQLAlchemy/SQLModel ecosystem.
  • Background tasks & job queues — for work that doesn't need to finish before the response is sent (sending an email, processing a large file), FastAPI has a built-in BackgroundTasks for simple cases, and you can graduate to Celery/RQ for heavier workloads.
  • Containerization with Docker — packaging your FastAPI app (along with its dependencies) into a consistent image from local all the way to production.
  • Wiring it into a CI/CD pipeline — once your API has a solid test suite (like the one we just built), the natural next step is automating its testing and deployment. If you haven't already, the CI/CD for Beginners series covers exactly this from scratch — triggers, jobs, matrix builds, all the way to automatic deployment — using a case study with the exact same shape as what you just built here.
  • The good news is that, since you already understand the core concepts — path operations, Pydantic models, dependency injection — learning other FastAPI features from here on out is mostly about deepening the same patterns, not starting from zero.

    Closing Thoughts

    We've made the journey from "why FastAPI matters," through understanding its application anatomy, building our first endpoint, tightening up data validation, all the way to connecting a real database with dependency injection. Most importantly, you now have a real, working API template you can reuse and build on for your own projects.

    Thanks for following along with this FastAPI for Beginners series. Good luck building your own!

    Topics

    FastAPIBest Practices
    III — Best Practices · Article 6 of 6