Forms
Goals
- Understand how HTML forms send data to a server using POST requests.
- Add a route that deletes a sighting when a user clicks a button.
- Add routes that let a user fill in a form to add a new sighting.
- Add routes that let a user upload a CSV file to add many sightings at once.
- Test routes that modify the database.
Deleting a Record
What is the difference between a GET request and a POST request?
- Every HTTP request uses a method that tells the server what the client wants to do
- GET asks the server to return data without changing anything
- Browsers send GET requests when a user types a URL or clicks a link
- POST asks the server to process data the browser is sending, usually by writing to a database
- Browsers send POST requests when a user submits a form
- Deleting a record must use POST, not GET
- Search engines and browser prefetch features send GET requests to URLs they discover automatically
- If a link could trigger a deletion, those tools could wipe out data without anyone asking them to
Modify
server_db.pyto createserver_delete.py. Add a Delete button to the sighting detail page that sends a POST request to remove the sighting and then sends the browser back to the home page.
- An HTML form groups one or more inputs together with a submit button
<form method="post" action="/delete/1">sends a POST request to/delete/1when submitted- A
<button type="submit">inside the form triggers the submission when clicked - This form needs no text inputs: the sighting ID is already in the URL
@postin Litestar marks a handler that responds to POST requests, just as@getmarks one that responds to GETdelete from sightings where id = ?removes the matching row;conn.commit()writes the change to disk- After deleting, the handler returns a HTTP 303 redirect
that tells the browser to fetch another URL
Redirect("/", status_code=303)sends the browser to the home page- This is called the Post/Redirect/Get pattern
- If the user presses Refresh afterward, the browser replays the GET, not the POST, so the record is not deleted a second time
@get("/sighting/{sighting_id:int}", media_type=MediaType.HTML)
async def detail(sighting_id: int) -> str:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
"select * from sightings where id = ?", [sighting_id]
).fetchone()
conn.close()
if row is None:
raise NotFoundException(f"No sighting with ID {sighting_id}")
return str(
html(lang="en")[
head[
title[f"Sighting {sighting_id}"],
link(rel="stylesheet", href="/style.css"),
],
body[
table[
[
tr[
td(class_="label")[lbl],
td[fmt(row[key])],
]
for key, lbl in LABELS.items()
],
],
a(href="/")["Back to all sightings"],
form(method="post", action=f"/delete/{sighting_id}")[
button(type="submit")["Delete this sighting"],
],
],
]
)
@post("/delete/{sighting_id:int}")
async def delete_sighting(sighting_id: int) -> Redirect:
conn = sqlite3.connect(db_path)
conn.execute("delete from sightings where id = ?", [sighting_id])
conn.commit()
conn.close()
return Redirect("/", status_code=303)
Adding a Record
Add a link to the home page and a
GET /addroute that displays a form for adding a new sighting. Save the result asserver_add.py.
<form method="post" action="/add">sends the completed form to/addwhen submitted<input type="text" name="species" required>creates a one-line text box- The
nameattribute is the key the browser uses when sending the field to the server requiredtells the browser to refuse to submit the form if this field is empty
- The
<input type="number" name="latitude" step="0.01">creates a number box that only accepts numeric inputstep="0.01"allows values with up to two decimal places; without it, only whole numbers are accepted
- Optional fields like
sexandweightomitrequired, so the form can be submitted even when they are blank
@get("/add", media_type=MediaType.HTML)
async def add_form() -> str:
return str(
html(lang="en")[
head[
title["Add a Sighting"],
link(rel="stylesheet", href="/style.css"),
],
body[
h1["Add a New Sighting"],
form(method="post", action="/add")[
label[
"Species", inp(type="text", name="species", required=True)
],
label["Sex (optional)", inp(type="text", name="sex")],
label[
"Weight in kg (optional)",
inp(type="number", name="weight", step="0.1"),
],
label["Color", inp(type="text", name="color", required=True)],
label[
"Date and time (YYYY-MM-DD HH:MM)",
inp(type="text", name="datetime", required=True),
],
label[
"Latitude",
inp(
type="number",
name="latitude",
step="0.01",
required=True,
),
],
label[
"Longitude",
inp(
type="number",
name="longitude",
step="0.01",
required=True,
),
],
button(type="submit")["Add Sighting"],
],
a(href="/")["Back to all sightings"],
],
]
)
Add a
POST /addroute toserver_add.pythat reads the submitted form data, inserts a new row, and redirects the user to the home page.
- When a form is submitted, the browser encodes its fields as
species=G.+canadensis&color=brown&...and sends them in the request body - Litestar decodes these into a dictionary when the handler is annotated with
Body(media_type=RequestEncodingType.URL_ENCODED)data["species"]reads the field whosenameattribute is"species"in the form- Optional fields left blank arrive as empty strings;
data["sex"] or Noneconverts them toNonebefore inserting into the database - Number fields also arrive as strings;
float(data["latitude"])converts them before inserting
@post("/add")
async def add_sighting(
data: Annotated[dict, Body(media_type=RequestEncodingType.URL_ENCODED)],
db_path: Path,
) -> Redirect:
conn = sqlite3.connect(db_path)
conn.execute(
INSERT_ROW,
[
data["species"],
data["sex"] or None,
float(data["weight"]) if data["weight"] else None,
data["color"],
data["datetime"],
float(data["latitude"]),
float(data["longitude"]),
],
)
conn.commit()
conn.close()
return Redirect("/", status_code=303)
def make_app(db_path: Path = DB_PATH) -> Litestar:
return Litestar(
[index, detail, delete_sighting, add_form, add_sighting, styles],
dependencies={"db_path": Provide(lambda: db_path, sync_to_thread=False)},
)
Uploading Many Records at Once
What does a form look like when it needs to send a file instead of typed text, and how does the server receive and parse the contents?
- File uploads require
enctype="multipart/form-data"on the<form>tag- Without this attribute the browser sends only the filename, not the file's contents
- Multipart encoding splits the request into separate labelled sections, one for each field, with file contents in their own section
<input type="file" name="csv_file" accept=".csv">adds a file picker to the formaccept=".csv"filters the file picker to show only CSV files by default
- CSV (comma-separated values) is a plain-text format for tabular data
- The first line lists column names; each subsequent line is one row of data
- Empty values appear as two consecutive commas
Add
GET /uploadandPOST /uploadroutes toserver_add.pyto createserver_upload.py. The GET route should show a file upload form; the POST route should read the CSV and insert all its rows into the database.
- Litestar receives the uploaded file through a
dataclasswhose field names match thenameattributes in the formawait data.csv_file.read()returns the file contents asbytes.decode("utf-8")converts them to a string that thecsvmodule can read
csv.DictReaderturns the string into an iterator of dictionaries, one per data row, using the header line for the keysio.StringIOwraps the string soDictReadertreats it like a file
- One
conn.commit()at the end saves all the new rows in a single write to disk
@dataclass
class CsvUpload:
csv_file: UploadFile
@get("/upload", media_type=MediaType.HTML)
async def upload_form() -> str:
return str(
html(lang="en")[
head[
title["Upload Sightings"],
link(rel="stylesheet", href="/style.css"),
],
body[
h1["Upload Sightings from a CSV File"],
form(
method="post", action="/upload", enctype="multipart/form-data"
)[
label[
"CSV file", inp(type="file", name="csv_file", accept=".csv")
],
button(type="submit")["Upload"],
],
a(href="/")["Back to all sightings"],
],
]
)
@post("/upload")
async def upload_csv(
data: Annotated[CsvUpload, Body(media_type=RequestEncodingType.MULTI_PART)],
) -> Redirect:
content = await data.csv_file.read()
reader = csv.DictReader(io.StringIO(content.decode("utf-8")))
conn = sqlite3.connect(db_path)
for row in reader:
conn.execute(
INSERT_ROW,
[
row["species"],
row["sex"] or None,
float(row["weight"]) if row["weight"] else None,
row["color"],
row["datetime"],
float(row["latitude"]),
float(row["longitude"]),
],
)
conn.commit()
conn.close()
return Redirect("/", status_code=303)
return Litestar(
[
index,
detail,
delete_sighting,
add_form,
add_sighting,
upload_form,
upload_csv,
styles,
]
)
app = make_app()
A sample CSV for testing the upload:
species,sex,weight,color,datetime,latitude,longitude
G. canadensis,Male,180,brown,2024-07-15 08:00,53.20,-117.40
G. horribilus,,,black,2024-07-16 14:00,54.10,-116.90
Start the server:
litestar run --app server_upload:app
Testing Routes That Change Data
How do you test a route that deletes a row from the database?
- Reuse the
small_dbfixture from the previous lesson to give each test a fresh temporary database - Send the POST request with
client.post("/delete/1") - After the
withblock closes the client, open the database directly withsqlite3.connectand count the rows- This checks the actual database state, not just what the server put in the response
- A helper function
count_rows(db_path)avoids repeating the connect-query-close pattern in every test
Write tests for the add route and the CSV upload route in
test_server_forms.py.
client.post("/add", data={...})sends URL-encoded form data, exactly as a browser would- Keys in
datamust match thenameattributes on the form's<input>elements - Pass empty strings for optional fields the user would leave blank
- Keys in
client.post("/upload", files={"csv_file": ("upload.csv", content, "text/csv")})sends a multipart request with a file attached- The tuple contains the filename, the file contents as bytes, and the media type
def count_rows(db_path):
conn = sqlite3.connect(db_path)
n = conn.execute("select count(*) from sightings").fetchone()[0]
conn.close()
return n
def test_delete_removes_row(small_db):
with TestClient(app=make_app(small_db)) as client:
client.post("/delete/1")
assert count_rows(small_db) == 1
def test_delete_unknown_id_is_harmless(small_db):
with TestClient(app=make_app(small_db)) as client:
client.post("/delete/9999")
assert count_rows(small_db) == 2
def test_add_inserts_row(small_db):
new_sighting = {
"species": "G. canadensis",
"sex": "",
"weight": "",
"color": "grey",
"datetime": "2024-06-01 09:00",
"latitude": "52.10",
"longitude": "-118.50",
}
with TestClient(app=make_app(small_db)) as client:
client.post("/add", data=new_sighting)
assert count_rows(small_db) == 3
def test_upload_csv_inserts_rows(small_db):
csv_content = (
"species,sex,weight,color,datetime,latitude,longitude\n"
"G. canadensis,Male,180,brown,2024-07-15 08:00,53.20,-117.40\n"
"G. horribilus,,,black,2024-07-16 14:00,54.10,-116.90\n"
)
with TestClient(app=make_app(small_db)) as client:
client.post(
"/upload",
files={"csv_file": ("upload.csv", csv_content.encode(), "text/csv")},
)
assert count_rows(small_db) == 4
Run the tests from the forms/ directory:
pytest test_server_forms.py -v
See [mdn-forms2025] for the HTML forms reference and [python-csv2025]
for the Python csv module documentation.
Check Understanding
A classmate adds a Delete link rather than a button: <a href="/delete/5">Delete</a>. What goes wrong, and how do you fix it?
A link sends a GET request, not a POST request.
The server should never delete data in response to a GET, because search engines,
browsers, and prefetch tools send GET requests automatically.
The fix is to wrap a <button type="submit"> inside
<form method="post" action="/delete/5">.
The weight field is left blank when a user submits the add form. The handler stores data["weight"] directly in the database. What does the database contain, and why is that a problem?
An empty form field sends the empty string "" to the server, so the database stores "".
An empty string is not the same as SQL null: it is a string with no characters,
not a missing value.
The fix is float(data["weight"]) if data["weight"] else None,
which converts an empty string to None.
Python's sqlite3 module stores None as SQL null.
The CSV file uses the header Species (capital S) but the handler reads row["species"] (lowercase s). What happens?
Python's dictionary lookup is case-sensitive, so row["species"] raises a KeyError.
The header in the CSV must exactly match the key the handler uses.
The simplest fix is to use the exact header from the CSV; a more robust fix converts
all header names to lowercase when reading the file.
Moving conn.commit() inside the loop so it runs after every insert changes the behavior. When does this matter, and when does it not?
Either way, all rows that were inserted successfully end up in the database. The difference is what happens if the server crashes mid-upload. Committing once at the end means no rows are saved if the crash happens before the commit: the database is unchanged. Committing after every insert means every row committed before the crash stays, leaving partial data in the database. For a small CSV in a tutorial, the difference is unnoticeable.
Exercises
Add a Confirmation Step
Modify the delete route so that clicking "Delete this sighting" first shows a page asking "Are you sure?" with Confirm and Cancel buttons. Only delete the row if the user clicks Confirm.
Validate the Input Before Inserting
The add handler trusts that latitude is between -90 and 90 and longitude is between -180 and 180.
Add a check in the POST /add handler that returns an error message if either value is out of range,
without inserting any row.
Report Upload Errors
If a row in the CSV is missing a required field, the handler raises an unhandled error. Modify it to skip bad rows and return a page that lists which row numbers were skipped and why.
Edit an Existing Record
Add a GET /edit/{id} route that shows a form pre-filled with the current values of a sighting,
and a POST /edit/{id} route that updates the row in the database with the submitted values.