Forms & Data Binding

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08. Every example was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21, the auto-configured SpringTemplateEngine, MockMvc and Spring Security’s test support), and the rendered output shown is the real output of those runs. Form binding is a feature of the Spring integration (thymeleaf-spring6, the module also used with Spring Framework 7): the Standard Dialect alone has th:object, but th:field, th:errors, the #fields and #ids helpers that talk to Spring’s binder, and the RequestDataValueProcessor hook exist only with Spring MVC. The same attributes are available with Spring WebFlux (which has its own reactive RequestDataValueProcessor; the examples here run on MVC), while a standalone engine has no binder and so no th:field.

An HTML form is a list of strings travelling to the server, and a Java application wants a typed object. Spring MVC closes that gap with a command object (also called form-backing bean): the controller puts it into the model, the template renders each of its properties as a form control, and when the form is posted Spring’s data binder converts the strings back into a new instance of the same class. Thymeleaf’s part is the first half: th:object selects the object and th:field writes the id, name, value and checked or selected state of each control so that the names are exactly what the binder will read. Validation and the error messages that come back are the subject of the next page of the section (Validation & Error Messages); this page builds the round trip itself. The running scenario is the Bookshelf introduced on the Getting Started page.

The form binding round-trip: the GET handler puts a form object in the model, the template renders it, the browser posts strings, the binder fills a new object and the POST handler redisplays or redirects

The command object: th:object and th:action

Thymeleaf requires the command object to be declared on the <form> tag with th:object, and the form’s target to be written with th:action:

<form action="#" th:action="@{/forms/books/save}" th:object="${book}" method="post">
  ...
</form>

The Spring tutorial attaches three restrictions to th:object on a form tag:

  • the value must be a variable expression that names only a model attribute, without property navigation: ${book} is valid and ${book.author} is not

  • no other th:object may appear inside the form, which is consistent with the fact that HTML forms cannot be nested

  • the name must match the model attribute on the Spring side: by default the uncapitalised simple class name of the command object (a BookForm is exposed as bookForm), or whatever name @ModelAttribute("book") or Model.addAttribute("book", ...) set

th:action is more than th:href for forms. It builds the URL with the same link-expression machinery as the Link URLs page (context path, rewriting) and, as shown further down, it is the attribute that asks Spring Security for the CSRF hidden field. The static action="#" is what a browser shows when the template is opened from disk, so the file stays a natural template.

Form-data objects versus domain objects

The object behind th:object is bound from request parameters, so every property with a setter is a property an attacker can set by adding a parameter. Binding a JPA entity directly (a Book with its id, version and any internal flag) invites that over-posting, and it also pushes web concerns (a LocalDate formatted for an HTML date input, an Author chosen from a list) into the persistence model. The Bookshelf therefore keeps two classes: the Book entity, and a BookForm that holds only what the page edits, with web-friendly types and the formatting and validation annotations:

public class BookForm {
    private Long id;
    private Integer version;
    @NotBlank private String title;
    private String summary;
    @Min(1) private Integer pages;
    @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) private LocalDate published;
    private Genre genre;
    private Format format;
    private Author author;
    private boolean featured;
    private List<String> tags = new ArrayList<>();

    // getters and setters
}

The controller exposes the form object (and the reference data the controls need) to the template. Methods annotated with @ModelAttribute add their return value to the model of every handler method in the controller:

@Controller
@RequestMapping("/forms/books")
public class BookFormController {
    // constructor injecting BookRepository books and AuthorRepository authors

    @ModelAttribute("genres")
    Genre[] genres() { return Genre.values(); }

    @ModelAttribute("formats")
    Format[] formats() { return Format.values(); }

    @ModelAttribute("authors")
    List<Author> authors() { return authors.findAll(); }

    @ModelAttribute("allTags")
    List<String> allTags() { return List.of("classic", "award", "translated"); }

    @GetMapping("/new")
    public String create(Model model) {
        model.addAttribute("book", new BookForm());
        model.addAttribute("mode", EditMode.CREATE);
        return "forms/book";
    }
}

The same mechanism, declared in a @ControllerAdvice class instead, exposes an attribute to every controller of the application (an application version for the footer, a list of languages). The rest of the controller is built up in the sections below, and the whole flow is the Post/Redirect/Get pattern: the POST handler either redisplays the form (errors) or saves and answers with redirect:, so that a reload of the result page does not post again.

