A form submission is the most common way a user changes data, and it is the one place where "the happy path" is the rare path. Someone will submit a blank title, an email with a trailing space, a date that will not parse, or the same form twice. Autumn's answer has three layers, and knowing which layer owns which problem is most of the work:

LayerOwnsPrimary type
The formre-rendering the page with the user's input still in the fieldsChangesetForm<T>
Validationdeciding whether a value is acceptable, and saying why#[validate(...)], Valid<T>
Normalizationmaking an acceptable value canonical before it is stored#[normalize(...)]

Those three layers run in two distinct places, and conflating them is the single easiest way to write a validator that rejects input you meant to accept:

  • The form layer validates first, on raw input. ChangesetForm extraction calls IntoChangeset, which runs validator::Validate on the value exactly as it was decoded from the body — before any handler code, and before any repository sees it.
  • The repository layer then normalizes, validates, and saves, in that order. This is the pipeline #[normalize(...)] belongs to.

So normalize, then validate, then save is true of the model write path, not of the form. A form-level #[validate(email)] sees " [email protected] " with its spaces intact and rejects it, even though the model carries #[normalize(trim)] and would have canonicalized it a moment later.

Design around it: keep form-level validators tolerant of what a human actually types (shape and length, not exact canonical form), and put the rules that assume a canonical value on the model, where normalization has already run.


The shape of a form round-trip

Server-rendered forms have one hard requirement that API endpoints do not: on failure you must render the form again, with the values the user typed and the errors next to the right fields. Everything else follows from that.

A Changeset<T> is the value that makes it possible — the submitted data plus the per-field errors, together. ChangesetForm<T> is the extractor that produces one:

Rust
use autumn_web::prelude::*;
use autumn_web::form::ChangesetForm;
use serde::{Deserialize, Serialize};
use validator::Validate;

// `Default` is here for the blank-form constructor further down
// (`ChangesetForm::blank(TodoForm::default(), ..)`): a form struct that a GET
// route renders empty needs it, and every field type already has one.
#[derive(Deserialize, Serialize, Validate, Clone, Default)]
pub struct TodoForm {
    #[validate(length(min = 1, max = 255, message = "Title must be 1–255 characters"))]
    title: String,
}

#[get("/todos/new")]
async fn new_todo(csrf: CsrfToken) -> Markup {
    // Correct as written under the default CSRF field name. If your app sets
    // `security.csrf.form_field`, this needs one more parameter — see
    // "CSRF is not optional, and not your job" below.
    let blank = ChangesetForm::blank(TodoForm { title: String::new() }, csrf.token());
    layout(render_form(&blank))
}

#[post("/todos")]
async fn create(mut db: Db, form: ChangesetForm<TodoForm>) -> AutumnResult<impl IntoResponse> {
    match form.into_valid() {
        Ok(valid) => {
            insert(&mut db, valid).await?;
            Ok(Redirect::to("/todos").into_response())
        }
        Err(form) => {
            // `form` still holds the submitted values *and* the errors.
            Ok((StatusCode::UNPROCESSABLE_ENTITY, render_form(&form)).into_response())
        }
    }
}

into_valid is the whole control flow: Ok(T) on success, Err(Self) on failure — and the Err arm gives the form back, ready to re-render. There is no flash-carrying, no session round-trip, and no second deserialization.

examples/todo-app/src/routes/todos.rs is this pattern end to end, including the list page it re-renders on failure.

Rendering the fields

ChangesetForm renders each control with its current value, its errors, and the ARIA wiring that ties the two together:

Rust
fn render_form(form: &ChangesetForm<TodoForm>) -> Markup {
    form.form_tag("/todos", "post", html! {
        (form.text_input("title", "Title"))
        (form.submit_button("Add"))
    })
}

form_tag emits the hidden _csrf input automatically — see CSRF below. For fields the built-in helpers do not cover, read the state yourself:

MethodReturns
form.field_value("title")the submitted value, for the value= attribute
form.errors_for("title")this field's error messages
form.is_valid()whether the changeset has any errors at all

The free functions in autumn_web::form cover the rest of the control types: password_input, textarea_input, checkbox_input, number_input, date_input, datetime_input, and select_input. The native inputs below add the switch, the slider, the groups and more types.

Each control has a required_* variant. It adds required, aria-required="true" and a visual * marker in the label. The marker has aria-hidden="true", so a screen reader says "required" one time only. Tell sighted users what * means, for example with "* required" above the form. required_* writes only the client-side signal. The model rule #[validate(presence)] rejects a blank submission.

Hints, attributes and required state: FieldOptions

Each helper also has a *_with variant that takes &FieldOptions. The label stays a positional argument, so a field with no label does not compile.

Rust
use autumn_web::form::{FieldOptions, text_input_with};

text_input_with(&changeset, "email", "Email", &FieldOptions::new()
    .hint("We never share your address.")
    .required()
    .autocomplete("email")
    .placeholder("[email protected]")
    .data("test-id", "signup-email")
    .hx("indicator", "#spinner"))
  • hint renders <p id="email-hint" class="autumn-field__hint"> between the label and the control (after the label for a checkbox). aria-describedby names the hint first, then the error.
  • The typed attributes (placeholder, autocomplete, inputmode, pattern, minlength, maxlength, min, max, disabled, readonly, multiple) render only on controls that use them.
  • data and hx add data-* and hx-* attributes. There is no setter for id, name, class or aria-*: the helper owns them. The htmx helpers also keep their own hx-post, hx-params and the other hx-* names they set. If you set one name two times, the last value applies.
  • The browser does not submit the value of a disabled control.
