FormSets¶
Handle multiple instances of the same form on a single page. FormSets manage the creation, validation, and processing of a collection of identical forms -- useful for bulk creation, inline editing, and tabular data entry.
Basic FormSet¶
from hyperdjango.forms import Form, CharField, FormSet
class ItemForm(Form):
name = CharField(max_length=100)
quantity = CharField(max_length=10)
# Create a FormSet factory with 3 empty forms
ItemFormSet = FormSet(ItemForm, extra=3)
@app.get("/items/bulk-create")
async def bulk_create_form(request):
formset = ItemFormSet()
return render(request, "items/bulk_create.html", {"formset": formset})
@app.post("/items/bulk-create")
async def bulk_create(request):
data = await request.form_data()
formset = ItemFormSet(data=data)
if formset.is_valid():
for form_data in formset.cleaned_data:
if form_data: # skip empty forms
await Item.objects.create(**form_data)
return redirect("/items/")
return render(request, "items/bulk_create.html", {"formset": formset}, status=400)
FormSet Factory Options¶
FormSet(
form_class, # The Form class to repeat
extra=1, # Number of extra empty forms
max_num=None, # Maximum total forms allowed
min_num=0, # Minimum number of forms that must be filled
can_delete=False, # Add a DELETE checkbox to each form
can_order=False, # Add an ORDER field to each form
validate_max=False, # Enforce max_num during validation
validate_min=False, # Enforce min_num during validation
)
Option details¶
| Parameter | Type | Default | Description |
|---|---|---|---|
form_class |
type[Form] |
(required) | The Form class that each form in the set is an instance of |
extra |
int |
1 |
Number of additional empty forms displayed beyond any initial data |
max_num |
int \| None |
None |
Upper limit on total forms. None means no limit. |
min_num |
int |
0 |
Minimum number of forms that must contain data |
can_delete |
bool |
False |
Adds a DELETE boolean field to each form |
can_order |
bool |
False |
Adds an ORDER integer field to each form |
validate_max |
bool |
False |
If True, raises a validation error when submitted form count exceeds max_num |
validate_min |
bool |
False |
If True, raises a validation error when filled form count is below min_num |
Examples¶
# Allow up to 10 items, require at least 1
ItemFormSet = FormSet(
ItemForm,
extra=3,
min_num=1,
max_num=10,
validate_min=True,
validate_max=True,
)
# Editable list with delete checkboxes
EditableItemFormSet = FormSet(
ItemForm,
extra=0,
can_delete=True,
)
# Orderable list
OrderableItemFormSet = FormSet(
ItemForm,
extra=0,
can_order=True,
)
FormSet Validation¶
is_valid()¶
Call is_valid() to validate all forms in the set. Returns True only if every non-empty form passes validation and the formset-level constraints (min_num, max_num) are met:
formset = ItemFormSet(data=form_data)
if formset.is_valid():
# All forms passed validation
for cleaned in formset.cleaned_data:
print(cleaned) # {"name": "Widget", "quantity": "5"}
else:
# Validation failed
print(formset.errors)
errors¶
formset.errors is a list of error dictionaries, one per form. Each dictionary maps field names to lists of error messages:
formset = ItemFormSet(data=invalid_data)
formset.is_valid() # False
# errors is a list with one dict per form
print(formset.errors)
# [
# {}, # Form 0: valid
# {"name": ["This field is required."]}, # Form 1: missing name
# {}, # Form 2: empty (skipped)
# ]
non_form_errors¶
Errors that apply to the formset as a whole (not to any individual form) are in non_form_errors:
formset = ItemFormSet(data=too_many_forms)
formset.is_valid()
print(formset.non_form_errors)
# ["Please submit at most 10 forms."]
Non-form errors are raised when:
validate_max=Trueand more thanmax_numforms are submittedvalidate_min=Trueand fewer thanmin_numforms contain data
cleaned_data¶
After successful validation, cleaned_data is a list of dictionaries with the validated and cleaned data from each form. Empty forms (where all fields are blank) return empty dicts:
formset = ItemFormSet(data=form_data)
if formset.is_valid():
for i, cleaned in enumerate(formset.cleaned_data):
if cleaned: # Skip empty forms
print(f"Form {i}: {cleaned}")
# Form 0: {"name": "Widget", "quantity": "5"}
# Form 1: {"name": "Gadget", "quantity": "3"}
Template Usage¶
Rendering the formset¶
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<!-- Management form data (required) -->
<input
type="hidden"
name="form-TOTAL_FORMS"
value="{{ formset.total_form_count }}"
/>
<input
type="hidden"
name="form-INITIAL_FORMS"
value="{{ formset.initial_form_count }}"
/>
{% for form in formset.forms %}
<fieldset>
<legend>Item {{ loop.index }}</legend>
{% for field in form.fields %}
<label>{{ field.label }}</label>
<input
type="{{ field.widget }}"
name="{{ field.html_name }}"
value="{{ field.value }}"
/>
{% if field.errors %}
<span class="error">{{ field.errors[0] }}</span>
{% endif %} {% endfor %}
</fieldset>
{% endfor %} {% if formset.non_form_errors %}
<div class="errors">
{% for error in formset.non_form_errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<button type="submit">Save All</button>
</form>
Management form¶
The management form is a set of hidden inputs that tell the formset how many forms were submitted. These are required for proper formset processing:
| Field | Purpose |
|---|---|
form-TOTAL_FORMS |
Total number of forms in the submission |
form-INITIAL_FORMS |
Number of forms with initial data (existing records) |
Without these fields, the formset cannot determine how many forms to process.
Form numbering¶
Each form's fields are prefixed with a form number to avoid name collisions. For a form at index 0 with a field named name, the HTML name is form-0-name:
<!-- Form 0 -->
<input type="text" name="form-0-name" value="Widget" />
<input type="text" name="form-0-quantity" value="5" />
<!-- Form 1 -->
<input type="text" name="form-1-name" value="Gadget" />
<input type="text" name="form-1-quantity" value="3" />
<!-- Form 2 (extra, empty) -->
<input type="text" name="form-2-name" value="" />
<input type="text" name="form-2-quantity" value="" />
Dynamic form adding with JavaScript¶
To allow users to add more forms dynamically, clone a form template and update the form index:
<div id="formset-container">
{% for form in formset.forms %}
<fieldset class="form-row" data-index="{{ loop.index0 }}">
<legend>Item {{ loop.index }}</legend>
{% for field in form.fields %}
<label>{{ field.label }}</label>
<input
type="{{ field.widget }}"
name="{{ field.html_name }}"
value="{{ field.value }}"
/>
{% endfor %}
</fieldset>
{% endfor %}
</div>
<button type="button" id="add-form">Add Another Item</button>
<script>
document.getElementById("add-form").addEventListener("click", function () {
const container = document.getElementById("formset-container");
const totalForms = document.querySelector("[name='form-TOTAL_FORMS']");
const currentCount = parseInt(totalForms.value);
// Clone the last form row
const lastRow = container.querySelector(".form-row:last-child");
const newRow = lastRow.cloneNode(true);
// Update field names and IDs with new index
newRow
.querySelectorAll("input, select, textarea")
.forEach(function (input) {
input.name = input.name.replace(
/form-\d+-/,
"form-" + currentCount + "-",
);
input.value = ""; // Clear values
});
// Update legend
newRow.querySelector("legend").textContent = "Item " + (currentCount + 1);
container.appendChild(newRow);
totalForms.value = currentCount + 1;
});
</script>
ModelFormSet¶
A ModelFormSet binds a formset to a database model, enabling bulk create and edit operations with automatic saving:
from hyperdjango.forms import ModelForm, ModelFormSet
class ItemForm(ModelForm):
class Meta:
model = Item
fields = ["name", "quantity", "price"]
# Create a ModelFormSet
ItemModelFormSet = ModelFormSet(ItemForm, extra=3)
Loading existing data¶
@app.get("/items/edit")
async def edit_items(request):
items = await Item.objects.all()
formset = ItemModelFormSet(initial=items)
return render(request, "items/edit.html", {"formset": formset})
Saving the formset¶
@app.post("/items/edit")
async def save_items(request):
data = await request.form_data()
formset = ItemModelFormSet(data=data)
if formset.is_valid():
await formset.save() # Creates new records, updates existing ones
return redirect("/items/")
return render(request, "items/edit.html", {"formset": formset}, status=400)
The save() method:
- Updates existing records (those with an
idin the form data) - Creates new records (forms without an
id) - Deletes records marked for deletion (if
can_delete=True)
Filtering the queryset¶
@app.get("/items/edit")
async def edit_items(request):
# Only show items belonging to the current user
items = await Item.objects.filter(owner_id=request.user.id).all()
formset = ItemModelFormSet(initial=items, extra=2)
return render(request, "items/edit.html", {"formset": formset})
Deletion Workflow¶
When can_delete=True, each form gets a DELETE checkbox. When checked, the form's data is included in deleted_forms instead of cleaned_data.
Setup¶
Template¶
{% for form in formset.forms %}
<fieldset>
<legend>Item {{ loop.index }}</legend>
{% for field in form.fields %} {% if field.name == "DELETE" %}
<label>
<input type="checkbox" name="{{ field.html_name }}" value="true" />
Delete this item
</label>
{% else %}
<label>{{ field.label }}</label>
<input
type="{{ field.widget }}"
name="{{ field.html_name }}"
value="{{ field.value }}"
/>
{% endif %} {% endfor %}
</fieldset>
{% endfor %}
Processing deletions¶
@app.post("/items/edit")
async def edit_items(request):
data = await request.form_data()
items = await Item.objects.all()
formset = ItemFormSet(data=data, initial=items)
if formset.is_valid():
# Process items marked for deletion
for form_data in formset.deleted_forms:
item_id = form_data.get("id")
if item_id:
item = await Item.objects.get(id=item_id)
await item.delete()
# Process remaining (non-deleted) items
for form_data in formset.cleaned_data:
if form_data and form_data not in formset.deleted_forms:
await Item.objects.create(**form_data)
return redirect("/items/")
Ordering Workflow¶
When can_order=True, each form gets an ORDER integer field. The formset sorts forms by this value.
Setup¶
Template¶
{% for form in formset.forms %}
<fieldset>
<legend>Item {{ loop.index }}</legend>
{% for field in form.fields %} {% if field.name == "ORDER" %}
<label>Position</label>
<input type="number" name="{{ field.html_name }}" value="{{ field.value }}" />
{% else %}
<label>{{ field.label }}</label>
<input
type="{{ field.widget }}"
name="{{ field.html_name }}"
value="{{ field.value }}"
/>
{% endif %} {% endfor %}
</fieldset>
{% endfor %}
Processing ordered data¶
@app.post("/items/reorder")
async def reorder_items(request):
data = await request.form_data()
formset = OrderableFormSet(data=data)
if formset.is_valid():
# ordered_forms returns cleaned_data sorted by ORDER value
for position, form_data in enumerate(formset.ordered_forms):
item_id = form_data["id"]
await Item.objects.filter(id=item_id).update(position=position)
return redirect("/items/")
Practical Patterns¶
Inline editing (edit existing + add new)¶
@app.get("/projects/{id:int}/tasks")
async def edit_tasks(request, id: int):
project = await Project.objects.get(id=id)
tasks = await Task.objects.filter(project_id=project.id).all()
TaskFormSet = FormSet(TaskForm, extra=3, can_delete=True)
formset = TaskFormSet(initial=tasks)
return render(request, "tasks/edit.html", {
"project": project,
"formset": formset,
})
@app.post("/projects/{id:int}/tasks")
async def save_tasks(request, id: int):
data = await request.form_data()
tasks = await Task.objects.filter(project_id=id).all()
TaskFormSet = FormSet(TaskForm, extra=3, can_delete=True)
formset = TaskFormSet(data=data, initial=tasks)
if formset.is_valid():
# Delete marked items
for form_data in formset.deleted_forms:
if form_data.get("id"):
await Task.objects.filter(id=form_data["id"]).delete()
# Save remaining items
for form_data in formset.cleaned_data:
if form_data and form_data not in formset.deleted_forms:
if form_data.get("id"):
# Update existing
await Task.objects.filter(id=form_data["id"]).update(
name=form_data["name"],
status=form_data["status"],
)
else:
# Create new
await Task.objects.create(
project_id=id,
name=form_data["name"],
status=form_data["status"],
)
return redirect(f"/projects/{id}/tasks")
return render(request, "tasks/edit.html", {"formset": formset}, status=400)
Bulk create¶
@app.get("/users/bulk-invite")
async def bulk_invite_form(request):
InviteFormSet = FormSet(InviteForm, extra=5, min_num=1, validate_min=True)
formset = InviteFormSet()
return render(request, "users/bulk_invite.html", {"formset": formset})
@app.post("/users/bulk-invite")
async def bulk_invite(request):
data = await request.form_data()
InviteFormSet = FormSet(InviteForm, extra=5, min_num=1, validate_min=True)
formset = InviteFormSet(data=data)
if formset.is_valid():
for form_data in formset.cleaned_data:
if form_data:
await send_invitation(
email=form_data["email"],
role=form_data["role"],
)
return redirect("/users/")
return render(request, "users/bulk_invite.html", {"formset": formset}, status=400)
Tabular display with validation errors¶
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input
type="hidden"
name="form-TOTAL_FORMS"
value="{{ formset.total_form_count }}"
/>
<input
type="hidden"
name="form-INITIAL_FORMS"
value="{{ formset.initial_form_count }}"
/>
<table>
<thead>
<tr>
<th>Name</th>
<th>Quantity</th>
<th>Price</th>
{% if formset.can_delete %}
<th>Delete</th>
{% endif %}
</tr>
</thead>
<tbody>
{% for form in formset.forms %}
<tr class="{{ 'error' if formset.errors[loop.index0] else '' }}">
{% for field in form.fields %} {% if field.name == "DELETE" %}
<td>
<input type="checkbox" name="{{ field.html_name }}" value="true" />
</td>
{% else %}
<td>
<input
type="{{ field.widget }}"
name="{{ field.html_name }}"
value="{{ field.value }}"
class="{{ 'field-error' if field.errors else '' }}"
/>
{% if field.errors %}
<span class="error">{{ field.errors[0] }}</span>
{% endif %}
</td>
{% endif %} {% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% if formset.non_form_errors %}
<div class="formset-errors">
{% for error in formset.non_form_errors %}
<p class="error">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<button type="submit">Save All</button>
</form>