When a CRM form contains only a few fields, putting everything on one page is perfectly fine. But some records can have a lot more information to collect.
Take a Lead, for example. You may need basic contact details, company information, address details, requirements, and other fields. Putting all of these fields into one long form can quickly become difficult to use.
This is where a multi-step form can make a big difference.
Instead of showing every field at once, the form can be divided into smaller sections. Users can complete one section, move to the next, and go back whenever they need to make a change.
In Horilla CRM, this functionality is handled by HorillaMultiStepForm and HorillaMultiStepFormView.
The interesting part is not just the step-by-step UI. The real challenge is managing the data between steps, validating only the fields the user is currently working on, and making sure uploaded files are not lost while navigating through the form.
This article looks at how Horilla handles these problems and how you can use the same approach when building your own multi-step form.
The Two Main Classes
Horilla keeps the multi-step form implementation split into two parts.
HorillaMultiStepForm, located in horilla/contrib/generics/forms/multi_step.py, is responsible for the form itself. It knows which fields belong to each step and makes sure validation is applied correctly.
HorillaMultiStepFormView, located in horilla/contrib/generics/views/multi_form.py, handles the flow between steps. It stores the entered data, processes Next and Previous actions, and finally saves the completed record.
The form is built on the same foundation used by HorillaModelForm:
class HorillaMultiStepForm(HorillaFormMixin, forms.ModelForm):
step_fields = {}
This means the normal Horilla form features are still available. Things such as audit-field exclusion, Select2 widgets for foreign keys and many-to-many fields, permission-aware field handling, phone-field detection, and readonly enforcement continue to work.
The multi-step form mainly adds one important concept: steps.
Defining the Steps
Steps are defined using a step_fields dictionary. Each step number points to the fields that should be displayed in that step.
For example, a Lead form can be divided like this:
class LeadFormClass(OwnerQuerysetMixin, HorillaMultiStepForm):
class Meta:
model = Lead
fields = "__all__"
exclude = ["lead_score"]
step_fields = {
1: [
"lead_owner", "title", "first_name", "last_name",
"email", "contact_number", "fax",
"lead_source", "lead_status"
],
2: [
"lead_company", "no_of_employees",
"industry", "annual_revenue"
],
3: [
"country", "state", "city", "zip_code"
],
4: [
"requirements"
],
}
This makes the structure easy to understand.
The first step contains the basic Lead information, the second focuses on the company, the third handles the location, and the final step collects requirements.
There is also a useful fallback built into the form. If a model field is not included in any step, it is automatically added to the last step.
This is particularly useful when using:
fields = “__all__”
You don’t have to manually add every field to step_fields.
Many-to-many fields that are left unassigned are excluded from the form instead. This helps prevent fields that were never intended to be part of the form from being exposed accidentally.
Validating Only the Current Step
One of the biggest problems with multi-step forms is validation.
Normally, Django’s ModelForm validates all of its fields when is_valid() is called. That works well for a normal form, but it creates a problem for a wizard.
Imagine the user is on step 1. The required fields from steps 3 and 4 have not been entered yet. If Django validates the entire form, those untouched fields can immediately produce validation errors.
Horilla handles this inside the form’s clean() method:
def clean(self):
cleaned_data = super().clean()
self._enforce_readonly_in_cleaned_data(cleaned_data)
current_fields = self.step_fields.get(self.current_step, [])
# strip validation errors for fields
# that aren't part of the current step
return cleaned_data
Django’s normal validation still runs, but errors belonging to fields outside the current step are removed.
This allows users to complete the current section without being interrupted by validation errors from later sections.
File fields receive some additional handling. If a file was already uploaded during an earlier step, a required-file error should not appear simply because that field isn’t part of the current step.
Horilla checks whether a file already exists before deciding whether the required error should remain.
Keeping Other Fields Hidden
The form still contains all of its model fields, even though only some of them are displayed at a particular step.
Horilla hides fields that don’t belong to the current step by replacing their widgets with HiddenInput.
There is an important detail here when dealing with composite widgets.
For example, the phone widget is based on Django’s MultiWidget. Instead of hiding the entire field at once, Horilla hides each sub-widget separately.
This is necessary because MultiValueField.clean() expects the individual values in a list. Hiding the entire composite widget incorrectly can break that validation process.
There is also some defensive handling for forms that rebuild fields after the base form initialization.
For example, a subclass may replace the state field after super().__init__() has already run. If the field was supposed to remain hidden for the current step, simply hiding it once during initialization would not be enough.
Horilla therefore checks and re-applies the hiding when fields are accessed through methods such as __iter__() and __getitem__().
This gives subclasses more freedom to customize fields without accidentally exposing fields belonging to another step.
Where the Form Data Is Stored
The form knows which fields belong to each step and how to validate them. But it doesn’t have to manage the complete state of the wizard.
That responsibility belongs to HorillaMultiStepFormView.
class HorillaMultiStepFormView(FormViewCommonMixin, FormView):
template_name = "form_view.html"
total_steps = 4
step_titles = {}
The view uses the Django session to keep the data while the user moves through the steps.
A session key is created for the form:
self.storage_key = f”{self.__class__.__name__}_form_data”
When editing an existing record, the object’s primary key is also included:
self.storage_key = f”{self.storage_key}_{pk}”
This prevents create and edit workflows from interfering with each other.
The session stores two important pieces of information:
- The values entered so far.
- Uploaded files, encoded so they can survive between requests.
This means the user can move between steps without losing the information they have already entered.
What Happens When the User Clicks Next?
Each step submits back to the same URL.
The form includes a hidden step value so the view knows which step was submitted:
<form
method="post"
hx-post="{{ form_url }}"
hx-target="#{{ view_id }}-multi-container"
hx-swap="outerHTML"
>
{% csrf_token %}
<input type="hidden" name="step" value="{{ current_step }}">
</form>
HTMX handles the request and replaces the relevant part of the form rather than requiring a full page reload.
The important data processing happens in get_form_kwargs().
The view takes the values submitted from the current step and merges them with the values already stored in the session.
This is important because the browser only submits the fields visible in the current step. The values from previous steps need to come from the stored session data.
The merging logic also handles different field types, including:
- Many-to-many fields using getlist()
- Unchecked checkboxes
- Dates
- Decimal values
- Uploaded files
- File clearing
- Existing uploaded files
For files, additional markers such as _filename, _new_file, and _cleared are used to keep track of what happened during navigation.
Moving to the Next Step
After processing the current step, form_valid() decides what should happen next.
If the current step is not the final step, the view increments the step number and builds the next form.
The next form is unbound, so users don’t suddenly see validation errors for fields they haven’t worked with yet.
HTMX then replaces the current form section with the next step.
From the user’s point of view, it feels like one continuous form even though each step is being submitted separately.
When the final step is submitted, the process is different.
The view combines the data stored in the session with the final POST data and creates a fully bound form containing everything collected throughout the wizard.
At this point, the complete form is validated.
If validation succeeds, the record is saved. File fields are explicitly assigned because commit=False does not save them automatically, many-to-many relationships are saved, and the temporary session data is cleaned up.
What Happens When the User Clicks Previous?
Going backward needs to behave differently from moving forward.
The Previous button is handled directly inside post():
def post(self, request, *args, **kwargs):
if "previous" in request.POST:
self.current_step -= 1
# rebuild an unbound form from session data
return super().post(request, *args, **kwargs)
The view rebuilds the previous step using the data stored in the session.
The form remains unbound, so navigating backward does not trigger validation errors.
This is important for the user experience. Someone should be able to move back and review or change information without being forced to complete validation for that step immediately.
Creating the View
Once the form is ready, the view subclass connects everything together.
For example:
@method_decorator(htmx_required, name="dispatch")
class LeadFormView(LoginRequiredMixin, HorillaMultiStepFormView):
form_class = LeadFormClass
model = Lead
detail_url_name = "leads:leads_detail"
single_step_url_name = {
"create": "leads:leads_create_single",
"edit": "leads:leads_edit_single",
}
step_titles = {
"1": _("Basic Information"),
"2": _("Company Details"),
"3": _("Location"),
"4": _("Requirements"),
}
The URLs can then point to the same view for both creating and editing:
path(
“leads-create/”,
views.LeadFormView.as_view(),
name=”leads_create”
)
path(
“leads-edit//”,
views.LeadFormView.as_view(),
name=”leads_edit”
)
The presence of a primary key determines whether the form is working in create or edit mode.
Because the session key is also scoped using the primary key in edit mode, separate form sessions don’t accidentally overwrite each other.
Switching Back to a Single-Step Form
Not every user needs a wizard every time.
Sometimes a user only wants to edit one field on an existing record. Making them move through four steps just to change a phone number can be unnecessary.
This is where single_step_url_name becomes useful.
It allows the multi-step form to provide a way to switch to the regular single-step form when needed.
That gives users both options: a guided workflow for entering a new record and a simpler form for quick changes.
Why Keep the Form and View Separate?
It might seem easier to put all of the step logic directly inside the view. However, separating the responsibilities makes the implementation easier to maintain and reuse.
The form is responsible for questions such as:
- Which fields belong to each step?
- Which fields should be visible?
- How should the current step be validated?
The view is responsible for:
- Keeping data between requests
- Handling Next and Previous navigation
- Combining the collected data
- Saving the final record
- Cleaning up the session
This separation also means the same form configuration can be reused in different workflows.
For example, the step_fields definition can exist independently from the view that renders it. A single-step fallback can therefore use the same underlying form without having to duplicate the validation logic.
A multi-step form is more than just dividing a large form into several screens. The real challenge is making sure the data survives between requests, validation happens at the right time, uploaded files are preserved, and users can move backward without unnecessary errors.
HorillaMultiStepForm and HorillaMultiStepFormView handle these responsibilities by keeping the form logic and workflow state separate.
For a new Horilla feature that needs a guided creation process — whether it’s onboarding an employee, creating a Lead, or configuring a complex record — this provides a reusable starting point.
Define the fields using step_fields, add the step titles to the view, connect the URLs, and Horilla takes care of the session handling, per-step validation, file persistence, and HTMX-based navigation.
The result is a form that is easier for users to complete and easier for developers to extend.