Learn how to craft validated and user-friendly input forms for workflows.
Parameterizing workflows is a critical part of orchestration. It allows you to create contracts between modular workflows in your organization and empower less-technical users to interact with your workflows intuitively.Pydantic is a powerful library for data validation using Python type annotations, which is used by Prefect to build a parameter schema for your workflow.This allows you to:
check runtime parameter values against the schema (from the UI or the SDK)
build a user-friendly form in the Prefect UI
easily reuse parameter types in similar workflows
In this tutorial, we’ll craft a workflow signature that the Prefect UI will render as a self-documenting form.
Let’s say you have a workflow that triggers a marketing email blast which looks like:
Copy
@flowdef send_marketing_email( mailing_lists: list[str], subject: str, body: str, test_mode: bool = False, attachments: list[str] | None = None): """ Send a marketing email blast to the given lists. Args: mailing_lists: A list of lists to email. subject: The subject of the email. body: The body of the email. test_mode: Whether to send a test email. attachments: A list of attachments to include in the email. """ ...
When you deploy this flow, Prefect will automatically inspect your function signature and generate a form for you:This is good enough for many cases, but consider these additional constraints that could arise from business needs or tech stack restrictions:
there are only a few valid values for mailing_lists
the subject must not exceed 30 characters
no more than 5 attachments are allowed
You can simply check these constraints in the body of your flow function:
Copy
@flowdef send_marketing_email(...): if len(subject) > 30: raise ValueError("Subject must be less than 30 characters") if mailing_lists not in ["newsletter", "customers", "beta-testers"]: raise ValueError("Invalid list to email") if len(attachments) > 5: raise ValueError("Too many attachments") # etc...
but there are several downsides to this:
you have to spin up the infrastructure associated with your flow in order to check the constraints, which is wasteful if it turns out that bad parameters were provided
this might get duplicative, especially if you have similarly constrained parameters for different workflows
To improve on this, we will use pydantic to build a convenient, self-documenting, and reusable flow signature that the Prefect UI can build a better form from.
there are only a few valid values for mailing_lists
Say our valid mailing lists are: ["newsletter", "customers", "beta-testers"]We can define a Literal to specify the valid values for the mailing_lists parameter.
Copy
from typing import LiteralMailingList = Literal["newsletter", "customers", "beta-testers"]
Using a BaseModel subclass to group and constrain parameters
Both the subject and attachments parameters have constraints that we want to enforce.
the subject must not exceed 30 characters
the attachments must not exceed 5 items
Additionally, the subject, body, and attachments parameters are all related to the same thing: the content of the email.We can define a BaseModel subclass to group these parameters together and apply these constraints.
The resulting form looks like this:where the mailing_lists parameter renders as a multi-select dropdown that only allows the Literal values from our MailingList type.and any constraints you’ve defined on the EmailContent fields will be enforced before the run is submitted.
Using json_schema_extra to order fields in the form
By default, your flow parameters are rendered in the order defined by your @flow function signature.Within a given BaseModel subclass, parameters are rendered in the following order:
parameters with a default value are rendered first, alphabetically
parameters without a default value are rendered next, alphabetically
You can control the order of the parameters within a BaseModel subclass by passing json_schema_extra to the Field constructor with a position key.Taking our EmailContent model from the previous example, let’s enforce that subject should be displayed first, then body, then attachments.
Copy
class EmailContent(BaseModel): subject: str = Field( max_length=30, description="The subject of the email", json_schema_extra=dict(position=0), ) body: str = Field(default=..., json_schema_extra=dict(position=1)) attachments: list[str] = Field( default_factory=list, max_length=5, json_schema_extra=dict(position=2), )
If your parameter model includes Callable or Type fields, Prefect can’t serialize them
to JSON. The UI shows a placeholder like <MyParams> instead of actual values, and
automation templates can’t access individual fields.Pydantic’s ImportString
type solves this. It accepts a dotted import path as a string (e.g. "mymodule.my_func"),
resolves it to the real Python object at validation time, and serializes back to a string
for JSON.For example, an order ingestion flow that needs a different normalizer per vendor:
vendors.py
Copy
from datetime import datetimefrom typing import Anyfrom pydantic import BaseModelclass Order(BaseModel): order_id: str customer_email: str total_cents: int currency: str placed_at: datetimeclass StripeCharge(BaseModel): id: str receipt_email: str amount: int currency: str created: intclass ShopifyOrder(BaseModel): name: str email: str total_price: str currency: str created_at: strdef normalize_stripe(records: list[dict[str, Any]]) -> list[dict[str, Any]]: return [ { "order_id": r["id"], "customer_email": r["receipt_email"], "total_cents": r["amount"], "currency": r["currency"], "placed_at": datetime.fromtimestamp(r["created"]).isoformat(), } for r in records ]def normalize_shopify(records: list[dict[str, Any]]) -> list[dict[str, Any]]: return [ { "order_id": r["name"], "customer_email": r["email"], "total_cents": int(float(r["total_price"]) * 100), "currency": r["currency"], "placed_at": r["created_at"], } for r in records ]
Use ImportString in the parameter model so the normalizer and raw schema are editable
strings in the UI, but resolve to real Python objects at runtime:
ImportString requires that the referenced object is importable by dotted path. Lambdas,
closures, and objects defined in __main__ won’t work — move them to a named module
instead.
As you craft a schema for your flow signature, you may want to inspect the raw OpenAPI schema that pydantic generates, as it is what the Prefect UI uses to build the form.Call model_json_schema() on your BaseModel subclass to inspect the raw schema.