HelperWith options
text_inputtext_input_with
text_input_htmx_with_token_fieldtext_input_htmx_with
password_inputpassword_input_with
textarea_inputtextarea_input_with
checkbox_inputcheckbox_input_with
number_inputnumber_input_with
date_inputdate_input_with
datetime_inputdatetime_input_with
select_inputselect_input_with
grouped_select_inputgrouped_select_input_with
rich_text_area_with_labelsrich_text_area_with
rich_text_area_htmx_with_token_field_with_labelsrich_text_area_htmx_with
switch_input, range_input, radio_group, checkbox_group, segmented_control*_with
email_input, tel_input, url_input, time_input, month_input, color_input*_with
hidden_inputhidden_input_with
otp_inputotp_input_with

The *_with htmx helpers take the submit-token field name. Pass "_submit_token" for the default. A nested-form RowScope has text_input_with, number_input_with and textarea_input_with.

The Autumn stylesheet marks a field invalid with :user-invalid, so the browser's own check shows only after the user changes the field or submits. Server errors use the autumn-field__input--invalid class.

Native inputs

These helpers use native HTML controls, so they work with JavaScript off. Each one has the wrapper, the hint, the errors and the passthrough above.

HelperControlBind torequired_*
switch_input<input type="checkbox" role="switch">#[serde(default)] boolyes
range_input(…, step)<input type="range">a numberno
radio_group(…, choices)radios in a <fieldset>a String or an enumyes
checkbox_group(…, choices)boxes in a <fieldset>#[serde(default)] Vec<String>no
segmented_control(…, choices)radios that look like buttonsa String or an enumyes
email_input, tel_input, url_inputtype="email", "tel", "url"Stringyes
time_inputtype="time"chrono::NaiveTimeyes
month_inputtype="month"String (YYYY-MM)yes
color_inputtype="color"String (#rrggbb)no
hidden_input(changeset, field)type="hidden"anyno
Rust
use autumn_web::form::{FieldOptions, checkbox_group_with, radio_group, switch_input};

(switch_input(&changeset, "alerts", "Email alerts"))
(radio_group(&changeset, "plan", "Plan", &[("free", "Free"), ("pro", "Pro")]))
(checkbox_group_with(&changeset, "topics", "Topics",
    &[("rust", "Rust"), ("web", "Web")],
    &FieldOptions::new().hint("Choose one or more.")))
  • A group is a <fieldset> with a <legend>. The hint and error links go on the <fieldset>. The first input has id="{field}", so an error summary link moves focus to the group. The other inputs have id="{field}-{index}".
  • A checkbox group posts one topics=… pair for each checked box, and no key when no box is checked. Changeset::field_values checks the boxes again after a 422.
  • required on a box means "check this box". So a required checkbox group shows the marker only. Validate "one or more" on the server, for example with #[validate(length(min = 1))].
  • A radio has no aria-required, because ARIA does not allow it. required on each radio makes the group required.
  • A slider, a color and a hidden input always have a value. They ignore required.
  • The time value shows as HH:MM, or with seconds and step="1" when the seconds are not zero. The month value shows as YYYY-MM, also from a date. The color value shows as a lowercase #rrggbb. Any other color is not shown.
  • A hidden input has no label, no wrapper and no error block. Show its errors with the error summary.

form_for takes the same controls: FieldControl::Switch, Range { step }, Radio { options }, CheckboxGroup { options }, Segmented { options }, Email, Tel, Url, Time, Month, Color and Hidden. A #[model] NaiveTime column gets Time. A non-null column does not make a switch, a slider, a color, a hidden input or a checkbox group required.

The "Native inputs" and "Choice controls" stories show each control.

minlength, maxlength, min, max, disabled, readonly, multiple) render only on controls that use them.

Passwords and one-time codes

A password field and a one-time code (OTP) field need more than a plain input. Both are in core, and the auth generator uses both.

Rust
use autumn_web::form::{FieldOptions, OtpFormat, otp_input_with, password_input_with,
    required_otp_input};

(password_input_with(&changeset, "password", "Password", &FieldOptions::new()
    .required()
    .new_password()
    .minlength(12)
    .reveal()
    .strength_hint()))
(required_otp_input(&changeset, "code", "Authenticator code"))
(otp_input_with(&changeset, "backup", "Backup code",
    OtpFormat::digits(8).group(4), &FieldOptions::new()))

Password options (other controls ignore them):

  • current_password() and new_password() set autocomplete. Use the first on a sign-in form. Use the second on a signup or a change form, so a password manager can suggest a password.
  • reveal() adds a "Show password" button with aria-pressed. The button has hidden until autumn-widgets.js shows it. A submit, a page hide and an htmx history snapshot hide the password again. reveal_label(text) sets the button text.
  • strength_hint() adds <p id="{field}-strength" aria-live="polite"> and puts its id in aria-describedby. The script writes "Strength: weak", "fair", "good" or "strong" from the length and the character kinds. The text changes only when the level changes. strength_labels([..; 4]) sets the four labels. Name the subject in each label: a screen reader reads the label alone. It is a hint, not a policy: it does not know common passwords. Check the password on the server.
  • A revealable password has spellcheck="false" and autocapitalize="off", so a cloud spellcheck does not read the revealed text.