th:field on inputs, textareas and hidden fields

th:field is the attribute that does the work of binding a control to a property. It is the equivalent of the path attribute of Spring’s <form:input> JSP tags (see Spring MVC with JSP), and it behaves differently on <input>, <select> and <textarea>, and by the type of the input. Its value must be a selection expression (*{...}), evaluated on the form-backing bean rather than on the context variables, and unlike th:object it may navigate properties.

<input type="hidden" th:field="*{id}">
<input type="hidden" th:field="*{version}">

<label for="title">Title</label>
<input type="text" th:field="*{title}">
<label for="pages">Pages</label>
<input type="number" min="1" th:field="*{pages}">
<label for="published">Published</label>
<input type="date" th:field="*{published}">
<label for="summary">Summary</label>
<textarea rows="3" th:field="*{summary}"></textarea>

Rendering the edit page of an existing book (the same template, its form object loaded from the database) produces:

Rendered output (fields of the edit page)
<input type="hidden" id="id" name="id" value="3">
<input type="hidden" id="version" name="version" value="0">

<label for="title">Title</label>
<input type="text" id="title" name="title" value="The Dispossessed">
<label for="pages">Pages</label>
<input type="number" min="1" id="pages" name="pages" value="387">
<label for="published">Published</label>
<input type="date" id="published" name="published" value="1974-05-01">
<label for="summary">Summary</label>
<textarea rows="3" id="summary" name="summary">An ambiguous utopia</textarea>

What th:field does, as shown by that output:

  • it writes id and name from the property path (title, and for nested paths such as rows[0].name the same string), so the posted parameter names always match what the binder reads

  • it writes the value from the bean, and for a textarea it fills the element body; the value is always HTML-escaped, and unlike Spring’s JSP tags there is no switch to turn the escaping off

  • it applies Spring’s registered conversion service even when the expression is not double-bracketed: the LocalDate appeared as 1974-05-01 because of @DateTimeFormat(iso = DateTimeFormat.ISO.DATE), the format an HTML date input requires (see Conversion & Formatting)

  • it understands the HTML5 input types (number, date, email, color and so on), so they are bound like any other text field, and any other attributes on the tag, such as min and rows, are left untouched

  • after a failed bind it shows the value that was rejected instead of the bean’s value, so the user does not lose what they typed (the failed date next summer came back verbatim in the value of the date input in the run)

Trimming and other binder settings

Trimming whitespace and turning empty strings into null is a binder concern, not a template one. An @InitBinder method on the controller registers a StringTrimmerEditor:

@InitBinder
void trimStrings(WebDataBinder binder) {
    binder.registerCustomEditor(String.class, new StringTrimmerEditor(true)); // "  " becomes null
}

In the run, a title posted as + Invisible Cities + was saved as Invisible Cities, and an empty title was seen by @NotBlank as null and rejected. @InitBinder is also where setAllowedFields / setDisallowedFields belong when a form object has to be bound from a class with more setters than the page edits. A PropertyEditor registered there is per-controller and per-request; a Formatter or Converter registered globally in the conversion service is the current, thread-safe way for types used all over the application (details on the Conversion & Formatting page).

Labels, ids and #ids

A label must point at its control’s id, and th:field chooses that id itself. The rules found in the run are:

  • for a text input, textarea, select, hidden field and file input, the generated id is the field name (title), so a plain for="title" is correct

  • for checkboxes and radio buttons the id carries a sequence suffix (featured1, tags1, tags2), because several controls share a name; the sequence comes from the #ids utility object, and the matching label has to ask it for the same value

#ids.seq('x') generates an id with the next sequence number for x, #ids.next('x') returns the id the next control will get (use it in a label written before its control, as the Spring tutorial does for its single checkbox) and #ids.prev('x') returns the id of the control just rendered (use it in a label written after it, as the examples below do).

<input type="checkbox" th:field="*{featured}">
<label th:for="${#ids.prev('featured')}">Featured on the home page</label>

