Tutorial: A Simple FastAPI Project¶
This tutorial walks through creating a small FastAPI app that renders a form from a Pydantic model using pydantic-schemaforms.
It uses the async-first render API so large forms won’t block the event loop.
Prerequisites¶
- Python 3.14+
Note: model-first rendering¶
This tutorial uses the model-first API (recommended). You only need:
- Define a
FormModel - Render it (async) with
render_form_html_async()orFormModel.render_form_async() - If using Jinja templates, render
{{ form_html | safe }}
See configuration.md.
1) Create a project¶
mkdir schemaforms-fastapi-demo
cd schemaforms-fastapi-demo
python -m venv .venv
source .venv/bin/activate
2) Install dependencies¶
pip install "pydantic-schemaforms[fastapi]" uvicorn
3) Create main.py¶
Create a file named main.py:
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, render_form_html_async
class User(FormModel):
name: str = Field(...)
email: str = Field(..., ui_element="email")
app = FastAPI(title="SchemaForms Demo")
@app.api_route("/user", methods=["GET", "POST"], response_class=HTMLResponse)
async def user_form(request: Request):
if request.method == "POST":
submitted = dict(await request.form())
result = User.validate(submitted, submit_url="/user")
if result.is_valid:
return f"<p>Hello {result.data['name']}!</p>"
form_html = await result.render_with_errors_async()
else:
form_html = await render_form_html_async(User, submit_url="/user")
return f"""
<html>
<body>
<h1>User</h1>
{form_html}
</body>
</html>
"""
User.validate() stores the submit URL and framework so render_with_errors_async()
needs no arguments on failure — the re-render just works.
4) Run the server¶
uvicorn main:app --reload
Open http://127.0.0.1:8000/user
Sync vs Async (what’s the difference?)¶
render_form_html() (sync)¶
Use render_form_html() when your web framework is synchronous (WSGI) and you already have submitted data as a plain dict.
Typical environments:
- Flask / Django (classic request/response)
- CLI apps or scripts that validate a dict
Example (Flask):
from flask import Flask, request
from pydantic_schemaforms import Field, FormModel, render_form_html
class User(FormModel):
name: str = Field(...)
email: str = Field(..., ui_element="email")
app = Flask(__name__)
@app.route("/user", methods=["GET", "POST"])
def user_form():
if request.method == "POST":
result = User.validate(request.form.to_dict(), submit_url="/user")
if result.is_valid:
return f"Hello {result.data['name']}!"
return result.render_with_errors()
return render_form_html(User, submit_url="/user")
render_form_html_async() (async)¶
Use render_form_html_async() when you are in an async runtime (ASGI) and you are already await-ing things (like request.form() in FastAPI/Starlette).
Typical environments:
- FastAPI / Starlette
- Any async stack where you want to keep the request handler non-blocking
Important FastAPI note¶
FastAPI’s Request.form() is async, so the most natural implementation is an async def route and render_form_html_async().
If you already have a dict of submitted data (for example from a different parsing path), you can still call the sync renderer inside an async def route — but for large forms, the async renderer avoids blocking the event loop.
Dual-use: HTML form + JSON API from one model¶
FormModel is a plain Pydantic BaseModel subclass, so it can validate JSON
directly. The only friction is that Field(ui_element=..., ui_placeholder=...)
stores rendering metadata in the JSON schema, cluttering the OpenAPI docs when
the class is used as a FastAPI body type.
as_api_model() returns a new BaseModel with the same fields and validation
rules but no ui_* keys — exactly what a hand-written Pydantic model looks
like. Field(title=...), Field(description=...), Field(examples=[...]),
and all validation constraints (min_length, ge, pattern, …) are fully
preserved.
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pydantic_schemaforms import Field, FormModel, parse_nested_form_data
app = FastAPI()
class ContactForm(FormModel):
name: str = Field(
...,
min_length=2,
title="Full Name",
examples=["Alice Smith"],
ui_element="text",
ui_placeholder="Enter your full name",
)
email: str = Field(
...,
title="Email Address",
examples=["alice@example.com"],
ui_element="email",
)
message: str = Field(
...,
min_length=10,
title="Message",
examples=["Hello, I would like to ask about..."],
ui_element="textarea",
)
# Derive the clean API model once at module level — safe to use as a
# FastAPI body type or response_model.
ContactSchema = ContactForm.as_api_model()
# ── HTML form ────────────────────────────────────────────────────────────────
@app.get("/contact", response_class=HTMLResponse)
async def contact_get():
return await ContactForm.render_form_async(submit_url="/contact")
@app.post("/contact", response_class=HTMLResponse)
async def contact_post(request: Request):
data = parse_nested_form_data(await request.form())
result = ContactForm.validate(data, submit_url="/contact")
if result.is_valid:
return f"<p>Thank you, {result.data['name']}!</p>"
return HTMLResponse(await result.render_with_errors_async())
# ── JSON API — OpenAPI shows clean schema with examples ─────────────────────
@app.post("/api/contact", response_model=ContactSchema)
async def api_contact(data: ContactSchema):
# `data` is a fully validated ContactSchema instance.
# FastAPI's Swagger UI shows the request body and response schema without
# any ui_* keys, identical to a hand-written Pydantic model.
return data
What the OpenAPI schema looks like for the /api/contact body:
{
"properties": {
"name": { "minLength": 2, "title": "Full Name", "examples": ["Alice Smith"], "type": "string" },
"email": { "title": "Email Address", "examples": ["alice@..."], "type": "string" },
"message": { "minLength": 10,"title": "Message", "examples": ["Hello..."], "type": "string" }
},
"required": ["name", "email", "message"]
}
No ui_element, no ui_placeholder — just the information an API consumer
needs.
Next steps¶
- Learn about asset delivery (
asset_mode) in assets.md - See the broader integration pattern in quickstart.md
- Configure CSRF for browser + session workflows in csrf.md