How-to: Cross-field Validation¶
Some validation rules depend on the values of multiple fields simultaneously — for example, confirming a password, checking that an end date is after a start date, or verifying that a total quantity matches a sum of parts.
Use clean_form() together with add_error() to implement this kind of logic.
Password confirmation¶
The most common example: a "new password" field and a "confirm password" field that must match.
from textual.app import App, ComposeResult, on
from textual_wtf import Form, StringField
class PasswordChangeForm(Form):
title = "Change Password"
current_password = StringField(
"Current password",
required=True,
help_text="Your existing password.",
)
new_password = StringField(
"New password",
required=True,
min_length=8,
help_text="Minimum 8 characters.",
)
confirm_password = StringField(
"Confirm new password",
required=True,
help_text="Type your new password again.",
)
def clean_form(self) -> bool:
new_pw = self.new_password.value
confirm = self.confirm_password.value
# Only compare if both fields have values
# (Required validators handle the empty-field case)
if new_pw and confirm and new_pw != confirm:
self.add_error("confirm_password", "Passwords do not match.")
return False
return True
When the user submits the form:
form.validate()runs all field-level validators (Required, MinLength, etc.).- If all pass,
clean_form()is called. - If the passwords differ,
add_error("confirm_password", "Passwords do not match.")attaches the error to the confirm field. - The form returns
Falsefromclean()and theSubmittedmessage is not posted.
clean_form() is called only after all fields pass
If new_password fails MinLength, clean_form() is never called. The length error on new_password is shown, but the confirmation check is deferred until the individual fields are valid.
Date range validation¶
from datetime import date
from textual_wtf import Form, StringField, ValidationError
def parse_date(value: str) -> date | None:
"""Simple YYYY-MM-DD parser for demonstration."""
if not value:
return None
try:
return date.fromisoformat(value)
except ValueError:
raise ValidationError("Enter a date in YYYY-MM-DD format.")
class BookingForm(Form):
title = "Make a Booking"
name = StringField("Guest name", required=True)
check_in = StringField(
"Check-in date",
required=True,
validators=[parse_date],
help_text="Format: YYYY-MM-DD",
)
check_out = StringField(
"Check-out date",
required=True,
validators=[parse_date],
help_text="Format: YYYY-MM-DD",
)
def clean_form(self) -> bool:
try:
check_in = date.fromisoformat(self.check_in.value or "")
check_out = date.fromisoformat(self.check_out.value or "")
except ValueError:
# Individual field validators already caught this
return False
if check_out <= check_in:
self.add_error(
"check_out",
"Check-out must be at least one day after check-in.",
)
return False
return True
Multiple errors in one clean_form()¶
You can call add_error() multiple times to attach errors to different fields. Each call contributes to the error state; clean() returns False as soon as any add_error() has been called.
from textual_wtf import Form, IntegerField
class BudgetForm(Form):
total_budget = IntegerField("Total budget ($)", required=True, minimum=0)
marketing_cost = IntegerField("Marketing ($)", required=True, minimum=0)
dev_cost = IntegerField("Development ($)", required=True, minimum=0)
ops_cost = IntegerField("Operations ($)", required=True, minimum=0)
def clean_form(self) -> bool:
total = self.total_budget.value
components = [
self.marketing_cost.value,
self.dev_cost.value,
self.ops_cost.value,
]
if None in [total, *components]:
return False # individual fields handle the empty-value errors
component_sum = sum(components)
is_valid = True
if component_sum > total:
self.add_error(
"total_budget",
f"Component costs (${component_sum}) exceed the total budget (${total}).",
)
is_valid = False
if self.marketing_cost.value > total * 0.5:
self.add_error(
"marketing_cost",
"Marketing cannot exceed 50% of the total budget.",
)
is_valid = False
return is_valid
Complete runnable example¶
from textual.app import App, ComposeResult, on
from textual_wtf import Form, StringField
class PasswordChangeForm(Form):
title = "Change Password"
current_password = StringField(
"Current password",
required=True,
)
new_password = StringField(
"New password",
required=True,
min_length=8,
help_text="Minimum 8 characters.",
)
confirm_password = StringField(
"Confirm new password",
required=True,
)
def clean_form(self) -> bool:
new_pw = self.new_password.value
confirm = self.confirm_password.value
if new_pw and confirm and new_pw != confirm:
self.add_error("confirm_password", "Passwords do not match.")
return False
return True
class PasswordApp(App):
CSS = """
Screen { align: center middle; }
DefaultFormLayout { width: 50; border: solid $primary; }
"""
def compose(self) -> ComposeResult:
self.form = PasswordChangeForm()
yield self.form.layout()
@on(PasswordChangeForm.Submitted)
def on_submitted(self, event: PasswordChangeForm.Submitted) -> None:
# In a real app, verify current_password and update the stored hash
self.notify("Password changed successfully.", severity="information")
@on(PasswordChangeForm.Cancelled)
def on_cancelled(self, event: PasswordChangeForm.Cancelled) -> None:
self.app.exit()
if __name__ == "__main__":
PasswordApp().run()
Tips¶
Guard against None in clean_form()
clean_form() is only called after all field validators pass. But required=False fields may still have None values. Always check for None before comparing or computing with field values.
Do not access widget state directly
Inside clean_form(), read values through self.<field_name>.value (the BoundField) not through Textual widget queries. The widget may not be mounted when clean() is called programmatically.
add_error() vs. returning False
add_error() attaches a visible error message to a specific field widget. Returning False without calling add_error() causes clean() to fail silently — the user won't see why their submission was rejected. Always pair a return False with at least one add_error() call.