Skip to content

Forms

Not every hunt starts with JSON. Browsers, legacy clients, and webhook payloads often arrive as application/x-www-form-urlencoded or multipart/form-data. VelociPy treats those fields as typed parameters, no different from query strings and headers: with typed, declarative parameters.

The Form(...) marker tells the framework to extract a value from the request body, coerce it to the declared Python type, and pass it to your handler. If a required field is missing or the type doesn't fit, you get a clean 422 response before your code runs.

No extra dependency for simple forms

URL-encoded form fields work out of the box. Multipart uploads with UploadFile require the multipart extra; see Uploads for that.

Basic form fields

Declare each field with Annotated[..., Form(...)]:

from typing import Annotated, Any

from velocipy import Form, VelociPy

app = VelociPy()


@app.post("/login")
async def login(
    username: Annotated[str, Form(...)],
    password: Annotated[str, Form(...)],
) -> dict[str, Any]:
    return {"username": username}

A request with body username=alice&password=secret yields:

{"username": "alice"}

Missing username or password returns 422 Unprocessable Entity.

Defaults and optional fields

Use Form(default) to make a field optional or give it a default value:

@app.post("/search")
async def search(
    q: Annotated[str, Form("")],
    limit: Annotated[int, Form(10)],
) -> dict[str, Any]:
    return {"q": q, "limit": limit}
Annotation Effect
Form(...) Required field; 422 if missing.
Form("") Optional string, defaults to empty.
Form(10) Optional integer, defaults to 10.
Form(False) Optional boolean, coerced from "true" / "false".

VelociPy coerces form values using the same rules as query parameters: integers, floats, booleans, and strings are all supported.

Use typed defaults

Declaring limit: Annotated[int, Form(10)] gives you an int in the handler. The framework converts the raw string "5" to 5 automatically.

Boolean and numeric coercion

Boolean fields accept common string representations:

@app.post("/preferences")
async def preferences(
    remember_me: Annotated[bool, Form(False)],
    dark_mode: Annotated[bool, Form(False)],
) -> dict[str, bool]:
    return {"remember_me": remember_me, "dark_mode": dark_mode}

These are all interpreted as True:

  • remember_me=true
  • remember_me=1
  • remember_me=yes

And these as False:

  • remember_me=false
  • remember_me=0
  • remember_me=
  • missing field

Form models

If you prefer to group fields into a model, you can define a msgspec.Struct or pydantic.BaseModel and declare it as a form parameter. VelociPy's serialization adapters validate the body against the model:

from pydantic import BaseModel
from typing import Annotated
from velocipy import Form, VelociPy


class LoginForm(BaseModel):
    username: str
    password: str
    remember_me: bool = False


app = VelociPy()


@app.post("/login-model")
async def login_model(form: Annotated[LoginForm, Form(...)]):
    return {"username": form.username, "remember_me": form.remember_me}

Model-backed forms may need the multipart extra

Model validation of form bodies works through the configured serialization adapter. Multipart file uploads still require pip install -e ".[multipart]".

Aliases and descriptions

Like Query, Header, and Cookie, Form supports alias and description:

@app.post("/subscribe")
async def subscribe(
    email: Annotated[str, Form(..., description="Subscriber email")],
    list_id: Annotated[str, Form(..., alias="list-id")],
):
    return {"email": email, "list_id": list_id}

The alias is used when reading the form field, while the parameter name is what your handler works with.

Mixing form and other parameters

Form fields can coexist with query parameters, headers, and path parameters in the same handler:

from typing import Annotated
from velocipy import Form, Header, Query, VelociPy


@app.post("/items/{item_id}")
async def update_item(
    item_id: int,
    name: Annotated[str, Form(...)],
    category: Annotated[str, Query("")],
    x_request_id: Annotated[str | None, Header()] = None,
):
    return {
        "item_id": item_id,
        "name": name,
        "category": category,
        "request_id": x_request_id,
    }

The framework sorts every parameter by its marker: path values from the route, query from the URL, headers from the request, and form values from the body.

At a glance

Feature Supported? How
Required fields Yes Form(...)
Typed defaults Yes Form(default)
Booleans / ints / floats Yes Automatic coercion
Field aliases Yes Form(..., alias="...")
Model validation Yes Annotated[Model, Form(...)]
Multipart uploads Yes UploadFile with multipart extra
OpenAPI docs Yes Generated automatically

See also