Two things do not work, and both were tried: th:for="*{title}" renders for="The Dispossessed" (a selection expression yields the value of the property, not its id), and #ids.next('title') on a text input renders for="title1" while the control is id="title". Use the #ids functions for checkboxes and radio buttons only. Associating every control with a label is also what makes a form usable with a screen reader, as covered on Accessibility.

Checkboxes

A single boolean checkbox

A checkbox bound to a boolean property needs only th:field. Browsers send nothing for an unchecked checkbox, so Spring’s binder uses a convention: for every control it can see, the template also writes a hidden field named _ plus the property name, and a request that carries _featured but no featured is read as "present and unchecked". th:field writes that hidden marker for you:

Rendered output (a single checkbox, the book is featured)
<input type="checkbox" id="featured1" name="featured" value="true" checked="checked"><input type="hidden" name="_featured" value="on"/>
<label for="featured1">Featured on the home page</label>

The prefix _ is WebDataBinder.DEFAULT_FIELD_MARKER_PREFIX and can be changed with setFieldMarkerPrefix. A test that posts only the markers confirms that the binder turns them into the empty values:

@Test
void uncheckedBoxesAreSentAsMarkers() throws Exception {
    Book b = saved();
    mvc.perform(post("/forms/books/save").with(csrf())
                    .param("id", String.valueOf(b.getId())).param("version", String.valueOf(b.getVersion()))
                    .param("title", "The Dispossessed").param("_featured", "on").param("_tags", "on"))
            .andExpect(status().is3xxRedirection());
    Book reloaded = books.findById(b.getId()).orElseThrow();
    assertThat(reloaded.isFeatured()).isFalse();
    assertThat(reloaded.getTags()).isEmpty();
}

Several checkboxes bound to a collection

When the property is a collection (a List<String> of tags here, but an array or a Set also works), every checkbox needs a th:value: it is the value that is added to the collection when the box is checked, and it is how th:field decides which boxes to mark checked:

<fieldset>
  <legend>Tags</legend>
  <div th:each="tag : ${allTags}">
    <input type="checkbox" th:field="*{tags}" th:value="${tag}">
    <label th:for="${#ids.prev('tags')}" th:text="${tag}">tag</label>
  </div>
</fieldset>
Rendered output (the stored book has the tags classic and award)
<div>
  <input type="checkbox" value="classic" id="tags1" name="tags" checked="checked"><input type="hidden" name="_tags" value="on"/>
  <label for="tags1">classic</label>
</div>
<div>
  <input type="checkbox" value="award" id="tags2" name="tags" checked="checked"><input type="hidden" name="_tags" value="on"/>
  <label for="tags2">award</label>
</div>
<div>
  <input type="checkbox" value="translated" id="tags3" name="tags"><input type="hidden" name="_tags" value="on"/>
  <label for="tags3">translated</label>
</div>

The id suffix is the #ids sequence, and a marker is written after each box. Posting tags=classic&tags=translated&_tags=on binds a list with those two values, which the creation test in the scratch application asserts.

Where the hidden marker goes

By default the marker is written after the checkbox, as above. Spring’s own tag library offers the opposite order, and Thymeleaf has a switch for it: the Spring Boot property spring.thymeleaf.render-hidden-markers-before-checkboxes (default false) sets the renderHiddenMarkersBeforeCheckboxes flag of the SpringTemplateEngine.

spring.thymeleaf.render-hidden-markers-before-checkboxes=true
Rendered output (property set to true)
<input type="hidden" name="_featured" value="on"/><input type="checkbox" id="featured1" name="featured" value="true">
<label for="featured1">Featured on the home page</label>

The order matters to client code that looks at nextElementSibling of the box, and to CSS or JavaScript that styles or wraps the pair; the binder does not care. The property and the flag are described with the other spring.thymeleaf.* settings on Spring Boot Auto-configuration & Properties.

Radio buttons

Radio buttons are written like multi-valued checkboxes, except that they are single-valued, so th:value supplies each option’s value and th:field marks the one equal to the bound property. No hidden marker is written for a radio group:

<fieldset>
  <legend>Genre</legend>
  <div th:each="g : ${genres}">
    <input type="radio" th:field="*{genre}" th:value="${g}">
    <label th:for="${#ids.prev('genre')}" th:text="${g}">genre</label>
  </div>
