What if the tedious part of building an API, checking that every incoming field is the right type, present, and within range, simply took care of itself? That is what this Python FastAPI tutorial delivers. You will define routes with type hints, validate request bodies with Pydantic, speed things up with async/await, share logic through dependencies, and get automatic Swagger UI documentation for free.
“FastAPI is fast to code, fast to run, and fast to learn.”
Sebastian Ramirez, FastAPI creator
Last Updated: July 2026 | Tested on: Python 3.14.6, FastAPI 0.138.0, Pydantic 2.13.4 | Difficulty: Intermediate | Reading Time: 16 minutes
Here is the problem. You build an API, and half your code is just checking the data that came in. Is the age really a number? Is the email field missing? Did someone send a string where you wanted an integer? You write that guard code by hand, you forget a case, and a bad request slips through and crashes something downstream at 2am. FastAPI exists so you can stop writing that boilerplate. You describe the shape of your data once, and FastAPI rejects anything that does not fit before your function even runs.
Think of it like the bouncer at a club checking IDs at the door. The bouncer turns away anyone underage or without a ticket, so inside, the staff can assume every guest is allowed in. FastAPI is that bouncer for your API. By the time your code runs, the data has already been checked, cleaned, and handed to you as a proper Python object. A request with a missing required field comes back as a clear 422 error. An integer field that got a string gets caught and reported, with the exact field name and reason.
FastAPI pulls this off by leaning on plain Python features you may already know: type hints to describe parameters, Pydantic models to describe request bodies, and async/await for speed. You type-hint your function, FastAPI reads those hints, and validation, serialization, and even the interactive docs come along for free. Vinay, a backend developer in his late twenties, moved a Flask API over to FastAPI in a single day and deleted roughly 60 percent of his hand-written validation code. The auto-generated Swagger page alone saved his team hours of writing docs by hand.
The diagram traces the FastAPI request lifecycle. An HTTP request hits the ASGI (Asynchronous Server Gateway Interface) server (Uvicorn), FastAPI validates the incoming data through Pydantic models, resolves any dependency injections, runs your endpoint function, and returns a JSON response. The OpenAPI docs are generated off to the side automatically. The Pydantic validation step is what sets FastAPI apart from Flask: invalid requests are rejected with detailed error messages before your code even runs. This pipeline gives you APIs that are both fast and self-documenting.
Table of Contents
Python FastAPI Tutorial Prerequisites
Before you start this Python FastAPI tutorial, you should be comfortable with Type Hints (FastAPI reads them constantly), Pydantic models, and Virtual Environments. A little Flask experience helps the comparisons land, but it is not required. You will also want Python 3.14.6 installed, which you can check by running python --version.
Install & Hello API
📄 Terminal: create a project and install FastAPI
mkdir fastapi_app && cd fastapi_app python -m venv venv source venv/bin/activate pip install "fastapi[standard]" # pulls in uvicorn, the server
The [standard] part is important. It installs FastAPI plus the bits you need to actually run and serve it, including the Uvicorn server. Once it finishes, do a quick sanity check so you know the install worked before you write a single endpoint.
📄 verify_install.py: confirm the versions you have
import fastapi, uvicorn, pydantic, starlette
print("FastAPI version:", fastapi.__version__)
print("Uvicorn version:", uvicorn.__version__)
print("Pydantic version:", pydantic.__version__)
print("Starlette version:", starlette.__version__)
▶ Output
FastAPI version: 0.138.0 Uvicorn version: 0.49.0 Pydantic version: 2.13.4 Starlette version: 1.3.1
If you see four version lines like these, you are ready. If you get a ModuleNotFoundError instead, your virtual environment probably is not active, so run the source venv/bin/activate step again and reinstall. FastAPI is built on Starlette (the web layer) and Pydantic (the validation layer), which is why all four show up.
📄 main.py: the simplest FastAPI application
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"message": "Hello from FastAPI!", "author": "Rahul"}
@app.get("/users/{user_id}")
async def get_user(user_id: int):
return {"user_id": user_id, "name": f"User {user_id}"}
📄 Terminal: run with Uvicorn
uvicorn main:app --reload # INFO: Uvicorn running on http://127.0.0.1:8000
That is your quick win. Two endpoints, a server running, and the app already validates input. The {user_id} in the path is type-hinted as int, so FastAPI converts and checks it for you. To prove the routes actually behave, here is the same app driven through FastAPI’s built-in TestClient, which sends real requests in memory without you opening a browser.
📄 test_hello.py: call the endpoints with TestClient
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
print(client.get("/").json())
print(client.get("/users/7").json())
▶ Output
{'message': 'Hello from FastAPI!', 'author': 'Rahul'}
{'user_id': 7, 'name': 'User 7'}
What happened here: TestClient wraps your app and lets you call it like a real HTTP client. GET /users/7 returned user_id: 7 as an actual integer, not the string "7", because the user_id: int hint told FastAPI to convert and validate it. Send /users/abc instead and you would get a 422 error automatically, with no extra code from you.
Now open http://127.0.0.1:8000/docs in your browser. You get a full interactive Swagger UI where you can see every endpoint, read the request and response schemas, and fire off real API calls right from the page. It is like a restaurant where the printed menu updates itself the moment the kitchen changes a dish. You wrote zero documentation code. FastAPI built all of it from your type hints.
Request Validation with Pydantic
📄 main.py: type-safe request handling
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
app = FastAPI(title="Student API", version="1.0.0")
class Student(BaseModel):
name: str = Field(min_length=1, max_length=100)
age: int = Field(ge=16, le=34)
grade: str = Field(pattern="^[A-F][+-]?$")
class StudentResponse(Student):
id: int
# In-memory storage (use a real database in production)
students_db: dict[int, Student] = {}
next_id = 1
@app.post("/students", response_model=StudentResponse, status_code=201)
async def create_student(student: Student):
global next_id
student_id = next_id
students_db[student_id] = student
next_id += 1
return StudentResponse(id=student_id, **student.model_dump())
@app.get("/students/{student_id}", response_model=StudentResponse)
async def get_student(student_id: int):
if student_id not in students_db:
raise HTTPException(status_code=404, detail="Student not found")
student = students_db[student_id]
return StudentResponse(id=student_id, **student.model_dump())
Think of a Pydantic model like the rules printed on a college application form: age within range, name filled in, grade in a fixed format. The clerk at the counter rejects a bad form on the spot, before it ever reaches the admissions office. The Field() calls are those printed rules. age must be between 16 and 34, name must be 1 to 100 characters, and grade must match a small regex such as A, B+, or C-.
Notice the model_dump() call: that is the Pydantic v2 way to turn a model back into a plain dict. (If you came from Pydantic v1, this used to be .dict(), which is deprecated in v2.) Let us drive this with TestClient: first a valid signup for a student named Aditi, then a bad one where her classmate Viraj sends an age of 50.
📄 test_students.py: a good request and a bad one
import json
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
# A valid student
r = client.post("/students", json={"name": "Aditi", "age": 22, "grade": "A"})
print("POST status:", r.status_code)
print(json.dumps(r.json(), indent=2))
# age 50 is over the limit of 34
bad = client.post("/students", json={"name": "Viraj", "age": 50, "grade": "B"})
print("POST bad-age status:", bad.status_code)
print(json.dumps(bad.json(), indent=2))
▶ Output
POST status: 201
{
"name": "Aditi",
"age": 22,
"grade": "A",
"id": 1
}
POST bad-age status: 422
{
"detail": [
{
"type": "less_than_equal",
"loc": [
"body",
"age"
],
"msg": "Input should be less than or equal to 34",
"input": 50,
"ctx": {
"le": 34
}
}
]
}
What happened here: The valid student came back with status 201 Created and an auto-assigned id. The student with age 50 never reached your function body. FastAPI returned 422 Unprocessable Entity with a precise error: it tells you the field (body.age), the rule that failed (less_than_equal), the exact value it received (50), and a human-readable message. That whole error report is something you would have hand-written in Flask. Here you wrote none of it.
Query Parameters & Filtering
📄 main.py: query parameters with type hints
from fastapi import FastAPI, Query
app = FastAPI()
fake_items = [
{"name": "Laptop", "price": 999.99, "category": "electronics"},
{"name": "Python Book", "price": 29.99, "category": "books"},
{"name": "Keyboard", "price": 79.99, "category": "electronics"},
{"name": "Flask Tutorial", "price": 19.99, "category": "books"},
]
@app.get("/items")
async def list_items(
category: str | None = None,
min_price: float = Query(default=0, ge=0),
max_price: float = Query(default=10000, le=100000),
skip: int = Query(default=0, ge=0),
limit: int = Query(default=10, ge=1, le=100),
):
results = fake_items
if category:
results = [i for i in results if i["category"] == category]
results = [i for i in results if min_price <= i["price"] <= max_price]
return {"items": results[skip:skip + limit], "total": len(results)}
Query parameters work like the filters panel on a shopping app: pick a category, set a price range, choose how many results per page, and the store shows only what matches. Every parameter here is just a function argument with a type hint and a default. FastAPI reads them and turns them into validated query parameters. category: str | None = None means the filter is optional, and the Query(ge=0) guards stop anyone from passing a negative price or a silly page size. Here is a real request that asks for books under 25.
📄 test_items.py: filter by category and price
import json
from fastapi.testclient import TestClient
from main import app
client = TestClient(app)
r = client.get("/items?category=books&max_price=25")
print(json.dumps(r.json(), indent=2))
▶ Output
{
"items": [
{
"name": "Flask Tutorial",
"price": 19.99,
"category": "books"
}
],
"total": 1
}
What happened here: Out of two books, only the Flask Tutorial at 19.99 came in under the 25 ceiling, so it is the single result. The Python Book at 29.99 was filtered out. You did not parse the query string, cast the price to a float, or check the bounds by hand. The type hints and Query() guards did all of it.
Async Endpoints: Real Performance Gains
📄 async_example.py: run a database call and an API call at the same time
import asyncio
from fastapi import FastAPI
app = FastAPI()
async def fetch_user_from_db(user_id: int) -> dict:
"""Simulates an async database call."""
await asyncio.sleep(0.1) # Simulate I/O
return {"id": user_id, "name": "Prathamesh", "age": 28}
async def fetch_user_orders(user_id: int) -> list:
"""Simulates an async API call."""
await asyncio.sleep(0.1)
return [{"order_id": 101, "amount": 59.99}]
@app.get("/users/{user_id}/profile")
async def user_profile(user_id: int):
# Run both I/O calls at the same time, not one after the other
user, orders = await asyncio.gather(
fetch_user_from_db(user_id),
fetch_user_orders(user_id)
)
return {"user": user, "orders": orders}
Picture a cook making tea and toast. The slow way is to boil the water, wait, and only then start the toast. The smart way is to put the bread in the toaster, then boil the water while it browns. Both finish in about the time of the slower one. That is what asyncio.gather() does here. The database call and the orders call each take about 100ms of waiting, so running them together takes about 100ms total, not 200ms. Let us prove it: the test below fetches the profile of user 42, a sample user named Prathamesh, through TestClient and times the whole call.
📄 test_profile.py: call the async endpoint and time it
import json, time
from fastapi.testclient import TestClient
from async_example import app
client = TestClient(app)
start = time.perf_counter()
r = client.get("/users/42/profile")
elapsed = (time.perf_counter() - start) * 1000
print(json.dumps(r.json(), indent=2))
print(f"elapsed ~ {elapsed:.0f} ms")
▶ Output
{
"user": {
"id": 42,
"name": "Prathamesh",
"age": 28
},
"orders": [
{
"order_id": 101,
"amount": 59.99
}
]
}
elapsed ~ 130 ms
What happened here: Two calls that each wait about 100ms came back together in roughly that same window, not the 200ms you would pay if you ran them one after the other. (Your exact number will wobble by a few milliseconds from run to run, since the small extra is just request and JSON overhead.) This is where FastAPI pulls ahead of plain Flask: async I/O (input/output) is built in, so while one request is waiting on the network, the server can get on with another. Swap asyncio.gather for two separate await lines and the time would roughly double.
Dependency Injection
📄 dependencies.py: share logic across routes with Depends()
from fastapi import FastAPI, Depends, HTTPException, Header
app = FastAPI()
async def verify_api_key(x_api_key: str = Header()):
"""Dependency that checks for a valid API key."""
if x_api_key != "secret-key-123":
raise HTTPException(status_code=403, detail="Invalid API key")
return x_api_key
@app.get("/protected", dependencies=[Depends(verify_api_key)])
async def protected_route():
return {"message": "You have access!"}
@app.get("/admin")
async def admin_route(api_key: str = Depends(verify_api_key)):
return {"message": "Admin access granted", "key": api_key}
Think of a dependency like the security desk in an office lobby. Every visitor passes the same desk and gets the same badge check before they reach any floor. You set up that desk once, and every room behind it is protected. Depends() is that shared desk for your routes. You write the check once (here, a valid API key) and FastAPI runs it before the endpoint, on every route that asks for it. The same pattern works for database sessions, the current user, or pagination settings, so you never copy that code into each function. Here is the check in action: one request with the right key, one with the wrong key.
📄 test_deps.py: a good key and a bad key
from fastapi.testclient import TestClient
from dependencies import app
client = TestClient(app)
good = client.get("/protected", headers={"x-api-key": "secret-key-123"})
print("Valid key status:", good.status_code, good.json())
bad = client.get("/protected", headers={"x-api-key": "wrong"})
print("Bad key status:", bad.status_code, bad.json())
▶ Output
Valid key status: 200 {'message': 'You have access!'}
Bad key status: 403 {'detail': 'Invalid API key'}
What happened here: The request with the right key sailed through to the route and got a 200. The request with the wrong key never reached protected_route at all. The dependency raised an HTTPException first, so FastAPI sent back a clean 403 Forbidden. You wrote the key check in exactly one place, and both routes are guarded by it. FastAPI resolves the whole dependency tree for you, in order, before your endpoint runs.
Common Mistakes
Before this Python FastAPI tutorial wraps up, two mistakes deserve a close look. The one that bites people most is mixing up blocking and non-blocking code inside an async route. FastAPI runs all your async def routes on one event loop, a single loop that juggles many requests by switching between them whenever one pauses at an await. If you call a plain blocking function like time.sleep() inside that loop, nothing ever pauses, so you freeze the whole thing. It is like one cashier serving a queue: the moment that cashier stops to take a long personal phone call, every customer in line just waits.
📄 ❌ Mistake: Using def instead of async def for I/O endpoints
@app.get("/data")
def get_data(): # Blocks the entire event loop during I/O!
import time
time.sleep(1) # Simulates slow database call
return {"data": "result"}
📄 ✅ Fix: Use async def with await
@app.get("/data")
async def get_data():
await asyncio.sleep(1) # Non-blocking, so other requests keep moving
return {"data": "result"}
The fix is to reach for the async version of whatever you are calling and await it. Use await asyncio.sleep() instead of time.sleep(), an async database driver instead of a blocking one, and httpx.AsyncClient instead of plain requests. While one request sits waiting on that await, the loop is free to serve the next one. One quick rule of thumb: if a library call talks to a disk, a network, or a database, there is usually an async version, and that is the one you want inside an async def route.
If you only have a blocking function and cannot change it, define the route with a plain def instead and FastAPI will run it in a thread pool so it does not jam the loop.
Conclusion
This Python FastAPI tutorial leaves you with the core of the framework in hand: routes built from plain type hints, Pydantic models that reject bad requests with precise 422 errors, query parameters with built-in guards, async endpoints that overlap their waiting time, and Depends() for logic you share across routes, all with a Swagger UI generated for free. The big habit to carry forward is simple: describe your data once, let the framework enforce it, and keep blocking calls out of async def routes.
Next up we put this in context with Flask vs FastAPI vs Django, so you know which framework fits which job. For everything else in this series, from basics to AI/ML, visit the Python + AI/ML tutorial series home.
Frequently Asked Questions
What is FastAPI?
FastAPI is a modern Python web framework for building APIs. It uses type hints for automatic validation, serialization, and documentation. It is built on Starlette (ASGI) and Pydantic, the two libraries doing the heavy lifting behind every example in this Python FastAPI tutorial.
Is FastAPI faster than Flask?
Yes, significantly. FastAPI on Uvicorn handles 5-10x more concurrent requests than Flask on Gunicorn for I/O-bound workloads, thanks to native async support and ASGI. Raw computation speed is similar.
Do I need to know async/await to use FastAPI?
No. You can use regular def functions and FastAPI runs them in a thread pool. But you miss the performance benefits. Learn async/await from the asyncio tutorial to get the full FastAPI advantage.
How do I deploy FastAPI in production?
Use uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4 behind Nginx. For containers, see the Docker tutorial. Cloud options: AWS Lambda, Google Cloud Run, Railway.
Where are the auto-generated docs?
Swagger UI at /docs, ReDoc at /redoc, and raw OpenAPI JSON at /openapi.json. All auto-generated from your type hints and Pydantic models, zero configuration needed.
Try It Yourself
Build a book catalog API with FastAPI. Requirements: CRUD (Create, Read, Update, Delete) operations for books (title, author, ISBN, price, category), filtering by category and price range, pagination, input validation with Pydantic, and a protected DELETE endpoint requiring an API key header.
Interview Questions on FastAPI
How interviewers actually probe this topic: real scenarios, with answers you can say out loud.
Q: When should a FastAPI path operation be async def versus plain def?
Use async def when everything you wait on inside is non-blocking: an async database driver, httpx.AsyncClient, or asyncio.sleep(). Use plain def when you must call blocking libraries, because FastAPI runs plain def routes in a thread pool where they cannot stall the event loop. The worst combination is async def wrapping blocking calls: that freezes every request sharing the loop.
Q: Your FastAPI service normally answers in 50ms, but whenever one specific endpoint runs, every other request hangs for a full second. What do you check first?
Open that endpoint and look for a blocking call inside an async def route: time.sleep(), the requests library, or a synchronous database driver are the usual suspects. Any of these blocks the shared event loop, so every other coroutine waits until it finishes. Fix it by switching to the async equivalent (asyncio.sleep(), httpx.AsyncClient, an async driver) or by declaring the route with plain def so it runs in the thread pool instead.
Q: What does response_model do, and why set it when the function already returns a typed object?
response_model tells FastAPI how to validate, filter, and document the response. Its most useful trick is stripping fields not declared on the model, which is how you keep internal data such as a hashed password out of the JSON you send back. It also feeds the OpenAPI schema, so Swagger UI shows clients the exact response shape without extra work.
Q: A single request touches Depends(get_db) in five places across nested dependencies. How many times does get_db actually run?
Once. FastAPI caches a dependency's result within a single request, so every parameter asking for the same dependency receives the same object. That is exactly what you want for a database session or the current user. If you genuinely need a fresh call each time, declare it with Depends(get_db, use_cache=False).
Q: Why does FastAPI return 422 for validation failures instead of 400?
422 Unprocessable Entity means the request was well-formed JSON but broke the schema rules, which is a more precise signal than a generic 400 Bad Request. FastAPI's built-in handler for RequestValidationError returns 422 with a detail list naming each failing field, the rule it broke, and the value received. If your API contract requires 400, you can register a custom exception handler to override the default.
Q: What is the difference between taking a dependency as a parameter versus listing it in the decorator's dependencies argument?
Both forms run the dependency before the endpoint and can reject the request by raising HTTPException. The parameter form, like api_key: str = Depends(verify_api_key), also hands the returned value to your function. The decorator form, dependencies=[Depends(verify_api_key)], runs it purely for the side effect, which reads cleaner when you only need the check and not the value.
Go deeper: FastAPI documentation covers every edge case of this topic.
Related Posts
Previous: Python: Building a Web App with Flask (Routes, Templates, Forms)
Next: Python: Flask vs FastAPI vs Django, Web Frameworks Compared
Series Home: Python + AI/ML Tutorial Series

No comment