The OTP field is one <input type="text" autocomplete="one-time-code" inputmode="numeric">. CSS draws a box for each digit behind it. One real input keeps SMS autofill and paste working, and a screen reader finds one field.

  • OtpFormat::digits(n) sets the length, 4 to 10. The default is 6.
  • pattern="[0-9]{n}" checks the code on submit, also without JavaScript. maxlength="{n}" keeps a page with no JavaScript from showing more digits than boxes.
  • The script removes that maxlength. It keeps only the digits of a paste, an autofill or an IME entry, up to the length. A paste of 123-456 gives 123456. Full-width digits (123) become ASCII digits.
  • .group(g) shows 1234 5678. g is 2 to 5 and less than n. The group needs the script. It changes pattern, shows the separator boxes, and removes the spaces from the form data and from an htmx request, so the server gets the digits only. Strip whitespace on the server too. A pattern option turns the group off, so your pattern checks the code.
  • The value is never rendered: a code is used one time.
  • An autocomplete, inputmode, pattern or maxlength option replaces the default. placeholder and minlength pass through.
  • On a narrow screen the boxes get smaller, so the page does not scroll sideways.
  • Do not use it for a recovery code with letters. The generated /login/verify page keeps a text input for that reason.

form_for takes FieldControl::Otp(OtpFormat). The "Sign-in inputs" story shows each option.

Select: groups, multiple and style

select_input renders a native <select>. The browser supplies the keyboard behaviour and the accessibility tree. There is no JavaScript.

ItemUse
grouped_select_input(…, choices, groups)Top-level choices, then one <optgroup> for each (label, options) group
FieldOptions::multiple()<select multiple>. Bind to a #[serde(default)] Vec<T>
FieldOptions::styled_select()The autumn-select class: appearance: base-select where the browser supports it
Rust
use autumn_web::form::{FieldOptions, grouped_select_input_with, select_input_with};

(grouped_select_input_with(&changeset, "city", "City",
    &[("", "Choose a city")],
    &[
        ("France", &[("paris", "Paris"), ("lyon", "Lyon")]),
        ("Japan", &[("tokyo", "Tokyo")]),
    ],
    &FieldOptions::new().required().styled_select()))
(select_input_with(&changeset, "tags", "Tags",
    &[("rust", "Rust"), ("web", "Web")],
    &FieldOptions::new().multiple()))
  • A single select marks the first option whose value is the field value. It marks one option only, also when two options have that value.
  • A multiple select reads Changeset::field_values. It marks each option whose value is in the list. Thus the chosen options show again after a 422. The browser posts one tags=… pair for each chosen option. It posts no key when no option is chosen.
  • required on a multiple select means "choose one or more". Also validate on the server, for example with #[validate(length(min = 1))].
  • A multiple select on a String field gets a 422 when the user chooses two options. Bind it to a Vec<T>.
  • Autumn also writes multiple on a file input. Read the files with ChangesetForm::files(field). Other controls ignore multiple.
  • styled_select is opt-in. Chrome 135+ and Safari 27 support appearance: base-select. They draw the select and its picker from the design tokens, in the light and the dark scheme. Other browsers keep the native control with the field style. A multiple select keeps the native list box in all browsers.
  • In a styled select, the arrow keys, Enter and Space open the picker. The arrow keys move in the picker. Enter chooses an option. Escape closes the picker.

form_for takes FieldControl::GroupedSelect { options, groups }. For a multiple select:

  1. Use .override_field(name, FieldControl::Select { options }), or GroupedSelect.
  2. Set .field_options(name, FieldOptions::new().multiple()).

The model required flag does not make a multiple select required, because a Vec can be empty.

The "Select" story shows the styled select, the native fallback, a grouped select and a multiple select.

The error summary

autumn_web::widgets::error_summary(&changeset) lists every error at the top of the form. Each entry links to #field, the id of the control. The summary has tabindex="-1" and autofocus, so focus moves to it when a submit fails, with no JavaScript. A form-level error (key __all__) has no link. Put one summary on a page, with no other autofocus element. Do not return it from an inline-validation response: it takes focus while the user types.

Rendering a whole form in one call

A #[model] implements FormModel, so form_for can derive one control per editable column:

Rust
use autumn_web::form::{FieldOptions, form_for};

form_for(&changeset, "/posts", "post")
    .csrf(csrf.token())
    .exclude("author_id")
    .override_label("body", "Post text")
    .field_options("title", FieldOptions::new().hint("Keep it short."))
    .render()

Use .field_options(field, FieldOptions) to give one field a hint, attributes or the required state. A checkbox and a password become required only from FieldOptions: a non-null bool column does not mean "must be checked", and the browser never fills a password field again on an edit form.

This is what the scaffold generator emits. Reach for the individual helpers when the form's layout matters; reach for form_for when it does not.

Controls from a widget pack

A widget pack (for example a date picker) gives a FieldRenderer. Bind it to one field in two ways:

Rust
use autumn_web::form::{FieldControl, FieldOptions, custom_input_with};

// With a changeset:
custom_input_with(&changeset, "due", "Due", &DatePicker, &FieldOptions::new())

// With form_for:
form_for(&changeset, "/tasks", "post")
    .override_field("due", FieldControl::custom(DatePicker))
    .render()

The control gets the same wrapper, label, hint, errors and passthrough as a core control. See Widget Packs.


Two validation paths, and how to choose

Autumn validates with the validator crate: #[validate(...)] attributes on the struct, #[derive(Validate)] to run them. Where the result goes is what differs.

ChangesetForm<T> — for pages a human is looking at

Invalid input is data, not an error: the extractor does not fail on a validation error, the handler decides what to render, and the user gets their input back. This is the right choice for every server-rendered HTML form.

