Quickstart¶
This walks through indexing a single Django model end to end. It assumes you have an OpenSearch cluster you can reach — if you do not, the official Docker instructions will get you one.
Install¶
$ pip install django-opensearch-models
$ uv add django-opensearch-models
$ pip install "django-opensearch-models[celery]"
Only needed to use CelerySignalProcessor — see
OPENSEARCH_SIGNAL_PROCESSOR.
The whole thing, end to end¶
Add the app and point it at your cluster:
# settings.py
INSTALLED_APPS = [
...,
"django_opensearch_models",
]
OPENSEARCH = {
"default": {
"hosts": "localhost:9200",
"http_auth": ("username", "password"),
},
}
Declare a document for the model you want to index, in your app’s documents.py:
# documents.py
from django_opensearch_models import Document
from django_opensearch_models.registries import registry
from .models import Car
@registry.register_document
class CarDocument(Document):
class Index:
name = "cars"
settings = {"number_of_shards": 1, "number_of_replicas": 0}
class Django:
model = Car
fields = ["name", "color", "description"]
Build the index:
$ ./manage.py search_index --rebuild
From here on, saving a Car indexes it. Searching returns OpenSearch hits, or Django objects:
hits = CarDocument.search().filter("term", color="red")
for hit in hits:
print(hit.name, hit.description)
# ...or come back to the ORM
cars = CarDocument.search().filter("term", color="red")[:30].to_queryset()
That is the entire happy path. The rest of this page explains the parts of it that bite.
Where documents must live¶
The file has to be called documents.py and sit inside an installed app. On startup the app config
calls autodiscover(), which imports exactly that name from each app. A document declared anywhere
else is never imported, so it is never registered, never mapped and never updated — and nothing
raises an error to tell you.
The connection setting is OPENSEARCH¶
Note
Every setting this app reads is named OPENSEARCH* — see Settings. A setting name it
does not recognise is simply never read, leaving a cluster that is never contacted and an index that
stays empty.
OPENSEARCH is handed straight to opensearchpy.connections.configure(), so anything that function
accepts works — TLS options, timeouts, and additional named connections beside default. See the
opensearch-py documentation and
Settings.
The two inner classes¶
They divide cleanly, and it is worth keeping them straight:
class IndexThe OpenSearch side — index name, shards, replicas, analyzers.
class DjangoThe Django side — which model feeds the index, which of its fields to index, and how updates behave.
Documents covers every option on both, plus the hooks for computed values and for controlling which objects get indexed at all.
What keeps the index in sync¶
Once the index exists, ordinary ORM writes maintain it through Django’s post_save and
post_delete signals. By default that write to OpenSearch happens inline, in the same request.
Important
Queryset-level operations do not emit per-instance signals, so they never reach the index:
QuerySet.update(), QuerySet.delete(), bulk_create(), bulk_update() and raw SQL. After any of
those, re-index explicitly:
$ ./manage.py search_index --populate --models myapp.Car
To move indexing off the request path entirely, switch to the Celery processor — see
OPENSEARCH_SIGNAL_PROCESSOR.
Searching, and getting back to the ORM¶
Document.search() returns an opensearch-py search object, with the full query DSL behind it.
Iterating it yields OpenSearch hits — they carry only the fields you indexed, not model instances.
.to_queryset() converts a response into a real Django queryset when you need model behaviour:
cars = CarDocument.search().filter("term", color="blue")[:30].to_queryset()
Note
That costs one extra SQL query — the ids come out of the search response and are looked up with
pk__in. The queryset preserves the search ordering; pass keep_order=False if you would rather
order in the database.
Slice before calling .to_queryset(), as above. Without a slice you will pull back every match.