Skip to content
Guide

Request Data and Dependencies

Bind and validate typed request data, dataclass models, forms, and reusable dependencies.

Flasgo uses typing.Annotated markers to bind request data without requiring a separate validation package. Path parameters still come from the route, and unmarked non-path parameters remain query parameters for compatibility.

from dataclasses import dataclass
from typing import Annotated
from flasgo import Body, Cookie, Depends, Flasgo, Header, Query
@dataclass
class CreateWidget:
name: str
quantity: int
def page_size(
limit: Annotated[int, Query(alias="page-size")] = 20,
) -> int:
return limit
app = Flasgo()
@app.post("/widgets")
def create_widget(
payload: Annotated[CreateWidget, Body()],
limit: Annotated[int, Depends(page_size)],
client_version: Annotated[int, Header()] = 1,
session_id: Annotated[
str,
Cookie(alias="session-id"),
] = "anonymous",
) -> dict[str, object]:
return {
"name": payload.name,
"quantity": payload.quantity,
"limit": limit,
"client_version": client_version,
"session_id": session_id,
}

The markers select the wire source:

Marker Source and behavior
Body() One JSON body, usually a standard-library dataclass model
Query() A query-string value; supports an explicit alias
Header() A request header; underscores map to hyphens unless an alias is supplied. The reserved Accept, Authorization, Content-Type, and Cookie names raise ValueError at registration
Cookie() A scalar cookie value; uses the exact parameter name unless aliased
Form() One URL-encoded or multipart dataclass model
Depends() A value returned or yielded by a provider; see lifetimes below

Use markers inside Annotated; using a marker as the parameter default is rejected so static type checkers continue to see the real default value.

Put the marker on the outer annotation and the optional type inside it: write Annotated[str | None, Header()] = None, not Annotated[str, Header()] | None. In 0.10.0, a marker nested inside another type is rejected at route registration; earlier releases ignored it and read the value from the query string.

Dataclass fields may use scalar types, nested dataclasses, collections, tuples, unions, literals, and enums. Query, header, cookie, form, and untyped path text is converted to the declared type, so item_id: int on /items/<item_id> accepts /items/5. Numeric text must use plain ASCII decimal syntax such as 12, -3, or 1.5e3; 1_000, surrounding spaces, and non-ASCII digits are rejected. Unknown model fields, invalid media types, ambiguous duplicate scalar headers or cookies, and invalid values are rejected. JSON parsing also rejects excessive nesting, oversized integers, and non-finite constants such as NaN and Infinity with 400 Bad Request.

JSON values are not converted across types. Literal, Enum, and UUID fields require the exact JSON type, so true does not match Literal[1] and a UUID must be a JSON string. An integer too large for a float field, a set[Model] whose items are unhashable, and a dataclass __post_init__ that raises TypeError or ValueError all return 422 rather than 500.

Validation failures return 422 Unprocessable Content with bounded field locations, codes, and messages. Collections, mappings, and models report every invalid item up to MAX_VALIDATION_ISSUES, and an invalid value for Model | None reports the model’s own field errors. Rejected values and internal type names are not reflected in the default response. The same endpoint plan drives both runtime binding and OpenAPI generation, keeping aliases, required fields, reusable schemas, and validation responses aligned.

Only one Body() or Form() input may appear across an endpoint and its dependency graph. This keeps request-body consumption deterministic.

Form() accepts a dataclass and supports both URL-encoded and multipart requests:

from dataclasses import dataclass
from typing import Annotated
from flasgo import Form, UploadedFile
@dataclass
class ProfileForm:
display_name: str
interests: list[str]
avatar: UploadedFile | None = None
@app.post("/profiles")
async def create_profile(
profile: Annotated[ProfileForm, Form()],
):
return {
"display_name": profile.display_name,
"interests": profile.interests,
"avatar": (
profile.avatar.filename if profile.avatar else None
),
}

FormValidationError retains the parsed FormData so an explicit error handler can redisplay safe form fields. Its errors property maps each form field name to its messages, grouping errors for individual list items under the list field’s name, and issues holds the raw ValidationIssue tuple. Since 0.11.0, a form model whose __post_init__ raises ValueError or TypeError also produces a 422 FormValidationError that reaches form-error handlers with the parsed form, matching Body().

Uploaded-file fields may be a single UploadedFile, an optional file, or a list, set, or tuple of files. A fixed-length tuple such as tuple[UploadedFile, UploadedFile] requires exactly that many files, and every member type must accept an UploadedFile. Upload filenames have path components and control characters removed, followed by repeated trimming of surrounding whitespace and dots; a name that sanitizes to nothing becomes upload. Treat the result as an untrusted display name, generate your own storage name, and do not redisplay uploaded file contents.