It can still reject a body it cannot decode. decode_form_body returns 415 for the wrong Content-Type, 400 when a field will not deserialize into its declared type, and 422 when a single-value field arrives more than once (see multi-value fields) — before the handler runs, so there is nothing to re-render with. The round-trip covers invalid values, not unparseable ones.

That distinction has a practical consequence: type a form field as the string the browser actually submits, and convert after validation. A field typed i64 turns "abc" — or an empty select — into a hard 400 that discards the whole submission, title and body included. A String field with a validator that parses it turns the same input into an inline message next to the offending field:

Rust
#[derive(Deserialize, Serialize, Validate, Clone, Default)]
struct SubmitPostForm {
    // Not `i64`: a form field's job is to round-trip whatever the browser
    // sent, so the page can be re-rendered with it. The conversion happens
    // after validation proves it parses.
    #[validate(custom(function = "validate_subreddit_choice"))]
    subreddit_id: String,
    #[validate(length(min = 1, max = 300))]
    title: String,
}

fn validate_subreddit_choice(value: &str) -> Result<(), ValidationError> {
    match value.trim().parse::<i64>() {
        Ok(id) if id > 0 => Ok(()),
        _ => Err(ValidationError::new("subreddit_id")
            .with_message("Choose a community".into())),
    }
}

Reserve non-string field types for values a browser cannot get wrong, or for endpoints where a 400 is the right answer. examples/reddit-clone's submit form is built this way, for exactly this reason.

One custom validator is worth spelling out, because it is easy to write as dead code. To reject a title that carries no text — "***", "🎉🔥💯" — ask autumn_web::contains_letter_or_number, not slugify(value).is_empty(): slugify never returns an empty string (input with nothing to slugify gets a stable hash fallback), so the is_empty() form is always false and rejects nothing. See Generators for the full contract.

Rust
fn validate_sluggable_title(value: &str) -> Result<(), ValidationError> {
    if autumn_web::contains_letter_or_number(value) {
        return Ok(());
    }
    Err(ValidationError::new("title")
        .with_message("Title must contain at least one letter or number".into()))
}

A route-local validator only runs on the route. If the same model is also written through generated API routes or a repository, put the rule in a mutation hook as well — the hooks are the choke point those paths share.

Valid<T> — for endpoints a program is calling

Invalid input is an error. Valid wraps another extractor, and a validation failure short-circuits into a 422 with a field-level error map before the handler body runs:

Rust
use autumn_web::Valid;

#[post("/api/posts")]
async fn create(Valid(Json(post)): Valid<Json<NewPost>>) -> impl IntoResponse {
    // `post` is guaranteed valid; there is no invalid branch to write.
}

It wraps Json, Form, or Query alike.

Validated<T> and .validate() — for values that did not arrive in a request

Sometimes the value comes from a job payload, a CSV row, or another service. ValidateExt::validate runs the same rules and returns AutumnResult<Validated<T>>, so ? works:

Rust
use autumn_web::ValidateExt;

let row: Validated<ImportRow> = raw_row.validate()?;

Validated<T> is proof that validation ran: it cannot be constructed outside the framework, it derefs to T for reading, and it deliberately does not implement DerefMut, so it cannot be mutated back into an invalid state.

Reading the failure back

Off the HTTP path nothing renders the 422 for you, so read the field map off the error instead of re-running validator yourself:

Rust
for (index, raw_row) in rows.into_iter().enumerate() {
    match raw_row.validate() {
        Ok(row) => import(row),
        // "Validation failed: email: Must be a valid email address"
        Err(error) => tracing::warn!(row = index, error = %error, "skipped invalid row"),
    }
}

Three accessors, on any AutumnError:

  • details() returns the field map for a validation failure, None otherwise. It is a HashMap, so sort the keys before you render them.
  • code() returns the stable code the problem+json body carries — autumn.validation_failed, autumn.not_found, and so on.
  • message() returns the wrapped error's message alone. It is not redacted, and status() does not tell you whether the response redacted it — the renderer reclassifies a cancelled database statement to a 503, so even a 4xx here can be a redacted response. Render the error when you need a client-safe string.

Display appends the failing fields to message(), sorted by field name. Keep untrusted text out of your #[validate(message = "...")] strings: this output reaches your logs.

The HTTP response does not change. Its detail still reads Validation failed, with the fields in errors.

Which one?

The caller is…UseFailure looks like
a browser rendering HTMLChangesetForm<T>the same page, 422, errors inline (a body that will not decode is still 400/415)
an API client, an MCP tool, htmx expecting JSONValid<T>422 Problem Details with a field map
not a request at all.validate() → Validated<T>an AutumnError you handle

Model-level validation

Putting #[validate(...)] on the #[model] itself moves the rule to where the data lives, so every write path is covered — a form, an API endpoint, a seed, a job, a CSV import:

Rust
#[model]
pub struct User {
    #[validate(email(message = "Must be a valid email address"))]
    pub email: String,

    #[validate(length(min = 2, max = 60))]
    pub display_name: String,
}

The generated repository runs these rules on insert, after #[normalize] canonicalizes the payload and before the before_create hook. Every generated insert path applies them — save, save_many, save_many_skip_invalid, and the create half of find_or_create_by_* — so a GraphQL resolver, a #[task] or an admin action reaching the repository gets the same answer a generated --api handler does.

What "every write path" means, precisely: every write through the generated repository. Three things in a normal project are not that, and are not covered:

Not coveredWhy
Model::factory().create(), and so autumn seedThe factory inserts with Diesel directly, bypassing the repository — it builds fixtures, and a fixture that had to satisfy every rule would be useless for testing the rules
A hand-written repository, or a handler using diesel::insert_into for cross-table atomicityNo generated code runs
upsert_manyIt takes whole models, and Postgres decides per row whether the statement inserts or updates — there is no insert to hang the rule on. Validate before you call it

