FastAPI Basics • Lesson 22

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:

FormatContent-TypeUsed By
JSONapplication/jsonJavaScript fetch, API clients
Formapplication/x-www-form-urlencodedHTML <form> elements
Multipartmultipart/form-dataFile uploads with forms

FastAPI uses the annotation to decide how to parse the incoming data:

  • Body() or no annotation on a Pydantic model -> expects JSON
  • Form() -> 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

FeatureBody ModelForm Model
AnnotationNone or Body()Form()
Content-Typeapplication/jsonapplication/x-www-form-urlencoded
Sent byAPI clients, fetchHTML forms
Nested objectsSupportedNot supported
File uploadsNot supportedSupported (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

  1. Always use the Form() annotation when receiving form data -- omitting it causes FastAPI to expect JSON
  2. Add validation with Field() to catch bad data early (min lengths, patterns, ranges)
  3. Use extra="forbid" to prevent clients from injecting unexpected form fields
  4. Keep sensitive fields out of responses -- never return passwords or credit card numbers
  5. 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