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...
HeaderWhat it communicates
User-AgentWhich client is making the request (browser, curl, mobile app)
AcceptWhich response format the client expects
Content-TypeWhich format the data sent in the body has
AuthorizationAuthentication credentials
Accept-LanguageThe 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 headerPython parameter
User-Agentuser_agent
Accept-Languageaccept_language
X-Request-IDx_request_id
Content-Typecontent_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:

SourceDeclarationExample
PathPath()book_id: int = Path(...)
Query stringQuery()skip: int = Query(default=0)
BodyBody()book: dict = Body(...)
HeaderHeader()user_agent: str = Header(default=None)
CookieCookie()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

ApproachWhen to use itControl
return dictSimple responses, status 200Minimal — FastAPI decides status and headers
return dict + status_code in the decoratorA fixed status other than 200Fixed status, no custom headers
Injected ResponseAdding headers while keeping the return simpleCustom headers + a normal return
JSONResponseDynamic status or full controlStatus + 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-IDx_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-Agentuser_agent)
  • Cookies are read with Cookie() — the same pattern as Header(), Query(), Path(), and Body()
  • To add headers to the response, inject Response as a parameter and use response.headers["X-Name"] = "value"
  • JSONResponse gives 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, use JSONResponse
  • 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 of JSONResponse

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

  1. FastAPI - Header Parameters - Official documentation for reading headers with Header()
  2. FastAPI - Cookie Parameters - How to read cookies in endpoints
  3. FastAPI - Response directly - Using JSONResponse and Response for full control
  4. FastAPI - Custom Response - The available response types (JSONResponse, HTMLResponse, etc.)
  5. HTTP Headers - MDN - Complete reference of standard HTTP headers
  6. HTTP Cookies - MDN - A complete guide to how HTTP cookies work