1) 問題提起: “え、なんでこんなに無関係な結果が混ざってくるの?”
最近、運営しているブログの投稿数が増えてきて、体系的な管理が必要になってきました。 私のブログは多言語で公開しているため、同じスラッグでも別インスタンスが使われており、記事が増えるほど目的のポストを一発で見つけ出すのが少し大変になってきました。コンテンツが充実している証拠でもありますが。
このように目的のインスタンスを一括で探しにくい原因は、Django Admin の標準検索(search_fields)のロジックが曖昧だからだと考えました。

search_fields を多く設定すると(機能が増えるほど普通に増えていきます)、検索ボックスに単語一つ入れた瞬間に:
- タイトルにヒットし
- 説明にヒットし
- ユーザー名にヒットし
- 余計なメモフィールドにまでヒットし
結果が“たくさん出る”レベルを超えて、“欲しいものが埋もれてしまう”状態になります。運用中に急に特定のドメインだけを探したいとき、結果が数十件に広がると… 正直イライラします。
そこで「特定のフィールドだけをピンポイントで検索できたら」という発想が始まりました。
2) 解決アイデア: field:value 文法 + shlex でクオート処理
アイデアはシンプルです。
name:diffのようにフィールドを指定して検索するtitle:"system design"のように空白を含む検索語もサポートする
ここで shlex.split() がとても役立ちます。Admin の検索ボックスは普通「空白で分割」してしまうため、"system design" のような入力が崩れてしまいます。shlex を使えばシェルと同様にクオートを尊重してトークン化できます。
例:
title:"system design"→["title:system design"]のようにきれいに処理name:diff python→["name:diff", "python"]
つまり、フィールド指定検索と通常検索語を混在させても綺麗にパースできるわけです。
3) コード説明
全コードは以下のリポジトリに置いてあります。コピーしてそのまま使えるようにしています。
ブログでは要点だけ抜粋して説明します。
_build_lookup_for: search_fields のプレフィックスをそのまま尊重
Django Admin の search_fields にはプレフィックス規則があります。
=field→ 完全一致(大文字小文字無視)^field→ プレフィックス一致- デフォルトは
icontains
この規則を新しい文法を導入してもそのまま維持したいので、プレフィックスを見て lookup を決める小さなヘルパーを用意しました。
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'
例えば admin.py で search_fields = ['=name', 'title'] と書くと
nameはiexacttitleはicontains
そして field:value でも同じ lookup 方針が適用されます。
get_search_results: フィールド指定は AND、通常検索語は既存通り
核心ロジックは get_search_results() で行います。
- 入力を
shlexでトークン化 - トークンを二つに分類
*
field:value形式でかつsearch_fieldsに含まれるフィールド → field_specific_terms * それ以外 → normal_terms - フィールド指定検索は AND で累積
- 通常検索語は Django Admin の既存ルールに従い(概念的に) * 各 term は複数フィールドに対して OR * term 同士は AND
重要な部分だけ抜き出すと次のようになります。
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)
ここで「存在しないフィールド名」や「空の値」は単なる通常検索語として扱われます。
(1) field:value は AND で累積
for field, value in field_specific_terms:
lookup = field_lookup_map[field]
qs = qs.filter(Q(**{f"{field}{lookup}": value}))
name:diff status:active のような入力は自然に name == diff AND status == active と積み重なります。
(2) 通常検索語は term ごとに (フィールド OR) → term 同士は AND
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)
したがって以下の入力が可能です:
name:diff pythonname=diffAND(name OR title OR description のいずれかに python が含まれる)
このやり方が良いのは、既存の Admin 検索感覚を大きく崩さない点です。field:value で意図を明確に絞り込み、残りは従来通り広く検索させることができます。
4) 使用方法: Mixin を Admin に組み込むだけで完了
使い方はシンプルです。
@admin.register(MyModel)
class MyAdmin(FieldScopedSearchMixin, admin.ModelAdmin):
search_fields = ['=name', 'title', 'description']
これで Admin の検索ボックスに次のように入力できます。
name:difftitle:"system design"name:diff python
ヘルプメッセージをカスタマイズしたい場合は get_changelist_instance() をオーバーライドすれば自由に変更できます。
5) 振り返りと限界: “便利だけど、まだ欲がある”
この Mixin を作って一番嬉しかったのは、運用中に自分が欲しい検索範囲を自在に調整できるようになったことです。実感としての快適さは思った以上に大きく、満足感が得られました。
しかし限界もあります。
- Full‑text search(@ プレフィックス) は未対応です。PostgreSQL の full‑text を組み込めばもっと賢くなりますが、設定や重み付け、tsvector の管理が別問題になるので、今回は「運用ストレスの軽減」に注力しました。
icontainsが中心なのでデータが大きくなるとパフォーマンス問題が出やすいです。特に複数フィールドに OR を掛け、複数 term を使うとクエリが重くなります。(実運用では検索語は短くなることが多いですが、最悪ケースは常に存在します。)- 今後挑戦したいことは次の通りです。
created_at:2026-01-01..2026-01-31のような日付範囲検索status:active,-status:deletedのような否定条件field>10のような簡易比較演算子
現状の形は「小さく、確実に使いやすい」地点にピッタリで、非常に気に入っています。運用中に自分がイライラしたポイントを自作ツールで解決できるので、手が頻繁に伸びます。
少しの工夫とアイデアで業務効率が永続的に向上するのは本当に嬉しいことです。 これこそがコーディングの魅力なのかもしれません。
関連記事