</fieldset>
Rendered output (the stored book is FICTION)
<div>
  <input type="radio" value="FICTION" id="genre1" name="genre" checked="checked">
  <label for="genre1">FICTION</label>
</div>
<div>
  <input type="radio" value="SCIENCE" id="genre2" name="genre">
  <label for="genre2">SCIENCE</label>
</div>
<div>
  <input type="radio" value="HISTORY" id="genre3" name="genre">
  <label for="genre3">HISTORY</label>
</div>

The property here is an enum, and the string FICTION is turned into Genre.FICTION by Spring’s StringToEnumConverterFactory; an unknown value is a binding error (a typeMismatch code) and not an exception: posting genre=BOGUS in the run redisplayed the form with status 200. The labels shown to people usually come from the message bundle (a #{...} lookup keyed by the enum name) rather than from th:text="${g}"; that keeps the submitted value stable while the displayed text is translated.

Selects

Only the <select> carries th:field; the nested <option> tags carry th:value, which is what lets th:field decide which option gets selected. Attribute precedence lets th:each sit on the <option> itself.

A select over an enum

<label for="format">Format</label>
<select th:field="*{format}">
  <option value="">-- choose --</option>
  <option th:each="f : ${formats}" th:value="${f}" th:text="${f}">format</option>
</select>
Rendered output (the stored book is a HARDCOVER)
<label for="format">Format</label>
<select id="format" name="format">
  <option value="">-- choose --</option>
  <option value="PAPERBACK">PAPERBACK</option>
  <option value="HARDCOVER" selected="selected">HARDCOVER</option>
  <option value="EBOOK">EBOOK</option>
</select>

The first <option value=""> is the "nothing chosen" entry: an empty string posted for an enum or a number is bound as null by the standard conversion, which is what a form that allows no choice needs.

A select over entities

For a property whose type is an entity, the option value is the entity’s identifier, and a Converter turns the posted identifier back into the entity before it reaches the form object. Registering the converter as a Spring bean is enough: Spring Boot adds every Converter bean to the MVC conversion service (details on Conversion & Formatting).

@Component
public class AuthorConverter implements Converter<String, Author> {
    private final AuthorRepository authors;

    public AuthorConverter(AuthorRepository authors) { this.authors = authors; }

    @Override
    public Author convert(String id) {
        return id.isBlank() ? null : authors.findById(Long.valueOf(id)).orElse(null);
    }
}
<label for="author">Author</label>
<select th:field="*{author}">
  <option value="">-- choose --</option>
  <option th:each="a : ${authors}" th:value="${a.id}" th:text="${a.name}">author</option>
</select>
Rendered output (the stored book’s author has the id 1)
<label for="author">Author</label>
<select id="author" name="author">
  <option value="">-- choose --</option>
  <option value="1" selected="selected">Ursula K. Le Guin</option>
  <option value="2">Italo Calvino</option>
</select>

In the run, the option of the stored author was marked selected with only this one-way converter registered, and the posted id 2 was bound to the Italo Calvino author (the creation test asserts getAuthor().getName()). The converter does a database lookup per request, so a request carrying an unknown id (author=999 in the run) leaves the property null and is accepted; validate that a required choice is present, and use the posted value only as an identifier to look up.

Dynamic fields: indexed rows

A form that edits a list (the chapters of a book) binds each row to an element of a collection with an indexed property path, rows[0].name. The template iterates the list and builds the index into the expression with preprocessing: the double underscore syntax evaluates ${rowStat.index} before the selection expression is evaluated, so the final expression is the literal path rows[0].name.

<form action="#" th:action="@{/forms/outline}" th:object="${outline}" method="post">
  <input type="text" th:field="*{title}">
  <table>
    <thead>
      <tr>
        <th>#</th><th>Chapter</th><th>Pages</th>
        <th><button type="submit" name="addRow">Add row</button></th>
      </tr>
    </thead>
    <tbody>
      <tr th:each="row, rowStat : *{rows}">
        <td th:text="${rowStat.count}">1</td>
        <td><input type="text" th:field="*{rows[__${rowStat.index}__].name}"></td>
        <td><input type="number" th:field="*{rows[__${rowStat.index}__].pages}"></td>
        <td><button type="submit" name="removeRow" th:value="${rowStat.index}">Remove</button></td>
      </tr>
    </tbody>
  </table>
  <button type="submit">Save outline</button>
</form>

Adding and removing rows needs no JavaScript: the two buttons submit the same form with an extra parameter, and the controller has one mapping per parameter. Spring picks the mapping whose params condition matches, and the plain @PostMapping handles the real submit:

@Controller
@RequestMapping("/forms/outline")
public class OutlineController {

    @GetMapping
    public String show(Model model) {
        OutlineForm form = new OutlineForm();
        form.getRows().add(new ChapterRow());
        model.addAttribute("outline", form);
        return "forms/outline";
    }

    @PostMapping(params = "addRow")
    public String addRow(@ModelAttribute("outline") OutlineForm form) {
        form.getRows().add(new ChapterRow());
        return "forms/outline";
    }

    @PostMapping(params = "removeRow")
    public String removeRow(@ModelAttribute("outline") OutlineForm form, @RequestParam("removeRow") int index) {
        if (index >= 0 && index < form.getRows().size()) {
            form.getRows().remove(index);
        }
        return "forms/outline";
    }

    @PostMapping
    public String save(@ModelAttribute("outline") OutlineForm form, Model model) {
        model.addAttribute("total", form.getRows().stream()
                .map(ChapterRow::getPages).filter(p -> p != null).mapToInt(Integer::intValue).sum());
        return "forms/outline";
    }
}

Posting addRow with one filled row, as the test does, redisplays the form with a second, empty row. The output of that run:

Rendered output (after Add row)
<tr>
  <td>1</td>
  <td><input type="text" id="rows0.name" name="rows[0].name" value="Winds"></td>
  <td><input type="number" id="rows0.pages" name="rows[0].pages" value="20"></td>
  <td><button type="submit" name="removeRow" value="0">Remove</button></td>
</tr>
<tr>
  <td>2</td>
  <td><input type="text" id="rows1.name" name="rows[1].name" value=""></td>
  <td><input type="number" id="rows1.pages" name="rows[1].pages" value=""></td>
  <td><button type="submit" name="removeRow" value="1">Remove</button></td>
</tr>

Two details of this design: a list property that is null is auto-grown by Spring’s binder when it sees rows[1].name (hence the initialised ArrayList in the form object, and the safe bound check in removeRow), and indexes are positions, not identities, so removing row 0 renumbers the later rows. Rows that are persisted entities should carry a hidden identifier field next to their other fields. A variant that does not reload the page, returning one row as a fragment to a fetch call or an htmx request, is described on the Fragment Rendering & AJAX page of this section.

Shared create and edit templates

Create and edit differ in the form object’s initial state and in a few labels, not in the controls. One template serves both; each handler puts an EditMode enum in the model and the template reads it for the title and button:

@GetMapping("/{id}/edit")
public String edit(@PathVariable Long id, Model model) {
    Book book = books.findById(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    model.addAttribute("book", toForm(book));
    model.addAttribute("mode", EditMode.EDIT);
    return "forms/book";
}

@PostMapping("/save")
public String save(@Valid @ModelAttribute("book") BookForm form, BindingResult result, Model model) {
    if (result.hasErrors()) {
        model.addAttribute("mode", form.getId() == null ? EditMode.CREATE : EditMode.EDIT);
        return "forms/book"; // same URL, same model: the user's input is still there
    }
    Book saved = books.save(toEntity(form)); // a stale version throws ObjectOptimisticLockingFailureException
    return "redirect:/forms/books/" + saved.getId() + "/edit"; // Post/Redirect/Get
}
<h1 th:text="${mode.name() == 'CREATE'} ? 'New book' : |Edit book ${book.title}|">Book</h1>
...
<button type="submit" th:text="${mode.name() == 'CREATE'} ? 'Create' : 'Save'">Save</button>

The single POST /save handler distinguishes the two modes by the hidden id field that the template always renders (empty on create, filled on edit). The redirect uses the identifier of the saved entity only, never text from the request. Two more rules keep this safe: a state-changing form is always a POST (the template’s method="post"), and the handler never builds the view name from request input (the two view names are literals). The error branch is the "bad input" path of the round trip in the figure: the form is rendered again from the very same object, so that every control shows what the user submitted. The model attribute mode is re-added because the failed POST creates a new model.

Optimistic locking with a hidden version field

Two users who open the same book and save one after the other would silently overwrite each other’s changes. JPA’s optimistic locking detects it: an @Version property on the entity is compared at flush time, and a stale value is an error. For that to work across requests the version has to travel with the form, so the template writes it into a hidden field (see the first two lines of the input example) and the form object carries it back. The POST handler builds a detached entity from the form (id and version included) and save merges it, which is when the stale version is detected:

@Entity
public class Book {
    @Id @GeneratedValue private Long id;
    @Version private Integer version;
    // other properties, getters and setters
}

The failure is ObjectOptimisticLockingFailureException, which an advice turns into an HTTP 409 with a template that tells the user to reload:

@ControllerAdvice
public class FormsAdvice {
    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public String conflict() { return "forms/conflict"; }
}

The test submits the same version twice; the first save wins and bumps the version to 1, the second carries a stale 0:

@Test
void secondStaleSubmissionIsAConflict() throws Exception {
    Book b = saved();
    var first = post("/forms/books/save").with(csrf())
            .param("id", String.valueOf(b.getId())).param("version", "0").param("title", "First edit");
    var second = post("/forms/books/save").with(csrf())
            .param("id", String.valueOf(b.getId())).param("version", "0").param("title", "Second edit");
    mvc.perform(first).andExpect(status().is3xxRedirection());
    MvcResult r = mvc.perform(second).andExpect(status().isConflict()).andReturn();
    assertThat(books.findById(b.getId()).orElseThrow().getTitle()).isEqualTo("First edit");
}
Rendered output (the conflict page, status 409)
<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>Conflict</title></head>
<body><h1>Somebody else changed this book</h1><p>Reload the page and apply your change again.</p></body>
</html>

A version that is missing from the request is a different case, and the run shows it is not silently accepted: posting an existing id without version fails with DataIntegrityViolationException: Detached entity with generated id '2' has an uninitialized version value 'null' for entity com.example.bookshelf.forms.Book.version (a 500 for the user). The hidden field is therefore part of the form’s contract, and the controller should reject a request that lacks it. The generic error pages (404, 5xx) and the conflict page belong to the Error Handling & Flash Messages page.

File upload with MultipartFile

A file input needs a multipart form: enctype="multipart/form-data" on the form and a MultipartFile property on the form object. Spring’s binder binds the uploaded part to it like any other property:

<form action="#" th:action="@{/forms/covers}" th:object="${cover}" method="post" enctype="multipart/form-data">
  <input type="text" th:field="*{caption}">
  <input type="file" accept="image/png" th:field="*{file}">
  <button type="submit">Upload</button>
</form>
Rendered output
<form action="/forms/covers" method="post" enctype="multipart/form-data"><input type="hidden" name="_csrf" value="..."/>
  <input type="text" id="caption" name="caption" value="">
  <input type="file" accept="image/png" id="file" name="file">
  <button type="submit">Upload</button>
</form>

A file input has no value to render (browsers forbid it), so th:field writes only id and name. The form object and the controller:

public class CoverForm {
    private String caption;
    private MultipartFile file;
    // getters and setters
}

@Controller
@RequestMapping("/forms/covers")
public class CoverController {
    private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
    private static final long MAX_BYTES = 1_000_000;
    private final Path directory;

    public CoverController() throws IOException {
        this.directory = Files.createTempDirectory("covers"); // placeholder storage for the example
    }

    @PostMapping
    public String upload(@ModelAttribute("cover") CoverForm form, Model model, RedirectAttributes redirect) throws IOException {
        MultipartFile file = form.getFile();
        if (file == null || file.isEmpty()) {
            model.addAttribute("problem", "Choose a file");
            return "forms/cover";
        }
        // The content type and the file name come from the client: check size and signature ourselves
        byte[] bytes = file.getBytes();
        if (bytes.length > MAX_BYTES || bytes.length < PNG.length
                || !Arrays.equals(Arrays.copyOf(bytes, PNG.length), PNG)) {
            model.addAttribute("problem", "Only PNG files up to 1 MB are accepted");
            return "forms/cover";
        }
        Path target = directory.resolve(UUID.randomUUID() + ".png"); // never the client's file name
        Files.write(target, bytes);
        redirect.addFlashAttribute("stored", bytes.length + " bytes");
        return "redirect:/forms/covers";
    }
}

The test uploads a PNG whose client-side name tries to escape the directory (../../etc/cover.png), which is never used, and a text file that is rejected because its first bytes are not the PNG signature:

@Test
void coverUpload() throws Exception {
    byte[] png = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0};
    mvc.perform(multipart("/forms/covers").file(new MockMultipartFile("file", "../../etc/cover.png", "image/png", png))
                    .param("caption", "Front").with(csrf()))
            .andExpect(status().is3xxRedirection()).andExpect(flash().attribute("stored", "12 bytes"));
    String html = mvc.perform(multipart("/forms/covers")
                    .file(new MockMultipartFile("file", "x.png", "image/png", "not a png at all".getBytes())).with(csrf()))
            .andExpect(status().isOk()).andReturn().getResponse().getContentAsString();
    assertThat(html).contains("Only PNG files up to 1 MB are accepted");
}

The servlet container enforces the size limits, through Spring Boot’s spring.servlet.multipart.* properties (these exist in the Boot 4.1 configuration metadata, with the defaults of 1MB for a file and 10MB for a request):

spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=2MB

A request over a limit fails with MaxUploadSizeExceededException before the controller runs, so handle it in an @ControllerAdvice if the user is to see a message. MockMvc does not apply the container’s limits (it builds the parts itself), which is why the example also checks MAX_BYTES in code; test the properties against a real server. Never trust the client for anything else about the file: the content type header and the file name are chosen by the sender, the original name must not become a path, and a signature check is the minimum before an upload is stored or served (a full image-processing and storage design is on the Media Optimization page of the Backend section).

The automatic CSRF field and RequestDataValueProcessor

With Spring Security on the classpath, every state-changing request must carry the CSRF token. In the forms above no template writes the token: th:action does it, through Spring’s RequestDataValueProcessor interface. Thymeleaf calls the bean named requestDataValueProcessor at these points:

Table 1. Where Thymeleaf calls RequestDataValueProcessor
Attribute Method called

th:href, th:src

processUrl(request, url) before the URL is written

th:action

processAction(request, action, httpMethod) for the action attribute, and on a <form> also getExtraHiddenFields(request): the returned fields are written right after the opening tag

th:value

processFormFieldValue(request, name, value, type), unless a th:field is on the same tag

th:field

processFormFieldValue(request, name, value, type) for the control’s value (or the body of a textarea)

Spring Security registers its implementation, CsrfRequestDataValueProcessor, under that bean name when the web security configuration is enabled, and its getExtraHiddenFields returns the _csrf field. That is the field that appeared right after the <form> tag in every output above:

Rendered output (start of any form with th:action and method="post")
<form action="/forms/books/save" method="post"><input type="hidden" name="_csrf" value="..."/>

The next test page has three forms, and shows what depends on th:action: only the second one, a POST whose action is a th:action, gets a token. A form with a plain action attribute does not, and neither does a GET form (Spring Security’s processor adds the token only for methods that change state).

<form action="/forms/books/save" method="post"><input type="text" name="title"></form>
<form th:action="@{/forms/books/save}" method="post"><input type="text" name="title"></form>
<form th:action="@{/forms/books/save}" method="get"><input type="text" name="q"></form>
Rendered output
<form action="/forms/books/save" method="post"><input type="text" name="title"></form>
<form action="/forms/books/save" method="post"><input type="hidden" name="_csrf" value="..."/><input type="text" name="title"></form>
<form action="/forms/books/save" method="get"><input type="text" name="q"></form>

Posting without the token is rejected by the Spring Security filter chain before any controller runs:

@Test
void postWithoutCsrfTokenIsRejected() throws Exception {
    mvc.perform(post("/forms/books/save").param("title", "x")).andExpect(status().isForbidden());
}

Keep CSRF protection on for browser forms. For the form that is not rendered by Thymeleaf (an htmx request, a fetch call) the token has to be sent in a header or a parameter by the client, and for non-browser clients the usual advice is a different security chain; the Spring Security integration page of this section covers that. Very few applications implement RequestDataValueProcessor themselves; the other legitimate use is rewriting URLs and form values (adding a signature, for instance) in one place.

Testing the forms

Every example on this page has a MockMvc test in the scratch application. The class setup is the part worth copying: a full application context, a mock user and the csrf() post-processor of Spring Security’s test support (the Spring Boot 4 package for the annotation is org.springframework.boot.webmvc.test.autoconfigure).

@SpringBootTest
@AutoConfigureMockMvc
@WithMockUser
class FormsTest {
    @Autowired MockMvc mvc;
    @Autowired BookRepository books;
    @Autowired AuthorRepository authors;

    @Test
    void newFormRenders() throws Exception {
        MvcResult r = mvc.perform(get("/forms/books/new")).andExpect(status().isOk()).andReturn();
        String html = r.getResponse().getContentAsString();
        assertThat(html).contains("name=\"_csrf\"").contains("id=\"title\"").contains("name=\"_featured\"");
    }
}

The whole scratch suite (thirteen form tests, including one that sets spring.thymeleaf.render-hidden-markers-before-checkboxes=true, plus the context-load test) passed with ./mvnw test.

Pitfalls

Table 2. Form binding pitfalls
Mistake What happens Do this

th:field in a form whose th:object names an attribute the controller did not put in the model

IllegalStateException: Neither BindingResult nor plain target object for bean name 'missing' available as request attribute, raised by SpringInputGeneralFieldTagProcessor

Add the form object in the GET handler, and use the same name in th:object, in @ModelAttribute and in Model.addAttribute

Rendering the form again after a failed POST without the reference data

The selects and radio groups come back empty

Put the lists in @ModelAttribute methods (they run for every handler), or add them in the error branch

Binding a JPA entity directly

Any request parameter can set any property with a setter (over-posting), and web formats leak into the entity

A separate form-data object, or setAllowedFields in an @InitBinder

th:for="*{title}" on a label

Renders the value of the property as the for attribute

Plain for="title" for text inputs, selects and textareas; #ids.prev(...) for checkboxes and radios

#ids.next('title') on a text input

for="title1" while the control is id="title": the label points at nothing

The #ids sequence applies to checkboxes and radio buttons only

A multi-valued checkbox without th:value

Every box is submitted as on, or marked checked for the wrong reason

th:value="${tag}" on each box

A plain action instead of th:action on a POST form

No _csrf field; Spring Security answers 403

th:action="@{...}" on every form

Leaving out the hidden version of an entity edited through a form

DataIntegrityViolationException (uninitialized version value null on a detached entity), so the user sees an error page

Always <input type="hidden" th:field="*{version}">, and make it required

enctype missing on a file form

The browser sends only the file name as text, the MultipartFile is empty or null

enctype="multipart/form-data", and check size, content and name on the server

Using the uploaded file’s original name as a path

Path traversal and overwriting of existing files

Generate the name (a UUID), keep only a validated extension

removeRow by an index that was not checked

IndexOutOfBoundsException from a forged request

Check 0 <= index < size before removing

th:object on a nested expression, ${book.author}

Rejected: only a model attribute name is allowed on a form tag

Bind the nested object with *{author.name} inside a form whose th:object is ${book}

Version notes

  • The th:object, th:field, #ids, #fields and RequestDataValueProcessor integration are the same attributes as in Thymeleaf 3.0; the only change is the base package, jakarta.servlet since Spring Framework 6 / Thymeleaf 3.1 with thymeleaf-spring6.

  • spring.thymeleaf.render-hidden-markers-before-checkboxes is a property of the Boot integration; as of Spring Boot 4.x the auto-configuration lives in the spring-boot-thymeleaf module (see the Spring Boot Auto-configuration & Properties page).

  • The Spring tutorial’s example th:object forms and #ids examples are valid, but its sample application predates Spring Boot; take the dependency coordinates and configuration from this section instead.

  • The consulted edition of Taming Thymeleaf (Wim Deblauwe, PDF v1.1.1, 2021) builds the same form flow (enum radios, @DateTimeFormat, shared create and edit template, optimistic locking with a hidden version, StringTrimmerEditor, file upload); its Boot 2 era package names (javax.*) and some of its security configuration are outdated, but the Thymeleaf attributes are not.