A database CHECK constraint is what covers all three at once.

Each path keeps its own failure shape:

Insert pathAn invalid row
save422 with the field map; nothing is written
save_many422 on the first offender, before any row is written
save_many_skip_invalidreported as (index, error) against the caller's own index; the valid rows still land
find_or_create_by_*422 only when the row is about to be created — a row that already exists is returned unchanged

A payload type with no #[validate] columns costs nothing: the check compiles away.

A generated --api handler still checks the decoded payload itself, before it reaches the repository, so it can answer 422 without a database round trip. The rules therefore run twice on that path — worth knowing for a #[validate(custom = ...)] that is expensive or has side effects. The two see different values: the handler's check sees the payload as submitted, the repository's sees it normalized. A value only normalization would make valid — " ab " against #[normalize(trim)] #[validate(length(max = 3))] — is therefore refused at the endpoint, though repo.save would accept it.

The rules run before before_create, which matters if a hook fills a field in. A before_create that derives slug from title runs after the model has already judged slug — so a #[validate(length(min = 1))] on a hook-populated column rejects every insert. Validate only what the caller supplies, or derive the value before you build the New*.

On update, merged-model validation is opt-in. A repository with hooks, or one declared #[repository(Post, validate_on_update = fetch)], loads the existing row, applies the patch, and validates the merged model — so a must_match or a #[validate(custom = ...)] comparing two columns sees real values on both sides, and a partial update that would break the invariant is rejected with the same 422 field-error map an insert produces.

A plain generated repository with no hooks and no knob takes the blind update path: it emits no merged-model check at all, deliberately, to avoid an unconditional extra SELECT on every update.

On that path the repository validates nothing at all — not cross-field rules, and not the field-level rules on the columns being written. The blind branch goes straight from changes.__to_changeset() to the UPDATE. So a direct repo.update(id, &UpdateUser { email: Patch::Set("nope".into()), .. }) persists a value your #[validate(email)] would have rejected.

Validation on an update comes from the caller, not the repository: a generated --api handler validates the patch before calling it, and a ChangesetForm handler validates the submitted form struct. If you call the repository directly and want the model's rules enforced, give it hooks, turn on validate_on_update = fetch, or validate before building the patch — do not assume it.

Turning it on changes what is validated, not what is stored: only the hooked path persists normalized values. See Updates are not the same as inserts for the three paths side by side.

Even with the knob, that check is point-in-time against a non-locked snapshot. It reliably rejects a single request that is invalid once merged, but two concurrent partial writes to different fields can still interleave into an invalid row. A cross-field invariant you cannot afford to lose wants a database CHECK constraint underneath it.

Model rules and form rules are not redundant. Put the invariant on the model, and put anything the form alone knows about on the form struct: confirm_password, an "I agree" checkbox, a field that exists only in the UI. The form struct is also where you put rules that need a different message for end users than for API clients.


Normalization: #[normalize(...)]

Validation decides whether to accept a value. Normalization decides what exactly gets stored. They are different problems, and conflating them produces the classic bug: " [email protected] " passes an email validator, is stored verbatim, and then never matches a lookup for [email protected].

Rust
#[model]
pub struct User {
    #[normalize(trim, downcase)]
    #[validate(email)]
    pub email: String,

    #[normalize(squish)]
    pub display_name: String,
}

Built-in normalizers, applied left to right in the order you write them:

NormalizerEffect
trimstrip leading and trailing whitespace
downcaselowercase
upcaseuppercase
squishtrim, and collapse every internal run of whitespace to one space
strip_nulremove every NUL (U+0000) — see Unstorable bytes
with = path::to::fnyour own fn(&str) -> String

All the built-ins are idempotent — normalizing an already-normal value changes nothing — which is what lets the same function run on the write path and on lookups and still agree.

Where it runs

Normalization happens at the head of the repository insert flow: save, save_many, save_many_skip_invalid and find_or_create_by_* normalize the New* insert struct, before validation and before the before_create hook, so:

Text
submitted value
  → #[normalize] chain          "  [email protected] " → "[email protected]"
    → #[validate] rules         validators see the canonical value
      → before_create hook      your code sees the canonical value
        → INSERT                the database stores the canonical value

A validator that would have rejected the raw value for its whitespace never sees the whitespace. That is the intent: #[normalize(trim)] on a #[validate(length(min = 1))] field means "a title of only spaces is blank", which is almost always what you wanted.

Updates are not the same as inserts

The insert path above stores the canonical value. Two of the three update paths do not. They differ in what validation sees and in what actually gets written, and those are not the same question:

RepositoryMerged model built?Validation seesPersisted
No hooks, no knob (blind)no — no from_patch at allnothing — the repository runs no validationraw patch
validate_on_update = fetchyes, and it is normalizedthe normalized merged modelraw patch
Has hooksyesthe normalized draftnormalized draft

So on either of the first two, this stores the untrimmed, uncased string even on a #[normalize(trim, downcase)] column:

Rust
repo.update(id, &UpdateUser { email: Patch::Set("  [email protected] ".into()), ..Default::default() }).await?;
// stored: "  [email protected] "   — not "[email protected]"

The middle row is the surprising one, and it is deliberate: from_patch normalizes the merged model in order to validate it, but that draft is side-effect-only — the generated code keeps its 422 and throws the value away, persisting changes.__to_changeset() unchanged. A value that passes validation only because normalization cleaned it up is therefore stored dirty. autumn-macros/src/repository.rs calls this the "normalize-vs-persist asymmetry" at the site that causes it.

