Request Form Models
Declare form fields as Pydantic models instead of individual Form() parameters for cleaner form handling.
🎯 What You'll Learn
- •Group form fields into Pydantic models
- •Understand the difference between Form models and JSON Body models
- •Apply validation to form data with model constraints
- •Handle complex forms with many fields cleanly
Request Form Models
What You'll Learn
HTML forms send data as application/x-www-form-urlencoded or multipart/form-data, not JSON. When a form has many fields, declaring each one with Form() individually leads to bloated function signatures. FastAPI 0.115+ lets you group form fields into a Pydantic model.
By the end of this lesson, you'll understand how to:
- Group form fields into Pydantic models
- Understand the difference between Form models and JSON Body models
- Apply validation to form data with model constraints
- Handle complex forms with many fields cleanly
Form Data vs JSON
When a client sends data, the format matters:
| Format | Content-Type | Used By |
|---|---|---|
| JSON | application/json | JavaScript fetch, API clients |
| Form | application/x-www-form-urlencoded | HTML <form> elements |
| Multipart | multipart/form-data | File uploads with forms |
FastAPI uses the annotation to decide how to parse the incoming data:
Body()or no annotation on a Pydantic model -> expects JSONForm()-> expects form-encoded data
Individual Form Parameters
Without form models, you declare each form field separately:
from fastapi import FastAPI, Form
app = FastAPI()
@app.post("/register/")
def register(
username: str = Form(),
email: str = Form(),
password: str = Form(),
full_name: str | None = Form(default=None),
):
return {
"message": "User registered",
"username": username,
"email": email,
}
For a registration form with four fields this is manageable, but real-world forms can have ten or more fields -- billing address, shipping address, payment details, preferences, and so on.
Form Parameter Models
With FastAPI 0.115+, you can group form fields into a Pydantic model:
from fastapi import FastAPI, Form
from pydantic import BaseModel
app = FastAPI()
class RegistrationForm(BaseModel):
username: str
email: str
password: str
full_name: str | None = None
@app.post("/register/")
def register(form_data: RegistrationForm = Form()):
return {
"message": "User registered",
"username": form_data.username,
"email": form_data.email,
}
The Form() annotation is what tells FastAPI to read the model fields from form-encoded data instead of a JSON body.
Key Concepts
The Form() Annotation Is Critical
Without the Form() annotation, FastAPI treats a Pydantic model parameter as a JSON body:
# This expects a JSON body
@app.post("/register/")
def register(form_data: RegistrationForm):
...
# This expects form-encoded data
@app.post("/register/")
def register(form_data: RegistrationForm = Form()):
...
An HTML form submitting to the first endpoint would fail because the data arrives as application/x-www-form-urlencoded but FastAPI expects application/json.
Validation
Because the model is a standard Pydantic model, all validation features work:
from pydantic import BaseModel, Field, EmailStr
class RegistrationForm(BaseModel):
username: str = Field(min_length=3, max_length=30)
email: str
password: str = Field(min_length=8)
full_name: str | None = None
If a user submits a username shorter than 3 characters or a password shorter than 8, FastAPI returns a 422 validation error with details about which field failed.
Required vs Optional Fields
Fields without default values are required. If the form submission omits them, FastAPI returns a 422 error:
class RegistrationForm(BaseModel):
username: str # required
email: str # required
password: str # required
full_name: str | None = None # optional
Forbidding Extra Fields
You can reject form submissions that include unexpected fields:
class RegistrationForm(BaseModel):
model_config = {"extra": "forbid"}
username: str
email: str
password: str
full_name: str | None = None
A submission with an extra field like role=admin would be rejected, preventing clients from injecting unexpected data.
Accessing Individual Fields
Once FastAPI parses the form data into the model, you access fields as normal attributes:
@app.post("/register/")
def register(form_data: RegistrationForm = Form()):
print(form_data.username) # "johndoe"
print(form_data.email) # "john@example.com"
print(form_data.full_name) # "John Doe" or None
return {"username": form_data.username}
You can also convert the entire model to a dictionary with form_data.model_dump().
Comparison with Body Models
| Feature | Body Model | Form Model |
|---|---|---|
| Annotation | None or Body() | Form() |
| Content-Type | application/json | application/x-www-form-urlencoded |
| Sent by | API clients, fetch | HTML forms |
| Nested objects | Supported | Not supported |
| File uploads | Not supported | Supported (with multipart/form-data) |
Form data is flat -- it does not support nested objects the way JSON does. If you need to receive deeply nested structures, use a JSON body instead.
Real-World Example
A complete checkout form model:
class CheckoutForm(BaseModel):
model_config = {"extra": "forbid"}
first_name: str = Field(min_length=1)
last_name: str = Field(min_length=1)
email: str
address_line_1: str
address_line_2: str | None = None
city: str
state: str
zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")
card_number: str
expiry: str
cvv: str = Field(min_length=3, max_length=4)
@app.post("/checkout/")
def checkout(form: CheckoutForm = Form()):
return {"message": "Order placed", "email": form.email}
Without form models, this endpoint would need 12 individual Form() parameters.
Best Practices
- Always use the
Form()annotation when receiving form data -- omitting it causes FastAPI to expect JSON - Add validation with
Field()to catch bad data early (min lengths, patterns, ranges) - Use
extra="forbid"to prevent clients from injecting unexpected form fields - Keep sensitive fields out of responses -- never return passwords or credit card numbers
- Reuse form models across endpoints that handle the same form structure
Common Mistakes
Forgetting Form() annotation
# Wrong - expects JSON body
@app.post("/register/")
def register(form_data: RegistrationForm):
...
# Correct - expects form data
@app.post("/register/")
def register(form_data: RegistrationForm = Form()):
...
Trying to nest models in forms
# Wrong - form data is flat, cannot nest
class Address(BaseModel):
street: str
city: str
class OrderForm(BaseModel):
name: str
address: Address # won't work with Form()
# Correct - flatten the fields
class OrderForm(BaseModel):
name: str
address_street: str
address_city: str
What's Next?
You now know how to group form fields into Pydantic models. This completes the parameter model series covering query parameters, cookies, headers, and form data. Each uses the same pattern: define a Pydantic model and annotate with the appropriate source (Query(), Cookie(), Header(), or Form()).
💡 Hint
Create a Pydantic model with your form fields and annotate the parameter with Form().
Ready to Practice?
Now that you understand the theory, let's put it into practice with hands-on coding!
Start Interactive Lesson