Skip to content

Forms and Body Inference

Lilya provides a powerful mechanism to parse request bodies that can seamlessly handle JSON payloads, URL-encoded forms, multipart forms (including file uploads), and mixed cases where form fields contain JSON-encoded strings.

This allows you to declare typed parameters in your handlers and let Lilya take care of converting incoming requests into strongly typed Python objects.

The infer_body flag

As you know, the body inference is activated via settings infer_body=True.

This will enable the following examples and explanations for your project.


Basic Form Handling

You can receive form data directly by annotating parameters in your handler.

This is the basic of the basics and works without the infer_body flag enabled as this is pretty much standard.

from lilya.requests import Request
from lilya.routing import Path

async def submit_form(request: Request):
    form = await request.form()
    return dict(form)

routes = [Path("/submit", handler=submit_form, methods=["POST"])]

Body Inference with Pydantic and Msgspec

When infer_body is enabled in your Lilya settings, Lilya will automatically attempt to parse the request body into the types declared in your handler signature.

Note

We use Pydantic and Msgspec as examples as they are used also as examples in the encoders section. You still need to create your encoders for this to work, anyway

Example: JSON vs Form

from pydantic import BaseModel
from msgspec import Struct

class User(BaseModel):
    name: str
    age: int

class Item(Struct):
    sku: str

async def process(user: User, item: Item):
    return {"user": user.model_dump(), "item": {"sku": item.sku}}

Sending JSON

POST /process
Content-Type: application/json

{
  "user": {"name": "lilya", "age": 10},
  "item": {"sku": "abc"}
}

Sending Form Data

POST /process
Content-Type: application/x-www-form-urlencoded

user={"name": "lilya", "age": 10}&item={"sku": "abc"}

Both will result in the same handler parameters being populated.


Nested Fields with Dotted Keys

Forms can’t natively encode nested structures. Lilya supports dotted key notation to express them:

POST /process
Content-Type: application/x-www-form-urlencoded

user.name=lilya&user.age=10&item.sku=abc

This expands automatically to:

{
  "user": {"name": "lilya", "age": 10},
  "item": {"sku": "abc"}
}

Pretty cool, right?


Lists with Bracket Notation

You can also use [] notation to represent lists in forms:

POST /items
Content-Type: application/x-www-form-urlencoded

items[0].sku=test1&items[1].sku=test2

Expands to:

{
  "items": [
    {"sku": "test1"},
    {"sku": "test2"}
  ]
}

When your handler signature expects items: list[Item], Lilya will pass a list of Item objects.


Nested JSON Strings

Sometimes form fields themselves contain JSON strings. Lilya recursively parses these into objects:

POST /items-meta
Content-Type: application/x-www-form-urlencoded

items[0].sku=test1&items[0].meta={"x": 1}

Expands to:

{
  "items": [
    {"sku": "test1", "meta": {"x": 1}}
  ]
}

File Uploads

Multipart forms with files are also supported. Simply annotate your handler with UploadFile:

from lilya.datastructures import DataUpload as UploadFile
from pydantic import BaseModel

class User(BaseModel):
    name: str
    age: int

async def upload(user: User, files: list[UploadFile]):
    return {
        "user": user.model_dump(),
        "files": [f.filename for f in files]
    }

Client request:

POST /upload
Content-Type: multipart/form-data

user={"name": "lilya", "age": 10}
files=@hello.txt

Result:

{
  "user": {"name": "lilya", "age": 10},
  "files": ["hello.txt"]
}

Notes

  • JSON payloads: Parsed directly
  • Forms: Converted to dicts and nested structures using dotted/bracket keys
  • Recursive JSON strings: Parsed anywhere inside the body
  • File uploads: Injected as UploadFile objects
  • Typed inference: list[Item], dict[str, User], tuple[Item, ...], etc. all supported

This makes Lilya’s body inference flexible enough to handle real-world form submissions without sacrificing strong typing.