Content Index
- What filtering data means in Django
- When to use Q() for more flexible queries
- Creating a form to filter lists
- Text fields and category selection
- Keeping filter values active after searching
- ⚙️ Implementing filters in the view (views.py)
- Applying simple conditionals for optional filters
- Complete view example with Paginator
- Improving user experience
- ⚠️ Common errors and best practices
- Deep diving into filtering: custom search bar
- Conclusion and next steps
- ❓ FAQs
Most of the applications we build or interact with handle large volumes of data. Therefore, having an efficient search and filtering mechanism is not a luxury, but a basic usability necessity. In this chapter, we will look at different ways to implement search in Django: from the simplest filter() to using the Q() object for combined queries, and finally full-text search with PostgreSQL. Let's build the component step by step.
Remember that in the previous chapter we learned how to send emails in Django.
What filtering data means in Django
In Django, almost everything revolves around QuerySets: collections of database objects that can be chained, filtered, and sorted lazily (lazy evaluation). To filter a list, we use the .filter() method, which returns only the records that meet the specified conditions.
products = Product.objects.filter(category__contains='Laptops')That example returns all products whose category contains the text "Laptops". It is functional, but it has an important limitation: it is case-sensitive. To make it more flexible and user-friendly, we can use icontains, which ignores capitalization:
products = Product.objects.filter(category__icontains='laptops')When to use Q() for more flexible queries
When you need to search across multiple fields at once—such as name, description, or category—Q() comes into play, a Django object that allows building complex queries by combining conditions with logical operators (| for OR, & for AND).
from django.db.models import Q
products = Product.objects.filter(
Q(name__icontains='laptop') |
Q(description__icontains='laptop') |
Q(category__icontains='laptop')
)This is the most direct way to create a text search across multiple columns without relying on Postgres or additional libraries. It is the ideal starting point for most projects.
Creating a form to filter lists
When we want to filter data, the ideal approach is to use GET requests. This makes active filters reflected in the URL, which makes it easy to share searches, reload the page, or navigate with the browser's "back" button without losing state.
In my case, I usually leave the action attribute empty so that the form submits to itself, keeping the current URL:
<form method="get" class="row">
<div class="col-auto">
<input type="text" name="search" class="form-control" placeholder="Search">
</div>
<div class="col-auto">
<select name="category_id" class="form-select">
<option value="">Categories</option>
{% for c in categories %}
<option value="{{ c.id }}">{{ c.title }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button type="submit" class="btn btn-primary">Search</button>
</div>
<div class="col-auto">
<a href="{% url 'elements:index' %}" class="btn btn-outline-secondary">Clean</a>
</div>
</form>The clear button points directly to the list URL without parameters, which automatically resets all filters with a simple redirection. Visually, it is best for this button to have a more discreet style than the search one (such as btn-outline-secondary) so that the user understands at a glance which is the primary action and which is secondary.
The layout uses a column system (row + col-auto from Bootstrap or Tailwind) so that all controls align horizontally with just the width they need.
Text fields and category selection
In the previous example we filtered by two criteria: item name (free text input field) and category (dropdown select menu). If you need to add more filters, you just need to replicate the same pattern for each additional field of the model: add the HTML control, a new GET parameter in the view, and one more conditional in the query.
Keeping filter values active after searching
One of the most common mistakes when implementing searches is that form fields empty out after submitting the request. The solution is simple: pass the values back to the template and inject them into the HTML. For the text field:
<input type="text" name="search" class="form-control" value="{{ search }}">And to ensure the correct option remains selected in the select:
<option
{% if category_id == c.id %} selected {% endif %}
value="{{ c.id }}">
{{ c.title }}
</option>This noticeably improves the user experience, especially when results are paginated: when navigating between pages, the active filter remains visible and the user doesn't lose context of their search.
⚙️ Implementing filters in the view (views.py)
In Django, accessing values submitted by a GET form is very straightforward thanks to request.GET.get(). The trick is to correctly handle cases where the parameter arrives empty or doesn't exist at all, to avoid breaking subsequent queries:
search = request.GET.get('search') if request.GET.get('search') else ''
category_id = request.GET.get('category_id')
category_id = int(category_id) if category_id else ''Converting category_id to an integer is important: Django expects a numerical value when filtering by foreign keys (ForeignKey), and leaving it as a string would cause a type error in the query.
Applying simple conditionals for optional filters
The magic of optional filters lies in their simplicity: they are standard Python conditionals. We use GET requests because we are querying data, not modifying the model. Each filter is applied only if the user has filled it out, making the system completely flexible:
elements = Element.objects
if search:
elements = elements.filter(title__icontains=search)
if category_id:
elements = elements.filter(category_id=category_id)
elements = elements.filter(type=2).all()A key advantage of this pattern is that it is incremental: you can add or remove filters simply by adding or removing conditionals and their corresponding form fields, without touching the rest of the logic. Django delays the actual execution of the query until the QuerySet is iterated, so chaining multiple filter() calls does not generate additional database queries.
Complete view example with Paginator
Once filters are applied, we paginate the results and send everything to the template, including active search values so the form reflects them correctly:
from django.core.paginator import Paginator
paginator = Paginator(elements, 10)
page_number = request.GET.get('page')
return render(request, 'elements/index.html', {
'elements': paginator.get_page(page_number),
'search': search,
'category_id': category_id,
'categories': categories
})By passing search and category_id in the context, the template can automatically populate the form and also build pagination links while preserving active filters.
Improving user experience
I always add a Clean or Reset button that lets you clear all filters in one go. It requires no additional backend logic: it simply points to the list URL without parameters:
<div class="col-auto">
<a href="{% url 'elements:index' %}" class="btn btn-outline-secondary">Clean</a>
</div>To achieve a clean and hierarchical design, I apply a secondary style to the clear button (btn-outline-secondary) and a primary style to the search button (btn-primary). This way the user visually identifies the primary action at a glance, without needing to read the button text.
⚠️ Common errors and best practices
- Do not forget to convert IDs to integers
category_id = int(category_id)prevents type errors when filtering by foreign keys.GETparameters always arrive as strings, so conversion is mandatory before using them in numeric queries.
- Avoid duplicating
filter()queries- Use the same
elementsobject and chain filters onto it. Django delays actual query execution until the QuerySet is iterated, so chainingfilter()is efficient and does not generate extra database queries.
- Use the same
- When to use
icontainsinstead ofcontainsicontainsis case-insensitive, which improves search usability at no extra cost. Reservecontainsonly for cases where capitalization is semantically relevant to the business logic.
Deep diving into filtering: custom search bar
Beyond the basic form, sometimes we need a search component with a more polished look. Below is an example of a search bar with an integrated SVG icon, whose form points to a dedicated view via action:
<!-- search -->
<div class="search_form">
<form class="search" method="GET" action="{% url 'products:search' %}">
<label>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="32" height="32"
viewBox="0 0 32 32">
<path
d="M32 30.586l-10.845-10.845c1.771-2.092 2.845-4.791 2.845-7.741
0-6.617-5.383-12-12-12s-12 5.383-12 12c0 6.617 5.383 12
12 12 2.949 0 5.649-1.074 7.741-2.845l10.845 10.845
1.414-1.414zM12 22c-5.514 0-10-4.486-10-10s4.486-10
10-10c5.514 0 10 4.486 10 10s-4.486 10-10 10z"></path>
</svg>
<input name="q" placeholder="Search" />
</label>
</form>
</div>The CSS styling this component is minimal but effective. The key is using display: flex on the label to align the icon and input inline, and removing the native border of the input so the whole assembly looks like a single cohesive element:
.search > label {
background-color: #fff;
padding: 0.5rem;
display: flex;
align-items: center;
gap: 1rem;
border-radius: 0.5rem;
border:1px solid #000;
}
.search svg {
fill: #000;
width: 1.2rem;
height: 1.2rem;
margin-left: 0.5rem;
}
.search input {
width: 100%;
background: transparent;
border: none;
outline: none;
color: #000;
font-size: 13px;
}Suppose you have an online store and want to filter products by name or category. The most direct filter would be:
Products.objects.filter(category__contains='Laptops')This returns all products in the "Laptops" category with exact capitalization matching, which is very limiting in practice. The immediate improvement is to use icontains to ignore case. However, we are still searching a single column. To go further, we need the Q() object.
Q() allows us to combine conditions across multiple fields with the | (OR) or & (AND) operator. Here is the complete view example with multi-field search:
from .models import Product
from django.db.models import Q
from django.shortcuts import render
def search(request):
q = request.GET.get('q') if request.GET.get('q') != None else ''
products = Product.objects.filter(
Q(category__icontains=q) |
Q(name__icontains=q) |
Q(description__icontains=q)
)
context = {
"products": products
}
return render(request, "search.html", context)The flow is simple: we import the Product model and the Q object, define a search view that captures the term entered by the user via request.GET.get('q'), build the query searching across category, name, and description simultaneously, and return the results to the template. If the user types "laptop", they will get all products where any of those three fields contain that word, regardless of uppercase or lowercase.
Although this solution is a huge improvement over simple filtering, it still has a limitation: the user needs to know exact terms like category or product names beforehand. To solve that, Django offers integrated full-text search with PostgreSQL.
To take advantage of Postgres's full-text search engine, we need to have 'django.contrib.postgres' in INSTALLED_APPS and then import the corresponding modules:
from .models import Product
from django.contrib.postgres import search
from django.shortcuts import render
def product_search(request):
q = request.GET.get('q') if request.GET.get('q') != None else ''
products = Product.objects.filter(category__search=q)
context = {
"products": products
}
return render(request, "search.html", context)This improves the previous view based on Q(), but we are still searching a single column. To execute the search across multiple columns at once, we introduce SearchVector:
from .models import Product
from django.contrib.postgres import search
from django.contrib.postgres.search import SearchVector
from django.shortcuts import render
def product_search(request):
q = request.GET.get('q') if request.GET.get('q') != None else ''
products = Product.objects.annotate(
search=SearchVector('category', 'name', 'description')
).filter(search=q)
context = {
"products": products
}
return render(request, "search.html", context)SearchVector combines multiple columns into a single search index. However, we still have a limitation: we can only search by a single word. For terms composed of multiple words, we introduce SearchQuery:
from .models import Product
from django.contrib.postgres import search
from django.contrib.postgres.search import SearchVector, SearchQuery
from django.shortcuts import render
def product_search(request):
q = request.GET.get('q') if request.GET.get('q') != None else ''
products = Product.objects.annotate(
search=SearchVector("name", "description", "category")
).filter(search=SearchQuery(q))
context = {
"products": products
}
return render(request, "search.html", context)SearchQuery processes the search term intelligently. In its default mode (search_type='plain'), it treats each word as a separate key. If we configure it as SearchQuery("hp laptop", search_type='phrase'), it will search for the exact phrase. Additionally, Postgres allows ordering results by relevance using SearchRank, which is very useful when you have large volumes of data and want to show the most relevant results first.
This chapter is a practical introduction, not an exhaustive guide. The important thing is that you understand the progression: from basic filter(), through Q() for combined searches, to PostgreSQL's full-text engine for more sophisticated searches. Each level adds power, but also complexity; choose the one that fits your project's real needs.
Conclusion and next steps
Filtering a list in Django does not require additional libraries: using request.GET, the .filter() method, and a bit of conditional logic in the view is enough. This approach is sufficient for most projects, even in production, and has the great advantage of being completely transparent and easy to debug.
If in the future you need more powerful searches—with relevance ranking, phonetic search, or multi-language support—you can scale up to PostgreSQL's SearchVector and SearchQuery, or to specialized libraries like django-filter or django-haystack.
❓ FAQs
- How to apply multiple filters in Django?
- Chain multiple
.filter()calls on the same QuerySet, or combine conditions withQ()using the|(OR) or&(AND) operator.
- Chain multiple
- How to clear filters?
- Include a link pointing to the list URL without
GETparameters. When clicked, Django renders the view with no filters applied.
- Include a link pointing to the list URL without
- How to keep filters when paginating?
- Pass
searchandcategory_idin the template context and use them as values in the form fields (value="{{ search }}"and theselectedattribute in theselect).
- Pass
Next step, learn how to create your first unit and integration tests in Django.