Lesson 3 - Headers, Cookies, and Forms

Welcome to Headers, Cookies, and Forms

So far, every input your Task Manager API has read came from one of three places: the path (/tasks/{task_id}), the query string (?status=done), or a JSON body parsed by a Pydantic model. But a real HTTP request carries more than that. The browser quietly attaches headers describing who’s calling, the client may send back cookies it was given earlier, and a plain HTML <form> submits its fields in a format that isn’t JSON at all.

FastAPI lets you read all three the same way you read everything else: by declaring a function parameter and giving it the right helper — Header, Cookie, or Form. No manual parsing, no digging through raw request objects. In this lesson you’ll wire up each one and learn exactly when a request should send form data instead of a JSON body.

By the end of this lesson, you will be able to:

  • Read a request header with Header(default=...) and explain the user_agentUser-Agent name conversion
  • Read a cookie with Cookie(default=None)
  • Accept HTML form submissions with Form(), and recognize when a form field is required
  • Decide when to use form data versus a JSON body

You’ll build on the endpoints from the earlier modules. Let’s begin.


Reading Request Headers

Every HTTP request comes with headers — small key/value pairs of metadata that travel alongside the body. They describe the request without being part of its content: what kind of client is calling (User-Agent), what formats it accepts (Accept), the content type of the body (Content-Type), and so on. Your browser sends a dozen of them on every page load without you ever noticing.

To read one in FastAPI, declare a parameter and default it with Header(...). Here we read the User-Agent header — the string a client uses to identify itself:

from fastapi import FastAPI, Header

app = FastAPI()

@app.get("/whoami")
def whoami(user_agent: str = Header(default="unknown")):
    return {"user_agent": user_agent}

Notice the parameter is named user_agent (with an underscore), but the actual header is User-Agent (with a hyphen). FastAPI bridges that gap automatically: it converts underscores in your parameter name to hyphens, and the match is case-insensitive. Python identifiers can’t contain hyphens, so this conversion is what lets you read hyphenated header names with ordinary Python names. You write user_agent; FastAPI reads User-Agent.

Let’s send a request with a custom User-Agent and see it come back:

from fastapi.testclient import TestClient

client = TestClient(app)
response = client.get("/whoami", headers={"User-Agent": "DataTweetsBot/1.0"})
print(response.status_code, response.json())
200 {'user_agent': 'DataTweetsBot/1.0'}

The header we set arrived in the user_agent parameter, exactly as expected. The default="unknown" means the parameter is optional: if a client sends no User-Agent at all, the endpoint still works and user_agent falls back to "unknown" instead of raising an error. (In practice almost every real client — browsers, mobile apps, command-line tools — sends a User-Agent, but defaulting it keeps your endpoint robust.)


Reading Cookies

A cookie is a small piece of data the server hands to a client, which the client then sends back on every subsequent request. Cookies are how websites remember you between page loads — a logged-in session, a theme preference, a shopping cart. Technically a cookie is just a specially formatted header, but it’s so common that FastAPI gives it a dedicated helper: Cookie.

You read a cookie the same way you read a header — declare a parameter, default it with Cookie(...). Let’s extend /whoami to also read a session cookie, the kind a logged-in Task Manager user would carry:

from fastapi import FastAPI, Header, Cookie

app = FastAPI()

@app.get("/whoami")
def whoami(
    user_agent: str = Header(default="unknown"),
    session: str | None = Cookie(default=None),
):
    return {"user_agent": user_agent, "session": session}

Here session is typed str | None with default=None, meaning “this cookie may or may not be present.” If the request carries a session cookie, we read it; if not, session is simply None. Let’s send both a header and a cookie:

client = TestClient(app)
response = client.get(
    "/whoami",
    headers={"User-Agent": "DataTweetsBot/1.0"},
    cookies={"session": "abc123"},
)
print(response.status_code, response.json())
200 {'user_agent': 'DataTweetsBot/1.0', 'session': 'abc123'}

Both inputs arrived in their respective parameters. The cookie value abc123 would, in a real app, be a session token your login endpoint set earlier — and now any endpoint can read it just by declaring a session: str | None = Cookie(default=None) parameter. That’s the whole point of cookies: the client carries them automatically so you don’t have to ask for them each time.


Reading Form Data

There’s one more way clients send input, and it predates JSON APIs entirely: the HTML form. When a user fills out a classic <form> on a web page and clicks submit, the browser packages the fields into a body with the content type application/x-www-form-urlencoded — not JSON. This is also how OAuth2 login flows send a username and password, which is why forms matter even for modern APIs.

To read form fields, declare parameters defaulted with Form(). Here’s a login endpoint for the Task Manager:

from fastapi import FastAPI, Form

app = FastAPI()

@app.post("/login")
def login(username: str = Form(), password: str = Form()):
    return {"username": username, "password_len": len(password)}

