How to Support Both Form-Encoded and JSON Bodies at the Same URL in FastAPI
In modern API development, flexibility is key. Clients consuming your API may use different data formats: legacy systems might send form-encoded data (application/x-www-form-urlencoded), while modern applications prefer JSON (application/json). Supporting both formats at the same endpoint can simplify integration for diverse clients, reduce API surface area, and improve developer experience.
FastAPI, a high-performance Python framework for building APIs, excels at handling request bodies—but by default, an endpoint is configured to accept only one data format. In this guide, we’ll explore how to create a single FastAPI endpoint that seamlessly accepts both form-encoded and JSON bodies. We’ll cover parsing logic, validation, testing, and documentation to ensure robustness.
Table of Contents#
- Prerequisites
- Understanding FastAPI Request Bodies
- Step-by-Step Guide: Supporting Both Form and JSON Bodies
- Testing the Endpoint
- Enhancing OpenAPI Documentation
- Common Pitfalls and Solutions
- Conclusion
- References
Prerequisites#
Before diving in, ensure you have:
- Basic knowledge of FastAPI and Pydantic.
- Python 3.8+ installed.
- The following packages:
pip install fastapi uvicorn python-multipartfastapi: The core framework.uvicorn: ASGI server to run the app.python-multipart: Required for parsing form data.
Understanding FastAPI Request Bodies#
FastAPI automatically parses request bodies based on the Content-Type header and the endpoint’s parameter types. Let’s recap how it handles JSON and form-encoded data.
JSON Bodies#
To accept JSON, define a Pydantic model and use it as an endpoint parameter. FastAPI uses the application/json Content-Type header to trigger JSON parsing:
from pydantic import BaseModel
from fastapi import FastAPI
app = FastAPI()
class UserJSON(BaseModel):
username: str
email: str
password: str
@app.post("/users")
def create_user(user: UserJSON):
return {"user": user.dict(), "format": "json"} Request Example (JSON):
curl -X POST "http://localhost:8000/users" \
-H "Content-Type: application/json" \
-d '{"username": "johndoe", "email": "[email protected]", "password": "secret"}' Form-Encoded Bodies#
For form-encoded data (application/x-www-form-urlencoded), use FastAPI’s Form dependency. This parses key-value pairs from the request body:
from fastapi import Form
@app.post("/users")
def create_user(
username: str = Form(...),
email: str = Form(...),
password: str = Form(...)
):
return {"user": {"username": username, "email": email}, "format": "form"} Request Example (Form-Encoded):
curl -X POST "http://localhost:8000/users" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=johndoe&[email protected]&password=secret" Limitation: Single Content-Type per Endpoint#
The problem arises when you try to combine both approaches in one endpoint. FastAPI parses the request body once based on the parameter types. If you include both a Pydantic model (for JSON) and Form parameters, FastAPI will throw an error:
# ❌ This will FAIL!
@app.post("/users")
def create_user(
user: UserJSON, # JSON body
username: str = Form(...), # Form data (conflicting)
):
... Error: Multiple body parameters received, but only one body is allowed.
To support both formats, we need to manually inspect the request and parse the body based on the Content-Type header.
Step-by-Step Guide: Supporting Both Form and JSON Bodies#
We’ll build an endpoint that:
- Checks the
Content-Typeheader. - Parses the request body as JSON or form data accordingly.
- Validates the data with a shared Pydantic model.
Step 1: Define Pydantic Models#
First, create a Pydantic model to standardize validation for both formats. This ensures consistency regardless of how the data is sent:
from pydantic import BaseModel, EmailStr
class User(BaseModel):
username: str
email: EmailStr # Auto-validates email format
password: str
class Config:
extra = "forbid" # Reject unexpected fields Step 2: Access the Raw Request#
FastAPI lets you access the raw Request object (from starlette.requests) to inspect headers and body. Inject it into your endpoint to analyze the incoming request:
from fastapi import Request
@app.post("/users")
async def create_user(request: Request):
# Logic to parse body based on Content-Type
... Step 3: Detect Content-Type#
Check the Content-Type header to determine if the request is JSON or form-encoded. Handle edge cases like case sensitivity (e.g., Application/JSON) or extra parameters (e.g., application/json; charset=utf-8):
async def create_user(request: Request):
content_type = request.headers.get("Content-Type", "").lower()
if "application/json" in content_type:
# Parse as JSON
...
elif "application/x-www-form-urlencoded" in content_type:
# Parse as form data
...
else:
return {"error": "Unsupported Content-Type"}, 415 # 415 = Unsupported Media Type Step 4: Parse and Validate the Body#
Use the User model to validate the parsed data. For JSON, use request.json(); for form data, use request.form() (returns a FormData object, which we convert to a dict):
async def create_user(request: Request):
content_type = request.headers.get("Content-Type", "").lower()
try:
if "application/json" in content_type:
body = await request.json()
user = User(**body) # Validate with Pydantic
format_used = "json"
elif "application/x-www-form-urlencoded" in content_type:
form_data = await request.form()
# Convert FormData to dict (handles single values)
body = {key: form_data[key] for key in form_data}
user = User(** body) # Validate with Pydantic
format_used = "form"
else:
return {"error": "Unsupported Content-Type. Use application/json or application/x-www-form-urlencoded."}, 415
return {
"message": "User created successfully",
"user": user.dict(exclude={"password"}), # Omit password for security
"format": format_used
}
except Exception as e:
return {"error": str(e)}, 400 # 400 = Bad Request (validation failed) Testing the Endpoint#
Let’s verify the endpoint works with both formats using curl or tools like Postman.
Testing with JSON#
Send a JSON payload with Content-Type: application/json:
curl -X POST "http://localhost:8000/users" \
-H "Content-Type: application/json" \
-d '{"username": "johndoe", "email": "[email protected]", "password": "secure123"}' Response:
{
"message": "User created successfully",
"user": {
"username": "johndoe",
"email": "[email protected]"
},
"format": "json"
} Testing with Form-Encoded Data#
Send form-encoded data with Content-Type: application/x-www-form-urlencoded:
curl -X POST "http://localhost:8000/users" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=janedoe&[email protected]&password=secure456" Response:
{
"message": "User created successfully",
"user": {
"username": "janedoe",
"email": "[email protected]"
},
"format": "form"
} Enhancing OpenAPI Documentation#
FastAPI auto-generates OpenAPI docs (visit http://localhost:8000/docs), but our manual parsing hides the expected input schema. To fix this:
- Add a description to clarify supported formats.
- Include examples for both JSON and form data using
openapi_extra.
@app.post(
"/users",
description="Create a user with either JSON or form-encoded data.",
openapi_extra={
"requestBody": {
"content": {
"application/json": {
"examples": {
"json_example": {
"summary": "JSON Example",
"value": {"username": "johndoe", "email": "[email protected]", "password": "secret"}
}
}
},
"application/x-www-form-urlencoded": {
"examples": {
"form_example": {
"summary": "Form Example",
"value": {"username": "janedoe", "email": "[email protected]", "password": "secret"}
}
}
}
}
}
}
)
async def create_user(request: Request):
... # Existing logic Now, the docs will explicitly show both supported formats and examples.
Common Pitfalls and Solutions#
Reading the Request Body Multiple Times#
FastAPI streams the request body, so reading it twice (e.g., await request.json() followed by await request.form()) will cause errors. Fix: Read the body once and parse conditionally, as shown in Step 4.
Handling Edge Cases in Content-Type Headers#
Headers like application/json; charset=utf-8 or APPLICATION/X-WWW-FORM-URLENCODED will fail strict checks. Fix: Use in to check for substrings (e.g., "application/json" in content_type).
Validation Consistency#
If your JSON and form models have different fields, clients will get inconsistent errors. Fix: Use a single Pydantic model (e.g., User) for both formats, as we did earlier.
Conclusion#
By manually inspecting the Content-Type header and parsing the request body accordingly, you can build FastAPI endpoints that support both JSON and form-encoded data. This approach improves flexibility for clients while maintaining strong validation via Pydantic. Remember to enhance documentation and handle edge cases like header formatting to ensure robustness.