Module 3: Request and Response
Headers, Cookies and Response Structure
Capsule overview
So far you've worked with data that travels in the URL (path params, query params) and in the request body. But HTTP has more channels of information. Headers are the metadata that accompanies every request and every response — they say things like "I'm a Chrome browser", "I accept JSON", or "here's my authentication token." Cookies are small values the browser stores and sends automatically with every request. And your API's response isn't just the JSON you return — it includes status codes, response headers, and a structure your clients need to interpret.
In this capsule you're going to learn to read headers and cookies in FastAPI, add custom headers to your responses, use JSONResponse for full control over the status code, and design a consistent response structure.
FastAPI makes working with headers and cookies as simple as declaring a parameter with Header() or Cookie() — the same pattern you already know from Query(), Path(), and Body(). The framework's consistency really shines here.
HTTP headers: request metadata
What are headers?
Every HTTP request has two parts: the content (the URL, the body) and the metadata (the headers). Headers are key-value pairs that give context about the request without being part of the data itself.
When your browser makes a request, it sends headers like these:
GET /books HTTP/1.1
Host: 127.0.0.1:8000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64)
Accept: application/json
Content-Type: application/json
Authorization: Bearer eyJhbGciOi...
| Header | What it communicates |
|---|---|
User-Agent | Which client is making the request (browser, curl, mobile app) |
Accept | Which response format the client expects |
Content-Type | Which format the data sent in the body has |
Authorization | Authentication credentials |
Accept-Language | The client's preferred language |
Headers are invisible to the end user, but your API can read them and act accordingly.
Reading headers in FastAPI
FastAPI uses Header() — exactly like Query() or Path(), but for headers:
from fastapi import FastAPI, Header
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical Realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental Novel"},
]
@app.get("/info")
def get_info(user_agent: str = Header(default=None)):
return {
"user_agent": user_agent,
"total_books": len(books)
}
Test it with curl:
curl -s http://127.0.0.1:8000/info | python -m json.tool
{
"user_agent": "curl/8.1.2",
"total_books": 3
}
FastAPI read the User-Agent header that curl sends automatically and passed it into your function as user_agent.
Automatic name conversion
HTTP headers use kebab-case (User-Agent, Content-Type), but Python doesn't allow hyphens in variable names. FastAPI solves this automatically: it converts hyphens into underscores.
| HTTP header | Python parameter |
|---|---|
User-Agent | user_agent |
Accept-Language | accept_language |
X-Request-ID | x_request_id |
Content-Type | content_type |
You don't need to do anything — just name your parameter with underscores and FastAPI will find the matching header.
Custom headers
Reading custom headers
APIs frequently use custom headers starting with X- to carry extra information. A common case is X-Request-ID — a unique identifier for tracing a request:
from fastapi import FastAPI, Header
app = FastAPI()
# ... (same books list from the previous examples)
@app.get("/books")
def list_books(x_request_id: str = Header(default=None)):
response = {"data": books, "count": len(books)}
if x_request_id:
response["request_id"] = x_request_id
return response
curl -s -H "X-Request-ID: abc-123-def" http://127.0.0.1:8000/books | python -m json.tool
{
"data": [...],
"count": 3,
"request_id": "abc-123-def"
}
Without the header, the request_id field simply doesn't appear. The default=None is what makes the header optional.
You can read multiple headers in the same endpoint — each one as an independent parameter with Header(), identical to the pattern with Query().
Cookies: persistent client data
What are cookies?
Cookies are small values the server sends to the client (the browser), and which the client sends back automatically on every subsequent request. They're used for user preferences, session IDs, or tokens.
In REST APIs, cookies are less common than headers — modern APIs prefer tokens in the Authorization header. But FastAPI supports them and it's useful to know the pattern.
Reading cookies in FastAPI
FastAPI uses Cookie() — the same pattern as Header() and Query():
from fastapi import FastAPI, Cookie
app = FastAPI()
@app.get("/settings")
def get_settings(theme: str = Cookie(default="light")):
return {
"current_theme": theme,
"available_themes": ["light", "dark", "system"],
}
curl -s -b "theme=dark" http://127.0.0.1:8000/settings | python -m json.tool
{
"current_theme": "dark",
"available_themes": ["light", "dark", "system"]
}
Without a cookie, the default value is used ("light"). The -b flag in curl simulates sending a cookie. In a real browser, cookies are sent automatically.
FastAPI's consistent pattern
Look at the symmetry. FastAPI uses the same pattern for every data source:
| Source | Declaration | Example |
|---|---|---|
| Path | Path() | book_id: int = Path(...) |
| Query string | Query() | skip: int = Query(default=0) |
| Body | Body() | book: dict = Body(...) |
| Header | Header() | user_agent: str = Header(default=None) |
| Cookie | Cookie() | theme: str = Cookie(default="light") |
Each one tells FastAPI where to read the value from. The parameter's type (str, int, dict) tells it how to validate it. Learn one, and you understand them all.
Custom response headers
Adding headers to the response
Up to here you've read headers from the request (the ones the client sends). Now you're going to add headers to the response (the ones your server sends). This is useful for sending metadata without polluting the body.
FastAPI gives you the Response object, which you can inject as a parameter:
from fastapi import FastAPI, Response
app = FastAPI()
# ... (same books list)
@app.get("/books")
def list_books(response: Response):
response.headers["X-Total-Count"] = str(len(books))
response.headers["X-API-Version"] = "1.0"
return books
Check it with curl in verbose mode:
curl -v http://127.0.0.1:8000/books 2>&1 | head -20
< HTTP/1.1 200 OK
< x-total-count: 3
< x-api-version: 1.0
< content-type: application/json
The X-Total-Count and X-API-Version headers show up in the response. The body is still the normal JSON with the list of books.
Why headers instead of fields in the body?
The X-Total-Count header is a common pattern in paginated APIs. The body holds the data (the list of books) and the headers hold metadata about the response. Keeping them separate keeps the structure clean:
# ❌ Metadata mixed in with the data
{"books": [...], "total_count": 3, "api_version": "1.0"}
# ✅ Data in the body, metadata in the headers
# Body: [...]
# Headers: X-Total-Count: 3, X-API-Version: 1.0
When you inject Response as a parameter, FastAPI knows it's a special object for manipulating the response — you don't need Header() or any other marker. Header values must be strings (that's why str(len(books))).
JSONResponse: full control over the response
When you need more control
Normally you return a dict and FastAPI turns it into JSON with a status of 200. But sometimes you need to control the status code dynamically — for example, returning a 404 when a resource doesn't exist:
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
# ... (same books list)
def find_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
return None
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = find_book(book_id)
if not book:
return JSONResponse(
status_code=404,
content={"error": "Book not found", "book_id": book_id}
)
return book
curl -s http://127.0.0.1:8000/books/1 | python -m json.tool
{
"id": 1,
"title": "One Hundred Years of Solitude",
"author": "Gabriel García Márquez",
"year": 1967,
"genre": "Magical Realism"
}
curl -s -w "\nStatus: %{http_code}\n" http://127.0.0.1:8000/books/999
{"error":"Book not found","book_id":999}
Status: 404
JSONResponse also takes a headers parameter, so you can combine a custom status code with custom headers:
@app.get("/books")
def list_books():
return JSONResponse(
status_code=200,
content=books,
headers={"X-Total-Count": str(len(books))},
)
With JSONResponse you have explicit control over the three components of an HTTP response: status code, body, and headers.
A consistent response structure
Wrapper functions for consistency
Without a defined pattern, every endpoint returns data in a different shape — lists, dicts with wrappers, loose messages. The client has to guess the structure. Define functions that standardize it:
from fastapi import FastAPI, Body
from fastapi.responses import JSONResponse
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical Realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental Novel"},
]
def success_response(data, message="OK"):
return {"status": "success", "message": message, "data": data}
def error_response(message, status_code=400):
return JSONResponse(
status_code=status_code,
content={"status": "error", "message": message, "data": None},
)
def find_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
return None
def generate_id():
if not books:
return 1
return max(book["id"] for book in books) + 1
@app.get("/books")
def list_books():
return success_response(data=books, message=f"{len(books)} books found")
@app.get("/books/{book_id}")
def get_book(book_id: int):
book = find_book(book_id)
if not book:
return error_response(f"Book with id {book_id} not found", status_code=404)
return success_response(data=book)
@app.post("/books", status_code=201)
def create_book(book: dict = Body(...)):
book["id"] = generate_id()
books.append(book)
return success_response(data=book, message="Book created")
Now every response follows the same structure:
Success returns {"status": "success", "message": "OK", "data": {...}}. An error returns {"status": "error", "message": "...", "data": null} with the correct HTTP status code. The client can always check response["status"], read response["message"], and reach for response["data"]. Predictable and consistent.
Comparison: response approaches in FastAPI
| Approach | When to use it | Control |
|---|---|---|
return dict | Simple responses, status 200 | Minimal — FastAPI decides status and headers |
return dict + status_code in the decorator | A fixed status other than 200 | Fixed status, no custom headers |
Injected Response | Adding headers while keeping the return simple | Custom headers + a normal return |
JSONResponse | Dynamic status or full control | Status + body + headers |
In most endpoints you'll use return dict. You save JSONResponse for when the status code varies with the logic (like a 404 when a resource isn't found). In Module 5 you'll learn HTTPException, FastAPI's idiomatic way of handling errors — built on top of JSONResponse.
Troubleshooting
Problem 1: The header comes through as None even though you're sending it
Cause: The parameter name doesn't match the header (you forgot the kebab-case → snake_case conversion).
# ❌ "X-Request-ID" doesn't map to "request_id"
@app.get("/items")
def list_items(request_id: str = Header(default=None)):
...
# ✅ "X-Request-ID" maps to "x_request_id"
@app.get("/items")
def list_items(x_request_id: str = Header(default=None)):
...
The rule: take the header name, replace hyphens with underscores, and lowercase it. X-Request-ID → x_request_id.
Problem 2: value is not a valid integer when reading a header
Cause: You declared the header as an int, but HTTP headers are strings.
# ❌ Headers are strings
@app.get("/items")
def list_items(x_page_size: int = Header(default=10)):
...
# ✅ Receive it as a string and convert
@app.get("/items")
def list_items(x_page_size: str = Header(default="10")):
page_size = int(x_page_size)
...
FastAPI can attempt the conversion automatically, but it's safer to receive them as str and convert explicitly.
Problem 3: JSONResponse doesn't show up in Swagger with the right schema
Cause: Swagger builds the documentation from the declared return type. If you return JSONResponse without a response_model, Swagger doesn't know what structure to expect.
Solution: This is a trade-off. JSONResponse gives you control but loses the automatic documentation. In Module 4, with Pydantic models and response_model, the documentation will be automatic again.
Problem 4: Custom headers don't show up in the browser's response
Cause: CORS can block access to custom headers from JavaScript. The browser receives the headers but doesn't expose them to JS code without Access-Control-Expose-Headers.
Solution: This gets fixed in Module 5, with the CORS configuration. From curl or /docs the headers are always visible.
Exercises
Exercise 1: A diagnostics endpoint (Easy)
Build a GET /diagnostics endpoint that reads the User-Agent, Accept-Language, and X-Client-Version headers. Return the three values along with a server timestamp. Use from datetime import datetime.
See solution
from datetime import datetime
from fastapi import FastAPI, Header
app = FastAPI()
@app.get("/diagnostics")
def get_diagnostics(
user_agent: str = Header(default=None),
accept_language: str = Header(default=None),
x_client_version: str = Header(default=None),
):
return {
"client": user_agent,
"language": accept_language,
"client_version": x_client_version,
"server_time": datetime.now().isoformat(),
}
curl -s \
-H "Accept-Language: es-MX" \
-H "X-Client-Version: 2.1.0" \
http://127.0.0.1:8000/diagnostics | python -m json.tool
{
"client": "curl/8.1.2",
"language": "es-MX",
"client_version": "2.1.0",
"server_time": "2026-03-13T14:30:00.123456"
}
curl sends User-Agent automatically; the other two we send ourselves with -H.
Exercise 2: A list with pagination headers (Medium)
Change the GET /books endpoint to accept skip and limit query params, return the matching subset, and add the response headers X-Total-Count, X-Page-Size, and X-Offset.
See solution
from fastapi import FastAPI, Query, Response
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical Realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental Novel"},
{"id": 4, "title": "The Aleph", "author": "Jorge Luis Borges", "year": 1949, "genre": "Short stories"},
{"id": 5, "title": "Pedro Páramo", "author": "Juan Rulfo", "year": 1955, "genre": "Novel"},
]
@app.get("/books")
def list_books(
response: Response,
skip: int = Query(default=0),
limit: int = Query(default=3),
):
paginated = books[skip : skip + limit]
response.headers["X-Total-Count"] = str(len(books))
response.headers["X-Page-Size"] = str(limit)
response.headers["X-Offset"] = str(skip)
return paginated
curl -v -s "http://127.0.0.1:8000/books?skip=0&limit=2" 2>&1 | head -15
< x-total-count: 5
< x-page-size: 2
< x-offset: 0
The body holds only the books on the current page. The headers communicate the pagination information without polluting the data.
Exercise 3: Search with a consistent response (Medium)
Build a GET /books/search endpoint that takes a q query param (the search text). Search in the title and the author (case-insensitive). Use the success_response and error_response wrapper pattern. If q is empty or missing, return an error.
See solution
from fastapi import FastAPI, Query
from fastapi.responses import JSONResponse
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical Realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental Novel"},
]
def success_response(data, message="OK"):
return {"status": "success", "message": message, "data": data}
def error_response(message, status_code=400):
return JSONResponse(
status_code=status_code,
content={"status": "error", "message": message, "data": None},
)
@app.get("/books/search")
def search_books(q: str = Query(default=None)):
if not q or not q.strip():
return error_response("Search query 'q' is required")
query = q.lower()
results = [
book for book in books
if query in book["title"].lower() or query in book["author"].lower()
]
return success_response(
data=results,
message=f"{len(results)} results for '{q}'"
)
Searching ?q=cortázar returns {"status": "success", "message": "1 results for 'cortázar'", "data": [...]}. Without q, it returns {"status": "error", "message": "Search query 'q' is required", "data": null} with status 400.
Exercise 4: DELETE with dynamic status codes (Medium)
Add a DELETE /books/{book_id} endpoint that returns 204 (no body) if the book exists and gets deleted, or 404 with an error message if it doesn't exist. Use JSONResponse for both cases.
See solution
from fastapi import FastAPI
from fastapi.responses import JSONResponse
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical Realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental Novel"},
]
def find_book(book_id: int):
for book in books:
if book["id"] == book_id:
return book
return None
@app.delete("/books/{book_id}")
def delete_book(book_id: int):
book = find_book(book_id)
if not book:
return JSONResponse(
status_code=404,
content={"error": f"Book {book_id} not found"},
)
books.remove(book)
return JSONResponse(status_code=204, content=None)
curl -s -w "\nStatus: %{http_code}\n" -X DELETE http://127.0.0.1:8000/books/1
Status: 204
curl -s -w "\nStatus: %{http_code}\n" -X DELETE http://127.0.0.1:8000/books/999
{"error":"Book 999 not found"}
Status: 404
The status code depends on the logic: 204 if it was deleted, 404 if it doesn't exist. JSONResponse is the right tool here.
Exercise 5: Combining headers, cookies, and response wrappers (Hard)
Build a GET /books endpoint that: (1) reads an X-Request-ID header, (2) reads a preferred_genre cookie, (3) filters books by the cookie's genre if it's there, (4) includes the response headers X-Total-Count and X-Request-ID (echoing the request), and (5) returns through the success_response wrapper.
See solution
from fastapi import FastAPI, Header, Cookie, Response
app = FastAPI()
books = [
{"id": 1, "title": "One Hundred Years of Solitude", "author": "Gabriel García Márquez", "year": 1967, "genre": "Magical Realism"},
{"id": 2, "title": "Don Quixote", "author": "Miguel de Cervantes", "year": 1605, "genre": "Novel"},
{"id": 3, "title": "Hopscotch", "author": "Julio Cortázar", "year": 1963, "genre": "Experimental Novel"},
{"id": 4, "title": "The House of the Spirits", "author": "Isabel Allende", "year": 1982, "genre": "Magical Realism"},
]
def success_response(data, message="OK"):
return {"status": "success", "message": message, "data": data}
@app.get("/books")
def list_books(
response: Response,
x_request_id: str = Header(default=None),
preferred_genre: str = Cookie(default=None),
):
results = books
if preferred_genre:
results = [
book for book in books
if book["genre"].lower() == preferred_genre.lower()
]
response.headers["X-Total-Count"] = str(len(results))
if x_request_id:
response.headers["X-Request-ID"] = x_request_id
message = f"{len(results)} books found"
if preferred_genre:
message += f" (filtered by genre: {preferred_genre})"
return success_response(data=results, message=message)
curl -s \
-H "X-Request-ID: req-abc-123" \
-b "preferred_genre=Magical Realism" \
http://127.0.0.1:8000/books | python -m json.tool
{
"status": "success",
"message": "2 books found (filtered by genre: Magical Realism)",
"data": [
{"id": 1, "title": "One Hundred Years of Solitude", ...},
{"id": 4, "title": "The House of the Spirits", ...}
]
}
The endpoint combines all three concepts: it reads a header (X-Request-ID), reads a cookie (preferred_genre), and writes response headers. Echoing back the X-Request-ID is a traceability pattern — the client sends an ID and the server returns it to confirm it processed that specific request.
Summary
- HTTP headers are request metadata; FastAPI reads them with
Header()as a function parameter - FastAPI automatically converts header names from kebab-case to snake_case (
User-Agent→user_agent) - Cookies are read with
Cookie()— the same pattern asHeader(),Query(),Path(), andBody() - To add headers to the response, inject
Responseas a parameter and useresponse.headers["X-Name"] = "value" JSONResponsegives you full control: dynamic status code, body, and headers in a single call- A wrapper pattern (
success_response/error_response) keeps the response structure consistent - For a fixed status code, use
status_code=in the decorator; for a dynamic status, useJSONResponse - Header values must be strings — convert with
str()if you need to - FastAPI's consistent pattern (
Query,Path,Body,Header,Cookie) is your best tool: learn one, and you understand them all - In Module 5 you'll learn
HTTPException, FastAPI's idiomatic way of handling errors — built on top ofJSONResponse
Next capsule: You'll combine path params, query params, body, and headers into complex endpoints — pulling together everything you learned in this module.
Additional resources
- FastAPI - Header Parameters - Official documentation for reading headers with Header()
- FastAPI - Cookie Parameters - How to read cookies in endpoints
- FastAPI - Response directly - Using JSONResponse and Response for full control
- FastAPI - Custom Response - The available response types (JSONResponse, HTMLResponse, etc.)
- HTTP Headers - MDN - Complete reference of standard HTTP headers
- HTTP Cookies - MDN - A complete guide to how HTTP cookies work