Notice that Form() is called with no default. That makes each field required: if a client submits the form without it, FastAPI rejects the request with a 422, just like a missing required query parameter or body field. Let’s submit a complete form:

from fastapi.testclient import TestClient

client = TestClient(app)
response = client.post("/login", data={"username": "ada", "password": "secret99"})
print(response.status_code, response.json())
200 {'username': 'ada', 'password_len': 8}

The key difference from earlier requests is data= instead of json=. Passing data= tells the client to send a form-encoded body, which is exactly what Form() reads. Now let’s leave out password and watch the validation fire:

response = client.post("/login", data={"username": "ada"})
print(response.status_code, response.json())
422 {'detail': [{'type': 'missing', 'loc': ['body', 'password'], 'msg': 'Field required', 'input': None}]}

A 422 with 'loc': ['body', 'password'] — FastAPI noticed the required form field was missing and rejected the request before your function ran. This is the same automatic validation you’ve relied on all along, now applied to form fields.

Form data versus a JSON body. Both arrive in the request body, so a single endpoint uses one or the other, never both. You reach for Form() when you’re handling a real HTML <form> submission or an OAuth2 login; you reach for a Pydantic model (a JSON body) for everything else — data APIs that talk to JavaScript front ends, mobile apps, or other services. For your Task Manager, creating and updating tasks will use JSON bodies; the login screen is the natural place for a form.

Form data needs the python-multipart package

Reading Form() (and uploaded files, in the next lesson) requires a small library called python-multipart, which parses form-encoded request bodies. You don’t have to install it separately if you set up your project with fastapi[standard] — it’s bundled in. If you ever see an error mentioning python-multipart when adding a form, that’s the fix: install it (pip install python-multipart) or use the [standard] extra.


Practice Exercises

Exercise 1: Read a custom header

Your Task Manager wants to read a custom X-Client-Version header so it can log which app version is calling. Write the parameter declaration that reads this header into a variable named x_client_version, defaulting to "0" when absent.

Hint

Write x_client_version: str = Header(default="0"). FastAPI converts the underscores in x_client_version to hyphens and matches X-Client-Version case-insensitively — so the Python-legal parameter name maps to the hyphenated header name for you.

You want an endpoint that reads a theme cookie (like "dark" or "light") but works fine for users who don’t have one set yet. Write the parameter.

Hint

Use theme: str | None = Cookie(default=None). The default=None makes the cookie optional, so a first-time visitor with no theme cookie still gets a successful response (with theme equal to None).

Exercise 3: Form or JSON?

For each, decide whether the endpoint should read form data (Form) or a JSON body (a Pydantic model): (a) a login page where a user types a username and password into an HTML <form>; (b) a JavaScript front end sending a new task as {"title": "Plan", "done": false}.

Hint

(a) is form data — a classic HTML <form> submits application/x-www-form-urlencoded, which Form() reads. (b) is a JSON body — structured data from code is the natural fit for a Pydantic model. Remember: one endpoint uses one or the other, never both.


Summary

Not every input arrives as a path parameter, query parameter, or JSON body. FastAPI lets you read three more sources by declaring a parameter with the matching helper. Header(default=...) reads request headers, converting your underscore parameter name to the hyphenated, case-insensitive header name (user_agentUser-Agent). Cookie(default=None) reads cookies the client carries back from earlier responses. Form() reads HTML form submissions sent as application/x-www-form-urlencoded; a Form() field with no default is required, and missing it yields a 422. Form support relies on the python-multipart package, bundled with fastapi[standard]. Crucially, an endpoint reads either form data or a JSON body, never both.

Key Concepts

  • Header — request metadata read with Header(default=...); underscores in the parameter become hyphens in the header name, matched case-insensitively.
  • Cookie — client-stored data read with Cookie(default=None), used for sessions and preferences.
  • Form — HTML form fields read with Form(); no default means required.
  • python-multipart — the package that parses form bodies, included with fastapi[standard].
  • Form vs JSON body — use forms for HTML <form> posts and OAuth2 login; use a JSON body for data APIs — one or the other per endpoint.

Why This Matters

The path, query, and JSON body cover most data APIs, but the moment you build a real web application you meet the rest: headers tell you who’s calling and let you version or trace requests; cookies are how authentication and user preferences survive between requests; and forms are how every plain HTML page and the standard OAuth2 login flow send data. Knowing that each is just a parameter with the right helper means you can read any part of an incoming request without ever touching raw HTTP — and it sets you up directly for the OAuth2 login work later in the course, which is built on Form().


Next Steps

Continue to Lesson 4 - Working with Files

Accept file uploads and send files back: UploadFile, file fields, and FileResponse downloads.

Back to Module Overview

Return to the HTTP Done Right module overview


Continue Building Your Skills

You can now read every part of an incoming request — headers, cookies, and form fields — each with a single, self-documenting parameter. Next you’ll handle the last piece of HTTP input and output: working with files, accepting uploads from clients and streaming files back as downloads.