It is a trap worth knowing about, because the stored value then no longer matches the normalized finders below: find_by_email("[email protected]") will not find that row, and a uniqueness assumption built on the canonical form is broken. If you need updates to store canonical values, the hooked path is the one that does it.

The hooked update path persists the normalized draft and does not have this problem. So: if a normalized column is ever written through update, give the repository hooks (or normalize the value yourself before building the patch) rather than assuming the attribute covers it. A CITEXT column or a functional unique index is the durable backstop.

Lookups normalize too

The derived #[repository] finders canonicalize their argument the same way, so find_by_email(" [email protected] ") matches the stored [email protected] row. You do not have to remember to normalize at every call site — which is the failure mode this attribute exists to remove.

Constraints

  • String only. #[normalize] on Option<String> or any non-String field is a compile error.
  • At least one normalizer. A bare #[normalize] or an empty #[normalize()] is a compile error rather than a silent no-op.
  • Not compatible with #[translatable]. Normalizers rewrite one string; they cannot see inside a per-locale column set.
  • It does not rewrite history. Adding #[normalize] to an existing column canonicalizes future writes only. Backfill existing rows with a migration.

Unstorable bytes: NUL

A Postgres TEXT or VARCHAR column cannot hold a NUL byte (0x00). This is not a length or a format rule that #[validate(...)] could express — the value is a perfectly good Rust String, and every validator you can write on it passes. It fails much later, when the driver hands the byte to the server, which answers invalid byte sequence for encoding "UTF8": 0x00.

A real user can produce one without trying: a paste from a binary source, a misbehaving input method, a clipboard glitch. So this is a robustness question, not only an adversarial one.

The framework handles it at the form boundary. The two extractors built for form round-trips — ChangesetForm and NestedChangesetForm — sweep every submitted text value before it is deserialized, and a field that carried a NUL gets an ordinary error:

Text
Cannot contain the NUL character (0x00)

The message is available as autumn_web::form::NUL_CHARACTER_FIELD_ERROR. Nothing in your handler changes — it is a field error like any other, so the form re-renders inline with your existing errors_for(...) markup and whatever 4xx you already return for a rejected changeset. The retained value is the author's text with the byte removed, so the re-rendered form keeps their work (and never echoes a raw 0x00 back into the HTML), and their next submission succeeds.

Framework plumbing fields are deliberately exempt — the CSRF token, the submit token, and the _method override, under both their default names and whatever name you configured. None is a form field any template renders, so an error keyed under one would leave the form invalid with nothing on screen to explain why, and none is ever written to a column. Nothing about their own handling changes either: each is read from the raw body by its middleware, before any extractor runs, so a NUL-mangled _csrf is still a 403 and a NUL-mangled _method is still a 400. (A mangled _submit_token is not rejected — it simply matches no stored token and loses its at-most-once protection, exactly as it would have before.)

_destroy on a nested child row is exempt for a different reason: it is read only for truthiness, and cleaning it would change it — 1\0 is falsy today and would become truthy — so the sweep leaves it alone entirely.

An unknown key is not exempt. A submitted name that is not a field of your form type is normally ignored, but if it carries a NUL it is still reported — under that name. Since no template renders that name, the submission is rejected with no message next to any input; error_summary will show the message, but without saying which input it belongs to. In practice that takes a hand-crafted request: the values your own templates emit for non-form fields are server-generated, not pasted.

File parts of a multipart/form-data body are untouched — binary uploads are supposed to contain arbitrary bytes.

The paths no form extractor sees

A JSON API body, a hand-written query, a background job argument, an autumn_web::extract::Form or Valid<T> extraction: these reach the database without passing one of the two round-trip extractors, so the byte still gets there. It is classified rather than blanket-500'd — the resulting AutumnError carries 422 Unprocessable Entity, matching the status a validator would have produced, and autumn_web::error::is_nul_byte_violation recognizes it if you want to fold it back into a form:

Rust
if let Err(err) = repo.save(&new_post).await
    && is_nul_byte_violation(&err)
{
    changeset.add_error("body", NUL_CHARACTER_FIELD_ERROR);
    return Ok(render_rejected(changeset));
}

This is a backstop. The form boundary is the main defense. It gives the author a message they can use.

The generated #[repository(api = ...)] create and update handlers name the field. The 422 has an errors[] entry for each string that holds a NUL byte. Nested names use dots and brackets: address.street, items[1].sku. The Postgres does not say which column held the byte. The handler looks for it in the payload. A payload type without Serialize keeps an empty errors[]. In your own handler, call autumn_web::error::nul_byte_json_fields on the JSON body.

Postgres rejects a NUL in a JSONB string with a different message (unsupported Unicode escape sequence, SQLSTATE 22P05). The classifier matches that message and its detail exactly. It returns the same 422.

Classification uses the server message. If the server translates the message, the error stays a 500. This is safe.

When you would rather clean than reject

On a column fed by paste-prone input, where dropping the byte silently is better than refusing the write, use the normalizer:

Rust
#[model]
pub struct Profile {
    #[normalize(strip_nul)]
    pub bio: String,
}

Prefer rejecting wherever the author can see and fix the value — they get told what happened instead of having their input quietly altered. strip_nul removes only NUL; every other control character is storable and is left exactly as written.

