A normal list view works well when you just want to see your records in a table. But as your CRM grows, you often want a better way to organize that data.
For example, you might want to see leads grouped by status, opportunities grouped by stage, or accounts grouped by type. Instead of scrolling through one long table and mentally sorting everything, a grouped view lets you see those records in clear sections.
This is where Horilla’s HorillaGroupByView comes in.
HorillaGroupByView builds on top of HorillaListView, so you don’t have to create an entirely new list-view system. You still get the columns, filters, permissions, actions, and other features you’re already familiar with. The main difference is that the records are displayed in collapsible groups.
The base implementation is available in horilla/contrib/generics/views/groupby.py. In this guide, we’ll start with a simple grouped lead view and then look at some of the features you can configure.
1. Creating a Basic Grouped List View
Creating a grouped view is pretty straightforward. You subclass HorillaGroupByView and provide the model, URLs, columns, filters, and the field you want to group by.
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from horilla.contrib.generics.views import HorillaGroupByView
from .models import Lead
from .filters import LeadFilter
class LeadGroupByView(LoginRequiredMixin, HorillaGroupByView):
model = Lead
view_id = "leads-group-by"
search_url = reverse_lazy("leads:leads_list")
main_url = reverse_lazy("leads:leads_view")
filterset_class = LeadFilter
group_by_field = "lead_status"
columns = [
"first_name",
"last_name",
"title",
"email",
"lead_status",
"lead_source",
]
That’s enough to get a basic grouped view working.
In this example, the leads are grouped using the lead_status field. Each status gets its own collapsible section, along with the number of records in that group and the rows belonging to it.
The nice part is that you don’t need to configure columns, filtering, or row actions from scratch. Those features already come from HorillaListView.
2. How HorillaGroupByView Works with HorillaListView
One of the important things to understand is that HorillaGroupByView isn’t a completely separate list-view implementation.
The relationship looks like this:
HorillaListView (list.py)
└── HorillaGroupByView (groupby.py)
└── LeadGroupByView
└── OpportunityGroupByView
└── AccountGroupByView
Since HorillaGroupByView extends HorillaListView, it reuses most of the existing list-view functionality.
There are a few defaults that change to make the layout work better for grouped data:
| Attribute | List View Default | Group-by View Default |
| template_name | “list_view.html” | “group_by_view.html” |
| bulk_select_option | True | False |
| paginate_by | 100 | 20 |
| supports_quick_filters | True | False |
One thing to note is that supports_quick_filters is disabled by default, but the enable_quick_filters setting is still available.
So, if your grouped view needs quick filters, you can simply enable them:
enable_quick_filters = True
Other familiar attributes such as columns, actions, col_attrs, header_attrs, filterset_class, search_url, main_url, view_id, and max_visible_actions continue to work the same way.
3. Choosing the Grouping Field
The main configuration you need to understand is group_by_field.
For a simple grouped view, you can set:
group_by_field = “stage”
Horilla also provides a few other options for controlling how grouping works:
| Variable | Default | Purpose |
| group_by_field | None | Default field used for grouping |
| group_by_param | “group_by” | Query parameter used for grouping fields |
| exclude_kanban_fields | Not set | Fields excluded from the group-by picker |
| include_kanban_fields | Not set | Limits the picker to specific fields |
Grouping is intended for fields that produce meaningful categories, such as choice fields and foreign keys. Free-text or numeric fields aren’t suitable for this and aren’t supported for grouping.
If your model has many fields, you can also control what appears in the group-by picker using include_kanban_fields or exclude_kanban_fields.
When Horilla decides which field or fields to use for a request, it follows this order:
- The group_by query parameter
- The user’s saved grouping preference
- The group_by_field defined on the view
- The first allowed field, if nothing else is available
So group_by_field is mainly a sensible starting point. Users can change the grouping from the UI, and their selection can be remembered.
4. Using Multiple Grouping Levels
Grouping doesn’t have to stop at one level.
You can group opportunities by stage and then group the records inside each stage by owner, for example.
The resulting structure could look something like:
Open
John
Opportunity 1
Opportunity 2
Sarah
Opportunity 3
Closed
John
Opportunity 4
There isn’t a separate setting that you need to enable for nested grouping. The selected grouping fields are treated as an ordered list, and Horilla builds the groups one level at a time.
This makes it possible to create useful multi-level views without writing custom grouping logic for every module.
5. Expand, Collapse, and Lazy Loading
Grouped views can potentially contain a lot of data, especially when several grouping levels are used.
Horilla handles this with lazy loading.
The first level of groups is built when the page loads and is initially expanded. Deeper levels are loaded only when the user expands them.
When a nested group needs to be displayed, Horilla sends an HTMX request to the appropriate expand endpoint and loads that part of the tree.
Collapsing a group also takes care of its nested content, so users can quickly open and close large sections of the view.
This approach helps keep the initial page load lightweight, even when there are many groups or several levels of grouping.
6. Group Counts and Pagination
Each group displays the number of records it contains.
For example:
Qualified (24)
Horilla gets these counts using an aggregate query across the queryset rather than running a separate .count() query for every group.
Pagination also works a little differently here.
By default:
paginate_by = 20
This means 20 rows are loaded per group before the user requests more.
Each leaf group has its own pagination and its own “load more” HTMX endpoint. Loading additional records in one group doesn’t require the other groups to reload.
7. Reusing Actions from an Existing List View
Grouped views are often created alongside an existing list view. Because of that, you may already have a set of row actions that you want to reuse.
For example:
from .views import LeadListView
class LeadGroupByView(LoginRequiredMixin, HorillaGroupByView):
model = Lead
view_id = "leads-group-by"
filterset_class = LeadFilter
search_url = reverse_lazy("leads:leads_list")
main_url = reverse_lazy("leads:leads_view")
enable_quick_filters = True
group_by_field = "lead_status"
exclude_kanban_fields = "lead_owner"
max_visible_actions = 5
columns = [
"first_name",
"last_name",
"title",
"email",
"lead_status",
"lead_source",
"industry",
"annual_revenue",
]
actions = LeadListView.actions
Here, the actions from LeadListView are reused instead of being defined again.
The columns are still defined separately. This is useful because a grouped view doesn’t always need exactly the same columns as the regular list view. Since the group itself already provides some context, you may want to show fewer fields or arrange them differently.
You can also customize col_attrs and header_attrs just like you would in HorillaListView.
For example, an opportunity grouped view can override col_attrs when it needs special formatting, such as currency-aware values:
class OpportunityGroupByView(LoginRequiredMixin, HorillaGroupByView):
model = Opportunity
view_id = "opportunity-group-by"
filterset_class = OpportunityFilter
search_url = reverse_lazy("opportunities:opportunities_list")
main_url = reverse_lazy("opportunities:opportunities_view")
enable_quick_filters = True
group_by_field = "stage"
columns = [
"name",
"amount",
"close_date",
"stage",
"opportunity_type",
"primary_campaign_source",
]
actions = OpportunityListView.actions
@cached_property
def col_attrs(self):
...
8. Bulk Actions and Empty Groups
Bulk selection is disabled by default in grouped views:
bulk_select_option = False
This makes sense because bulk-selection checkboxes can become awkward when records are spread across multiple collapsed and paginated groups.
If your use case requires bulk actions, you can still enable them in your subclass.
Empty groups are also handled gracefully. The group header remains visible, but instead of displaying an empty table, Horilla shows a message indicating that there are no items in that group.
9. Adding the Grouped View to Your URLs
Once the view is ready, add it to your module’s URL configuration:
#urls.py
path(
“leads-group-by/”,
views.LeadGroupByView.as_view(),
name=”leads_group_by”,
)
Then, in the parent shell view, point group_by_url to the new view:
group_by_url = reverse_lazy(“leads:leads_group_by”)
You don’t need to manually configure the endpoints used for loading more rows or expanding nested groups. Those endpoints are part of the generic HorillaGroupByView infrastructure and are automatically registered when the grouped view is defined.
10. Putting Everything Together
Here’s a complete example with permissions, filtering, grouping, actions, and the other common settings:
from django.contrib.auth.mixins import LoginRequiredMixin
from django.urls import reverse_lazy
from django.utils.decorators import method_decorator
from horilla.contrib.generics.views import HorillaGroupByView
from horilla.decorators import permission_required_or_denied
from .models import Lead
from .filters import LeadFilter
from .views import LeadListView
@method_decorator(
permission_required_or_denied(
["leads.view_lead", "leads.view_own_lead"]
),
name="dispatch",
)
class LeadGroupByView(LoginRequiredMixin, HorillaGroupByView):
model = Lead
view_id = "leads-group-by"
filterset_class = LeadFilter
search_url = reverse_lazy("leads:leads_list")
main_url = reverse_lazy("leads:leads_view")
enable_quick_filters = True
group_by_field = "lead_status"
exclude_kanban_fields = "lead_owner"
max_visible_actions = 5
columns = [
"first_name",
"last_name",
"title",
"email",
"lead_status",
"lead_source",
"industry",
"annual_revenue",
]
actions = LeadListView.actions
With this setup, you have a grouped lead view that uses the existing list-view infrastructure while adding grouping, nested groups, lazy loading, group counts, and per-group pagination.
HorillaGroupByView is designed to keep grouped views simple.
Instead of creating another completely separate list-view system, Horilla builds grouping on top of HorillaListView. That means the features you already know — columns, filters, permissions, actions, and customization — continue to work without needing to be implemented again.
The main configuration you’ll usually need is:
group_by_field = “lead_status”
From there, you can control which fields users can group by, enable multiple grouping levels, customize columns and actions, and adjust the grouped-view behavior when necessary.
So, if you’re already comfortable creating a HorillaListView, creating a HorillaGroupByView is not a big jump. In many cases, adding the grouping configuration is all it takes to turn a regular list into a much more useful grouped view.