Chapter 4 · Your First Backend
Your First FastAPI App
- Page 17 of 22
- 16 min read
On the last page you wrote a backend by hand and saw how much of it was plumbing: splitting paths, converting strings to numbers, checking JSON, picking status codes, writing headers. FastAPI does all of that plumbing for you. You write ordinary Python functions with type hints, and FastAPI turns them into HTTP endpoints that parse and check their input, convert their output to JSON and document themselves.
FastAPI is the most common choice for Python AI backends today: model-serving endpoints, LLM gateways, RAG services and agent tools are very often FastAPI apps. On this page you create a proper project with uv, write your first endpoints, and call them with curl, Python and the browser.
What you will learn
- Why FastAPI, and how it compares with Flask and Django.
- How to set up a FastAPI project with uv (and the pip equivalent), and the difference between
fastapi devandfastapi run. - How to write GET and POST endpoints with typed path parameters, query parameters and a request body.
- How FastAPI checks input for you and what its error responses look like.
- Where the automatic documentation lives (
/docs,/redoc,/openapi.json) and how to call your API fromcurlandrequests.
Why FastAPI
Python has three web frameworks you will meet constantly. They are all good; they were built for different jobs.
| FastAPI | Flask | Django | |
|---|---|---|---|
| Built for | JSON APIs | Small web apps and APIs | Full websites with database, admin and templates |
| Input checking | Automatic, from type hints (Pydantic) | By hand or with extensions | Forms and serializers (Django REST framework for APIs) |
| API docs | Generated automatically (OpenAPI) | Extensions | Extensions |
| Async | Native: def and async def both work | Possible, not its centre | Partly async |
| Typical AI use | Model serving, LLM backends, RAG and agent services | Older ML demos and small tools | Products with lots of pages, users and admin screens |
FastAPI is built on two libraries: Starlette (the web part: routing, requests, responses) and Pydantic (data checking). A server program, Uvicorn, runs it. FastAPI is still version 0.x (0.143 in October 2026), so read the release notes when you upgrade.
Create the project with uv
You met uv on Environment Variables, .env Files and Virtual Environments. From now on, every project is a uv project. Create one for this page:
mkdir -p ~/projects
cd ~/projects
uv init --no-package bitbyte-api
cd bitbyte-api
uv add -q "fastapi[standard]"
ls -AInitialized project `bitbyte-api` at `/home/nadia/projects/bitbyte-api`
.git
.gitignore
.python-version
.venv
README.md
main.py
pyproject.toml
uv.lockuv init created the project files: pyproject.toml, a starter main.py, a README.md, a .python-version file naming the Python version, and a Git repository with a .gitignore that already ignores .venv; --no-package means "this is an application, not a library to publish". uv add created the virtual environment .venv, installed FastAPI, recorded it in pyproject.toml and pinned every exact version in uv.lock. The [standard] extra brings the extras you want for development: Uvicorn, the fastapi command, and a few helpers.
cat pyproject.toml[project]
name = "bitbyte-api"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"fastapi[standard]>=0.143.0",
]Your requires-python will show your own Python version. If you prefer plain pip, or a project already uses it, the equivalents are:
| Step | uv | pip and venv |
|---|---|---|
| Create the project | uv init --no-package bitbyte-api | mkdir bitbyte-api, cd bitbyte-api, python -m venv .venv |
| Activate the venv | not needed: uv run uses it | source .venv/bin/activate (PowerShell: .venv\Scripts\Activate.ps1) |
| Install FastAPI | uv add "fastapi[standard]" | pip install "fastapi[standard]" |
| Record the versions | automatic: pyproject.toml + uv.lock | pip freeze > requirements.txt |
| Run a command | uv run fastapi dev main.py | fastapi dev main.py (venv active) |
| Recreate on another machine | uv sync | pip install -r requirements.txt |
Your first endpoints
uv init left a "hello" main.py. Replace it with this:
# file: main.py
from fastapi import FastAPI
app = FastAPI(title="BitByte Course API")
@app.get("/")
def home():
return {"name": "BitByte Course API", "docs": "/docs"}
@app.get("/health")
def health():
return {"status": "ok"}That is a complete backend. app is the application. @app.get("/health") is a path operation decorator: it registers the function below it as the handler for GET /health. The function returns a dict and FastAPI turns it into a JSON response with status 200. Compare that with the forty lines of routing and send_json in tiny_server.py.
fastapi dev and fastapi run
The fastapi command starts your app with Uvicorn. It has two modes:
fastapi dev main.py | fastapi run main.py | |
|---|---|---|
| For | Writing code on your machine | Production, Docker, and this page |
| Auto-reload when you save a file | Yes | No |
| Listens on | 127.0.0.1 (only your machine) | 0.0.0.0 (all network interfaces) |
| Default port | 8000 | 8000 |
On your own machine, open a second terminal in the project folder and run the dev server there; leave it running while you edit, and press Ctrl+C to stop it:
uv run fastapi dev main.pyIt prints the URL of your app and of its docs, then one log line per request. Every time you save main.py, it restarts by itself. On this page, as on the last one, the server runs in the background with fastapi run, so each step works the same way for everyone:
uv run fastapi run main.py --port 8000 > server.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health
echo
curl -s http://localhost:8000/
echo
grep "Uvicorn running" server.log{"status":"ok"}
{"name":"BitByte Course API","docs":"/docs"}
INFO: Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)FastAPI sends compact JSON (no spaces). If you open http://localhost:8000/ in your browser you will see the same thing. Also try what tiny_server.py got wrong:
curl -s http://localhost:8000/teachers
echo
curl -s -X DELETE http://localhost:8000/health{"detail":"Not Found"}
{"detail":"Method Not Allowed"}A JSON 404 for an unknown path and a JSON 405 for a known path with the wrong method, without a single line of your code.
The course API
Now a real API: courses you can list and filter, one course by id, and a sentiment endpoint that stands in for an ML model. Replace main.py with the full version and read it once; the sections below take it apart.
# file: main.py
"""BitByte Course API: your first FastAPI app."""
from typing import Annotated
from fastapi import FastAPI, HTTPException, Path, Query
from pydantic import BaseModel
app = FastAPI(title="BitByte Course API", version="0.1.0")
COURSES = [
{"id": 1, "title": "How AI Works", "level": "beginner", "hours": 3},
{"id": 2, "title": "Python for AI", "level": "beginner", "hours": 12},
{"id": 3, "title": "Maths for AI", "level": "beginner", "hours": 8},
{"id": 4, "title": "RAG Systems", "level": "advanced", "hours": 10},
{"id": 5, "title": "AI Agents", "level": "advanced", "hours": 12},
]
POSITIVE = {"good", "great", "love", "excellent", "helpful", "clear"}
NEGATIVE = {"bad", "slow", "boring", "confusing", "hate", "broken"}
class SentimentRequest(BaseModel):
text: str
@app.get("/")
def home():
return {"name": "BitByte Course API", "docs": "/docs"}
@app.get("/health")
def health():
return {"status": "ok"}
@app.get("/courses")
def list_courses(
level: Annotated[str | None, Query(description="beginner or advanced")] = None,
limit: Annotated[int, Query(ge=1, le=50)] = 10,
):
"""List courses, optionally filtered by level."""
items = [c for c in COURSES if level is None or c["level"] == level]
return {"count": len(items[:limit]), "items": items[:limit]}
@app.get("/courses/{course_id}")
def get_course(course_id: Annotated[int, Path(ge=1)]):
"""Get one course by its id."""
for course in COURSES:
if course["id"] == course_id:
return course
raise HTTPException(status_code=404, detail=f"Course {course_id} not found")
@app.post("/sentiment")
def sentiment(body: SentimentRequest):
"""Label a text as positive, negative or neutral (a stand-in for a real model)."""
words = body.text.lower().replace(".", " ").replace(",", " ").split()
score = sum(w in POSITIVE for w in words) - sum(w in NEGATIVE for w in words)
label = "positive" if score > 0 else "negative" if score < 0 else "neutral"
return {"label": label, "model": "keyword-v1"}fastapi run does not reload, so restart the server: stop it, wait until the old process has gone, start it again. (With fastapi dev you would simply save the file.)
kill $SERVER_PID
while kill -0 $SERVER_PID 2>/dev/null; do sleep 0.1; done
uv run fastapi run main.py --port 8000 > server.log 2>&1 &
export SERVER_PID=$!
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8000/health{"status":"ok"}Path parameters with types
"/courses/{course_id}" declares a path parameter: whatever is in that part of the URL is passed to the function argument with the same name. The type hint does the rest:
curl -s http://localhost:8000/courses/2
echo
curl -s http://localhost:8000/courses/42
echo
curl -s http://localhost:8000/courses/two
echo
curl -s http://localhost:8000/courses/0{"id":2,"title":"Python for AI","level":"beginner","hours":12}
{"detail":"Course 42 not found"}
{"detail":[{"type":"int_parsing","loc":["path","course_id"],"msg":"Input should be a valid integer, unable to parse string as an integer","input":"two"}]}
{"detail":[{"type":"greater_than_equal","loc":["path","course_id"],"msg":"Input should be greater than or equal to 1","input":"0","ctx":{"ge":1}}]}Four requests, four lessons:
course_id: intconverted"2"to the integer2before your function ran. Noisdigit(), noint().- A course that does not exist is your decision, so your code raises
HTTPException(status_code=404, detail=...). FastAPI turns it into a response with that status and{"detail": ...}. twois not an integer, so FastAPI answered422without calling your function. The body says where the problem is ("loc": ["path", "course_id"]), what went wrong (msg) and what it received (input).Path(ge=1)adds a rule: greater than or equal to 1. Zero breaks it, so another 422, with the rule inctx.
The Annotated[int, Path(ge=1)] form reads as "an int, plus this extra information for FastAPI". It is the style the FastAPI documentation recommends for all new code. You will still see the older style course_id: int = Path(ge=1) in blog posts; it works, but write the Annotated form.
Query parameters
Any function argument that is not in the path becomes a query parameter. A default value makes it optional; str | None = None means "optional, and None when missing":
curl -s "http://localhost:8000/courses?level=advanced"
echo
curl -s "http://localhost:8000/courses?level=beginner&limit=2"
echo
curl -s "http://localhost:8000/courses?limit=500"{"count":2,"items":[{"id":4,"title":"RAG Systems","level":"advanced","hours":10},{"id":5,"title":"AI Agents","level":"advanced","hours":12}]}
{"count":2,"items":[{"id":1,"title":"How AI Works","level":"beginner","hours":3},{"id":2,"title":"Python for AI","level":"beginner","hours":12}]}
{"detail":[{"type":"less_than_equal","loc":["query","limit"],"msg":"Input should be less than or equal to 50","input":"500","ctx":{"le":50}}]}Remember to quote URLs containing & in the shell, or the shell cuts the command there. limit was converted to an integer and checked against ge=1, le=50: a client asking for 500 courses gets a clear 422 instead of a huge response. For an AI API, the same kind of rule caps how many items a client may send to a model in one request.
| Declared as | Becomes | Example |
|---|---|---|
Name inside {} in the path | Path parameter, always required | course_id: Annotated[int, Path(ge=1)] |
| Simple type with a default | Optional query parameter | limit: Annotated[int, Query(ge=1, le=50)] = 10 |
| Simple type without a default | Required query parameter | q: str |
| A Pydantic model | JSON request body | body: SentimentRequest |
Request bodies
A function argument whose type is a Pydantic model (a class that inherits from BaseModel) is read from the JSON body. SentimentRequest says the body must be an object with a string field text:
curl -s -X POST http://localhost:8000/sentiment \
-H "Content-Type: application/json" \
-d '{"text": "Great course, the examples are clear."}'
echo
curl -s -X POST http://localhost:8000/sentiment -H "Content-Type: application/json" -d '{"txt": "typo"}'
echo
curl -s -X POST http://localhost:8000/sentiment -H "Content-Type: application/json" -d '{"text": 42}'{"label":"positive","model":"keyword-v1"}
{"detail":[{"type":"missing","loc":["body","text"],"msg":"Field required","input":{"txt":"typo"}}]}
{"detail":[{"type":"string_type","loc":["body","text"],"msg":"Input should be a valid string","input":42}]}Inside the function, body is a real Python object: body.text is guaranteed to exist and to be a string, so your code can just use it. A missing field and a wrong type each get a precise 422, and the loc now starts with "body". The next page is all about these models: limits on fields, nicer errors, and models for responses too.
def or async def? These endpoints are plain
deffunctions. FastAPI runs them in a thread pool, so slow or blocking code inside (arequestscall, a scikit-learn prediction) does not freeze other requests. Useasync defonly when everything you wait on inside is awaited with an async library. When in doubt, writedef.
Automatic documentation
FastAPI describes your API in an OpenAPI document, built from your paths, types, defaults and docstrings, and serves three views of it:
| URL | What it is |
|---|---|
/docs | Swagger UI: an interactive page where you can try every endpoint ("Try it out", then "Execute") |
/redoc | ReDoc: the same information as a clean, readable reference |
/openapi.json | The raw OpenAPI document: what tools, client generators and AI agents read |
Open http://localhost:8000/docs in your browser and try POST /sentiment from there. From the terminal you can check that the pages exist:
curl -s http://localhost:8000/docs | grep -o "<title>.*</title>"
curl -s http://localhost:8000/redoc | grep -o "<title>.*</title>"<title>BitByte Course API - Swagger UI</title>
<title>BitByte Course API - ReDoc</title>Calling your API from Python
Your API is just another web API, so the requests code from Calling APIs from Python with requests works unchanged. This also reads the OpenAPI document to list every endpoint:
import requests
BASE = "http://localhost:8000"
r = requests.get(f"{BASE}/courses", params={"level": "advanced", "limit": 1}, timeout=5)
print(r.status_code, r.headers["content-type"])
print(r.json())
r = requests.post(f"{BASE}/sentiment", json={"text": "Too slow and boring"}, timeout=5)
print(r.status_code, r.json())
r = requests.get(f"{BASE}/courses/42", timeout=5)
print(r.status_code, r.json())
spec = requests.get(f"{BASE}/openapi.json", timeout=5).json()
print(spec["info"])
for path, operations in spec["paths"].items():
for method, operation in operations.items():
print(f"{method.upper():5} {path:20} {operation['summary']}")200 application/json
{'count': 1, 'items': [{'id': 4, 'title': 'RAG Systems', 'level': 'advanced', 'hours': 10}]}
200 {'label': 'negative', 'model': 'keyword-v1'}
404 {'detail': 'Course 42 not found'}
{'title': 'BitByte Course API', 'version': '0.1.0'}
GET / Home
GET /health Health
GET /courses List Courses
GET /courses/{course_id} Get Course
POST /sentiment SentimentFastAPI made a summary for each endpoint from the function name (list_courses became "List Courses") and used your docstrings as descriptions. Good function names and docstrings are documentation for free. This matters more every year: AI agents and code assistants read /openapi.json to learn how to call an API.
Finally, the server log shows each request with its status code (the log lines also start with INFO: and the client's address, which grep -o cuts off here):
grep -oE '"(GET|POST) /courses.*' server.log"GET /courses/2 HTTP/1.1" 200 OK
"GET /courses/42 HTTP/1.1" 404 Not Found
"GET /courses/two HTTP/1.1" 422 Unprocessable Entity
"GET /courses/0 HTTP/1.1" 422 Unprocessable Entity
"GET /courses?level=advanced HTTP/1.1" 200 OK
"GET /courses?level=beginner&limit=2 HTTP/1.1" 200 OK
"GET /courses?limit=500 HTTP/1.1" 422 Unprocessable Entity
"GET /courses?level=advanced&limit=1 HTTP/1.1" 200 OK
"GET /courses/42 HTTP/1.1" 404 Not FoundUvicorn still prints the old name of 422, "Unprocessable Entity"; the current HTTP standard calls it "Unprocessable Content". Same code, same meaning.
Where this shows up in real AI work
- Model serving: a
POST /predictendpoint with a Pydantic body is how most teams put a trained model behind an API;/sentimentis exactly that shape. - LLM backends: a FastAPI app receives chat messages, calls the provider and returns or streams the reply; the advanced tutorial builds one.
- Tools for agents: an agent framework or MCP server can call your endpoints, and the OpenAPI schema tells it what each one needs.
- Internal APIs: data teams wrap a database query or a feature computation in a small FastAPI service so other teams can use it safely.
Common mistakes
- Forgetting to restart. With
fastapi run(or plainuvicornwithout--reload), edits are not picked up and you test old code. Fix: usefastapi devwhile developing. - Using
fastapi devin production. Reload watches files and wastes resources, and it listens only on127.0.0.1, so a container would be unreachable. Fix:fastapi runin Docker and on servers. - Returning an error dict with status 200.
return {"error": "not found"}still sends 200. Fix:raise HTTPException(status_code=404, detail="..."). - Path and function argument names that do not match.
@app.get("/courses/{id}")withdef get_course(course_id: int)makescourse_ida required query parameter, and every call fails with 422. Fix: use the same name in both places. - "Address already in use". Another server (often an old one you forgot) already listens on port 8000. Fix: stop it with Ctrl+C in its terminal, or start yours with
--port 8001.
Try it yourself
To keep the answers short and runnable, they add endpoints to the same app from a second file, answers.py, and run it on port 8001. In your own project, you would simply add them to main.py.
- Easy: Add
GET /levelsthat returns the sorted list of distinct levels:{"levels": ["advanced", "beginner"]}. - Medium: Add
GET /searchwith a required query parameterq(at least 2 characters) that returns the courses whose title containsq, ignoring case. What happens whenqis missing? - Hard: Add
POST /sentiment/batchthat takes{"texts": ["...", "..."]}and returns one label per text, reusing the existingsentimentfunction. Send two texts withrequests.
Answers
# file: answers.py
from typing import Annotated
from fastapi import Query
from pydantic import BaseModel
from main import COURSES, SentimentRequest, app, sentiment
@app.get("/levels")
def levels():
return {"levels": sorted({c["level"] for c in COURSES})}
@app.get("/search")
def search(q: Annotated[str, Query(min_length=2)]):
hits = [c for c in COURSES if q.lower() in c["title"].lower()]
return {"query": q, "count": len(hits), "items": hits}
class BatchRequest(BaseModel):
texts: list[str]
@app.post("/sentiment/batch")
def sentiment_batch(body: BatchRequest):
return {"labels": [sentiment(SentimentRequest(text=t))["label"] for t in body.texts]}uv run fastapi run answers.py --port 8001 > answers.log 2>&1 &
curl -s --retry 20 --retry-connrefused --retry-delay 0 http://localhost:8001/levels
echo
curl -s "http://localhost:8001/search?q=ai"
echo
curl -s "http://localhost:8001/search"{"levels":["advanced","beginner"]}
{"query":"ai","count":4,"items":[{"id":1,"title":"How AI Works","level":"beginner","hours":3},{"id":2,"title":"Python for AI","level":"beginner","hours":12},{"id":3,"title":"Maths for AI","level":"beginner","hours":8},{"id":5,"title":"AI Agents","level":"advanced","hours":12}]}
{"detail":[{"type":"missing","loc":["query","q"],"msg":"Field required","input":null}]}A parameter without a default is required, so a missing q is a 422 with "type": "missing". For the hard task, call the new endpoint from Python:
r = requests.post(
"http://localhost:8001/sentiment/batch",
json={"texts": ["I love the clear examples", "The video is broken"]},
timeout=5,
)
print(r.status_code, r.json())200 {'labels': ['positive', 'negative']}A batch endpoint is common in AI APIs: one request with many inputs is much cheaper than many requests with one input each. In a real API you would also cap the list length (the next page shows how).
Summary
- FastAPI turns typed Python functions into documented HTTP endpoints; it is the usual choice for Python AI backends.
- Start projects with
uv initanduv add "fastapi[standard]"(orpython -m venvandpip install); develop withfastapi dev, deploy withfastapi run. - Path parameters come from
{name}in the path, query parameters from other simple arguments, and the JSON body from a Pydantic model argument. Write extra rules withAnnotated[..., Path(...)]andAnnotated[..., Query(...)]. - Wrong input gets a 422 with the location, message and input, before your code runs; your own "not found" is
raise HTTPException(status_code=404, ...). - Every app has
/docs,/redocand/openapi.json, generated from your code, names and docstrings.
Next: Pydantic Models, Validation and Error Responses. You will describe data precisely with Pydantic, put limits on every field, hide fields from responses, and send the right status code for create, delete and "not found".