It covers inserts and hooked updates only. Per Updates are not the same as inserts, a repository with no hooks, or one using validate_on_update = fetch, persists the raw patch and throws the normalized draft away — so an update through either of those still sends the NUL to Postgres and still fails the write, now as a 422 rather than a 500.

Localize the message

With the i18n feature, the form extractors read the Arc<Bundle> from the request extensions. They resolve the request locale and look up common.error.nul_character (autumn_web::form::NUL_CHARACTER_MESSAGE_KEY). Add the key to each catalog:

Text
common.error.nul_character = No puede contener el carácter NUL (0x00)

If there is no bundle, or the key is missing, the message is NUL_CHARACTER_FIELD_ERROR in English. A missing key does not record a miss.

autumn generate scaffold --i18n adds the English entry to en.ftl. autumn i18n check does not report the key as unused.

The API 422 message stays in English. No locale is available there.


CSRF is not optional, and not your job

ChangesetForm captures the CSRF token from the request extensions during extraction, and form_tag emits the hidden input. In a POST handler that is the whole story — no parameter, no wiring. It picks up the configured field name from the extensions too, so a customized security.csrf.form_field is handled for you.

GET handlers that render a blank form get neither, because there is no request body to have captured them from. The token is the obvious half:

Rust
#[get("/todos/new")]
async fn new_todo(csrf: CsrfToken) -> Markup {
    let blank = ChangesetForm::blank(TodoForm::default(), csrf.token());
    render_form(&blank)
}

That is correct only while you use the default field name. blank is a plain constructor with no access to configuration, so it hardcodes _csrf, while CsrfLayer scans an incoming body for the configured name only. Set security.csrf.form_field to anything else and this form renders happily and 403s on its first submission — a failure that appears at the far end of the flow, on an app that "just changed a config value".

If your app configures the field name, extract it and pass it through. It costs one parameter and is inert under the default:

Rust
// `CsrfFormField` is in the prelude, so this import is only for clarity.
use autumn_web::security::CsrfFormField;

#[get("/todos/new")]
async fn new_todo(csrf: CsrfToken, csrf_field: CsrfFormField) -> Markup {
    let blank = ChangesetForm::blank(TodoForm::default(), csrf.token())
        .with_csrf_field(csrf_field.0);
    render_form(&blank)
}

NestedChangesetForm::blank has the same constraint and the same with_csrf_field remedy. So does any hand-written <input type="hidden">: name it from CsrfFormField, not from the literal _csrf.

ChangesetForm::without_csrf exists for tests and for apps that have disabled the CSRF layer. If CSRF is enabled and you use it in a real handler, the form will render and every submission will be rejected.


Inline validation with htmx, and the no-JavaScript path

text_input_htmx renders a field that re-validates itself as the user leaves it. The field POSTs to a validation endpoint and htmx swaps the returned wrapper in place with outerHTML:

Rust
fn title_field(form: &ChangesetForm<TodoForm>) -> Markup {
    form.text_input_htmx("title", "Title", "/todos/validate/title")
}

#[post("/todos/validate/title")]
async fn validate_title(form: ChangesetForm<TodoForm>) -> Markup {
    title_field(&form)     // the same partial, now carrying errors
}

The endpoint is three lines because it re-renders the same partial the full page render uses. That is the property to preserve when you hand-roll a field wrapper: one function, called from both places.

The no-JavaScript fallback is automatic, and it is not a second code path. With htmx absent, the hx-* attributes are inert, the browser performs an ordinary full-form POST, and the #[post("/todos")] handler returns 422 with the same wrapper rendered inline. The user sees the same errors in the same places. Nothing extra is written to make that work — it works because the failure branch of the main handler re-renders the form, which is the pattern this whole guide is built on.

Two rules keep it that way:

  1. Never make the validation endpoint the only way to see an error. The full POST handler must render errors too.
  2. Keep the CSRF token in the form. With JavaScript on, the framework's autumn-htmx-csrf.js shim can send the token as a header; with JavaScript off, the hidden _csrf input is the only thing that gets a plain form POST through. form_tag emits it, so this costs nothing — but a hand-rolled <form> that omits it breaks for exactly the visitors least able to work around it.

examples/reddit-clone builds its submit and edit forms this way. Neither carries an hx-* attribute, so the no-JavaScript path is the only path there, and unit tests in src/routes/posts.rs assert it: no htmx attributes, a method="post" form, and the hidden _csrf input that gets a plain POST past the CSRF layer.


Accessible fields by construction

The form helpers emit labels and error wiring for you. When you build a field by hand, use the typed primitives in autumn_web::a11y rather than raw markup: TextField, TextArea, Select, Checkbox, FileField and RadioGroup do not implement Render until a label is attached, so an unlabeled field is not merely discouraged — it does not compile.

Rust
use autumn_web::a11y::TextField;

TextField::new("title")
    .label("Title")                       // required before it can render
    .value(form.field_value("title").unwrap_or_default())
    .required()                           // emits the HTML5 constraint
    .aria_invalid(!form.errors_for("title").is_empty())
    .described_by("title-error")          // ties the field to its message

Pair described_by with an element carrying that id and role="alert", and a screen-reader user hears the error when focus lands on the field. The accessibility guide covers the full set, and autumn a11y verify fails a build that regresses.


Multi-value fields

A checkbox group, a <select multiple> and a tag input send one key more than once: tags=rust&tags=web. Give the field a sequence type. Each value becomes one element:

Rust
#[derive(Deserialize, Serialize, Validate)]
struct PostForm {
    title: String,
    #[serde(default)]          // no box checked → empty list
    tags: Vec<String>,
    #[serde(default)]
    reviewer_ids: HashSet<i64>,
    labels: Option<Vec<String>>, // absent → None
}