URL-encoded forms decode percent-escapes with the declared charset (UTF-8 by default); invalid bytes return 400 Bad Request instead of being replaced.

Query strings, URL-encoded forms, and multipart text fields are capped by MAX_FORM_FIELDS (1000 by default). Multipart requests are also capped by MAX_MULTIPART_PARTS (1000 by default) before full parsing. Requests over either limit receive 413 Content Too Large.

Providers may be synchronous or asynchronous, may depend on other providers, and are cached once per request by default:

from typing import Annotated
from flasgo import Depends, Header
async def tenant_id(
tenant: Annotated[str, Header(alias="x-tenant-id")],
) -> str:
return tenant
@app.get("/tenant")
async def tenant_summary(
tenant: Annotated[str, Depends(tenant_id)],
):
return {"tenant": tenant}

A provider that returns an instance of the annotated dataclass, including User, is injected as-is rather than rebuilt from a mapping, so managed resources on it are not copied. Use Depends(provider, use_cache=False) only when repeated execution is intentional. Dependency cycles and conflicting duplicate wire-parameter schemas are rejected. Declare providers at module scope when using postponed annotations so Python can resolve their names reliably. When an annotation cannot be resolved at runtime, such as a name imported only under TYPE_CHECKING, unmarked handler parameters and return types fall back to forward references instead of failing registration, and a dataclass model keeps validating its other fields.

The database_session example below expects an application-owned database pool on app.state.database, whose session() method is an async context manager. Flasgo does not supply a database driver or ORM.

from typing import Annotated, Protocol, runtime_checkable
from flasgo import Depends, Flasgo
app = Flasgo()
@runtime_checkable
class Database(Protocol):
async def list_public_users(self) -> list[dict[str, object]]: ...
async def database_session():
async with app.state.database.session() as database:
yield database
@app.get("/users")
async def users(database: Annotated[Database, Depends(database_session)]):
return await database.list_public_users()

Providers may return an ordinary value, return an awaitable, or yield exactly one value from a sync/async generator. Use try/finally or a context manager inside the provider for cleanup. Application exceptions reach the provider at its yield; providers must re-raise exceptions rather than suppressing them. Resources close in reverse acquisition order, including when binding, validation, or the handler fails.

  • Depends(provider, scope="request"), the default, retains the resource until response sending finishes or fails. It supports a resource used by a streaming producer. Cleanup runs before background tasks.
  • Depends(provider, scope="function") closes the resource before response headers are sent. Use this scope for transaction boundaries whose commit failure must prevent a successful response.
  • A request-scoped provider cannot depend on a function-scoped provider. Cache identity includes the declared scope; use_cache=False acquires a separate value/resource for each binding.
  • A request-scoped cleanup failure after response delivery is logged; the framework cannot retract an already sent response. Critical transactions must finish before sending. Background tasks must acquire their own resources.

Ordinary synchronous providers retain their existing execution behavior. Keep their work short and nonblocking; use async database/network APIs for I/O. Cleanup and cancellation require cooperative application code.

Routes and blueprints accept dependencies=[Depends(...)] for providers whose result is not injected into a handler parameter, such as an audit or prerequisite check. Their request parameters participate in binding and OpenAPI. Inherited authorization and rate checks execute before these dependencies.

See the dependency override test for a complete replacement-provider example.

Overrides are context-local, nest safely, and restore automatically. They do not mutate global provider registrations or the public OpenAPI contract. Concurrent async tests can use different overrides on the same application. Replacement providers undergo the same binding, lifetime, and cycle checks. Declare providers at module scope when using postponed string annotations; Python 3.14’s native deferred annotations also work.

Every request shares bounded validation budgets:

  • MAX_VALIDATION_DEPTH limits recursive model and collection depth.
  • MAX_VALIDATION_WORK limits total validation effort, including union branches.
  • MAX_VALIDATION_ISSUES limits returned errors.

When a budget is reached, Flasgo stops further work and returns one bounded validation_limit or too_many_errors issue. Keep these limits enabled and add tests for deeply nested, oversized, and invalid payloads.

Dataclass handler return values are converted recursively to JSON. Underscore-prefixed fields are omitted at every dataclass level so private implementation fields are not serialized accidentally; ordinary mapping keys remain application data and are not filtered. JSON responses reject non-finite floats instead of emitting non-standard JSON.

Return annotations describe OpenAPI. To enforce an output schema and filter fields, opt into Response Contracts with response_model=.

Related docs:

Sources: