## 1) The Problem: “Why are irrelevant results showing up?” {#sec-b345eb02ca5e} As the number of posts on my blog grew, systematic management became inevitable. Because my blog is multilingual, the same slug can be used by different language instances, making it increasingly difficult to pinpoint the exact post I need. It’s a good sign that the blog is thriving, but it also introduces a new pain point. I realized that the difficulty of finding a specific instance in one go stems from the **vague nature of Django Admin’s default search (`search_fields`)**. ![Librarian with a magical magnifying glass searching for a book](/media/editor_temp/6/12d4b1c2-0844-485d-b9ef-0e1f766b18ca.png) When you add many `search_fields` (which tends to happen as admin features expand), a single keyword entered into the search box ends up matching: * the title * the description * the username * even unrelated note fields The result set quickly shifts from “a lot of hits” to “my target is buried under noise.” When you need to locate a particular domain urgently and the list balloons into dozens of entries, it can be genuinely irritating. That’s why the idea of **being able to target a single field for search** was born. --- ## 2) The Idea: `field:value` Syntax + `shlex` for Quoted Terms {#sec-5381e00c4696} The concept is straightforward: * Search by explicitly specifying a field, e.g., `name:diff` * Support terms that contain spaces, e.g., `title:"system design"` `shlex.split()` becomes extremely handy here. The admin search box normally splits on whitespace, which would break an input like `"system design"`. By using `shlex`, we honor shell‑style quoting and get clean tokens. Examples: * `title:"system design"` → `["title:system design"]` * `name:diff python` → `["name:diff", "python"]` In other words, **field‑specific searches** and **regular terms** can be mixed together and parsed gracefully. --- ## 3) Code Walkthrough {#sec-8edf0b65ccd2} The full code lives in the repository below, ready for copy‑and‑paste. [GitHub repository link](https://github.com/mikihands/djangomixins/blob/main/admin_fieldscopedsearch.py) Below I’ll highlight the essential parts. ### `_build_lookup_for`: Respecting `search_fields` Prefixes {#sec-25f0fe79cd1c} `search_fields` supports prefix modifiers: * `=field` → exact match (case‑insensitive) * `^field` → starts‑with match * default → `icontains` I wanted the new syntax to **preserve these rules**. A tiny helper examines the prefix and returns the appropriate lookup. ```python def _build_lookup_for(self, raw_field): if raw_field.startswith('='): return raw_field.lstrip('=^@'), '__iexact' if raw_field.startswith('^'): return raw_field.lstrip('=^@'), '__istartswith' return raw_field.lstrip('=^@'), '__icontains' ``` So, if you set `search_fields = ['=name', 'title']` in `admin.py`: * `name` uses `iexact` * `title` uses `icontains` The **same lookup policy** applies to `field:value` queries. --- ### `get_search_results`: Field‑Specific Terms are ANDed, Normal Terms Keep Their Original Logic {#sec-2432c8a5949d} The core logic lives in `get_search_results()`: 1. Tokenise the input with `shlex` 2. Split tokens into two buckets * `field:value` where the field exists in `search_fields` → **field_specific_terms** * everything else → **normal_terms** 3. Field‑specific terms are combined with **AND** 4. Normal terms follow the classic Admin behaviour (OR across fields, AND across terms) A stripped‑down version looks like this: ```python terms = shlex.split(search_term or "") field_specs = [self._build_lookup_for(f) for f in getattr(self, 'search_fields', [])] field_lookup_map = {} for name, lookup in field_specs: field_lookup_map.setdefault(name, lookup) field_specific_terms = [] normal_terms = [] for t in terms: if ':' in t: field, value = t.split(':', 1) if field in field_lookup_map and value != '': field_specific_terms.append((field, value)) else: normal_terms.append(t) else: normal_terms.append(t) ``` Invalid field names or empty values fall back to normal search. #### (1) Apply `field:value` with AND ```python for field, value in field_specific_terms: lookup = field_lookup_map[field] qs = qs.filter(Q(**{f"{field}{lookup}": value})) ``` An input like `name:diff status:active` becomes `name = diff AND status = active`. #### (2) Apply Normal Terms as (field OR) → AND across terms ```python if normal_terms and field_lookup_map: for term in normal_terms: term_q = Q() for field, lookup in field_lookup_map.items(): term_q |= Q(**{f"{field}{lookup}": term}) qs = qs.filter(term_q) ``` So `name:diff python` translates to: * `name = diff` **AND** * `(name OR title OR description) contains "python"` The benefit is that we **don’t break the familiar Admin search feel**. Field‑specific queries narrow the result set precisely, while the remaining terms keep the broad, forgiving search behavior. --- ## 4) How to Use: Just Add the Mixin to Your Admin {#sec-0eb3a92e04b6} Implementation is a one‑liner: ```python @admin.register(MyModel) class MyAdmin(FieldScopedSearchMixin, admin.ModelAdmin): search_fields = ['=name', 'title', 'description'] ``` Now the admin search box accepts: * `name:diff` * `title:"system design"` * `name:diff python` If you want a custom help text, override `get_changelist_instance()`. --- ## 5) Reflection & Limitations: “It’s Handy, but I Want More” {#sec-4fc456da292d} Building this mixin gave me the power to **tune the search scope exactly the way I need it** during operation. The practical boost in productivity was surprisingly satisfying. However, there are clear limits: * **Full‑text search (`@` prefix)** isn’t covered. Hooking PostgreSQL’s full‑text capabilities would make the tool smarter, but that opens a whole discussion about weighting, `tsvector` maintenance, etc. For now I focused on reducing operational friction. * Since the default lookup is `icontains`, **performance can degrade** on large datasets, especially when many fields are OR‑combined with multiple terms. In production the queries are usually short, but worst‑case inputs still exist. * Future ideas I’d love to explore: * Date‑range syntax like `created_at:2026-01-01..2026-01-31` * Negation, e.g., `status:active,-status:deleted` * Simple comparison operators such as `field>10` For now, the mixin hits the sweet spot: a small, reliable improvement that solves a real annoyance I faced daily. Adding a modest amount of thought and code to streamline a workflow feels incredibly rewarding—perhaps that’s the true charm of programming. --- **Related post** - [Why You Should Hide the Admin Interface Right Now](/ko/whitedec/2025/11/10/admin-now-need-to-hide/)