ChangesetForm<T>, NestedChangesetForm and the multipart path all use these rules. So does autumn_web::form::from_urlencoded, for a handler that reads Bytes:

BodyFieldResult
tags=a&tags=bVec<String>["a", "b"]
tags=aVec<String>["a"]
(no tags key)#[serde(default)] Vec<String>[]
(no labels key)Option<Vec<String>>None
q=a%20b&q=c+dVec<String>["a b", "c d"]
ids=1&ids=&ids=3Vec<i64>[1, 3] — a blank element of a typed list is dropped
q=&q=xVec<String>["", "x"] — a blank string stays
ids=1&ids=xVec<i64>400
name=a&name=bString422

A repeated key on a single-value field is a 422. The decoder does not select the first value or the last value. Two layers can select different values (a proxy log and the handler, for example). That causes parameter-pollution bugs. The message names the field, so a field that must be a Vec is easy to find. Single-value fields that arrive once decode as before.

Notes:

  • Brackets are part of the key. ids[]=1 does not fill ids. Use #[serde(alias = "ids[]")] to accept both spellings. Do not send both in one body.
  • bool also accepts on, the value a checkbox with no value attribute sends.
  • A blank numeric or bool Option (page=) is None. A #[serde(default = "...")] on that field does not apply to a blank value.
  • A fixed-size array ([i64; 2]) and a newtype around a Vec take one value. Use a plain Vec.
  • Changeset::field_values("tags") returns every element as a string, to re-render the checked boxes or the selected options. In a nested form row, row.values("tags") returns every submitted value of that subfield. row.value("tags") returns only the last one.
  • field_from_urlencoded reads one raw value for a preview. It is lenient and returns the first value.
  • Form<T> still uses serde_urlencoded. It rejects a repeated key on a struct field.

Multi-row and multi-step forms

  • Repeated rows — a has_many edited inline, with add/remove — are their own binding problem. See nested forms.
  • Multi-step flows that must survive a refresh belong in a wizard.
  • At-most-once submission (payments, anything with an external side effect) needs a submit token, not a disabled button.

Uploads

Multipart applies the app's security.upload policy — MIME allowlist and per-file size caps — before your handler sees a byte. See storage for where the bytes then go.

ChangesetForm keeps file parts, so one form can take a file upload and its text fields together. On a multipart/form-data body (the multipart feature), the text fields decode into T as usual. Each file part goes to the form, not to T:

Rust
#[post("/profile")]
async fn update(mut form: ChangesetForm<ProfileForm>) -> impl IntoResponse {
    if form.file("avatar").is_none() {
        form.changeset.add_error("avatar", "Choose a picture");
    }
    let files = form.take_files(); // take them before `into_valid` consumes the form
    match form.into_valid() {
        Ok(profile) => { /* store `files`, save `profile` */ }
        Err(form) => (StatusCode::UNPROCESSABLE_ENTITY, render(&form)).into_response(),
    }
}
MethodReturns
form.file("avatar")the first file part named avatar
form.files("photos")every file part named photos (<input type="file" multiple>)
form.take_files()all file parts, moved out of the form

Each FormFile has field(), file_name(), content_type() (from the client), sniffed_content_type() (from the bytes, when a MIME policy is set) and bytes().

Rules:

  • The security.upload policy applies: the MIME allowlist (400), the per-file cap (413), and the request cap. The per-file cap also applies to each text part. A route that sets a smaller DefaultBodyLimit keeps it.
  • The form holds each file in memory. The per-file cap and the request cap limit that memory. For a file larger than you want in memory, use Multipart and stream it to storage on its own route.
  • An empty optional file input (no file chosen) is not a file. file(..) returns None.
  • A browser cannot fill a file input again. When you re-render after an error, the user must choose the file again.

Testing forms

TestResponse has HTML assertions, so a form test reads like the form:

Rust
let res = app.post("/todos").form("title=").send().await;
res.assert_status(422)
   .assert_selector("input[name=title][aria-invalid=true]")
   .assert_text_contains("[role=alert]", "Title must be 1–255 characters");

Test the failure path first. The success path is one insert; the failure path is the one with the round-trip, the ARIA wiring, and the CSRF token in it.

Building a ChangesetForm by hand in a unit test has one trap: ChangesetForm::without_csrf and ChangesetForm::blank wrap the data in a fresh, error-free changeset — they never call validate. Only IntoChangeset::into_changeset runs the rules, so a test that wants to exercise them has to go through it:

Rust
// Renders, but proves nothing about the rules — `into_valid()` always succeeds.
let form = ChangesetForm::without_csrf(SubmitPostForm { .. });

// Runs the rules.
let form = ChangesetForm::from_changeset(SubmitPostForm { .. }.into_changeset());

For normalization, assert on what was stored, not on what was accepted:

Rust
// "  [email protected] " — `+` is a space in a form-urlencoded body.
app.post("/users").form("email=++Alice%40Example.COM+").send().await;

let user = users.find_by_email("[email protected]").await?.unwrap();
assert_eq!(user.email, "[email protected]");

See the testing guide.


See also

  • Extractors — where ChangesetForm and Valid sit among the rest
  • Nested forms — has_many rows in one submission
  • Accessibility — the typed primitives, and autumn a11y verify
  • Rich text — accepting formatted text safely
  • Generators — what autumn generate scaffold emits, and its no-JS fallbacks
  • Repositories — where model validation and normalization run
  • Submit tokens — at-most-once form submission
  • Wizards — multi-step flows