Skip to content

Django Integration

django-pgsync is the official Django integration for PGSync. Declare search indexes on your Django models, and PGSync keeps Elasticsearch/OpenSearch in sync with your database. You never write the schema JSON by hand.

pip install django-pgsync            # includes pgsync
pip install "django-pgsync[celery]"  # with Celery beat support

Requires Python 3.10+, Django 4.2+ and PostgreSQL.


Why not signals?

Indexers built on Django signals hook into the ORM, so anything that bypasses Model.save() silently never reaches your index. django-pgsync uses PGSync's change data capture, which watches the database instead:

Write path Signal indexers django-pgsync
instance.save() / delete()
queryset.update() / delete()
bulk_create() / bulk_update()
Cascade deletes
Raw SQL / data migrations
Writes from other services

Quick start

1. Register the app

settings.py
INSTALLED_APPS = [
    # ...
    "django_pgsync",
]

PGSYNC = {
    "MODE": "polling",  # "polling" (default) | "event" | "wal"
    "ELASTICSEARCH_URL": "http://localhost:9200",
}

Database credentials are read from DATABASES["default"] automatically. Every other PGSYNC key is passed through to PGSync as an environment setting.

2. Declare an index

<app>/search_indexes.py
from django_pgsync import PGSyncIndex, Nested
from .models import Author, Book, Publisher, Rating

class BookIndex(PGSyncIndex):
    model = Book
    index = "books"
    fields = ["isbn", "title", "description"]
    children = [
        Nested(Rating, fields=["value"], label="ratings"),      # one_to_many
        Nested(Publisher, fields=["name"], label="publisher"),  # one_to_one
        Nested(Author, fields=["name"], label="authors"),       # M2M through
    ]

Relationships are inferred from model metadata: foreign keys, one-to-one fields, and many-to-many through tables. Each committed change lands in the index as a denormalized nested document:

{
  "isbn": "9780441172719",
  "title": "Dune",
  "description": "Politics, religion and giant sandworms.",
  "authors": [{"name": "Frank Herbert"}],
  "ratings": [{"value": 5}, {"value": 5}, {"value": 4}],
  "publisher": {"name": "Chilton Books"}
}

3. Bootstrap and run

python manage.py pgsync_schema      # inspect generated schema JSON
python manage.py pgsync_bootstrap   # one-time setup for the mode
python manage.py pgsync_pull        # one-shot sync, then exit
python manage.py pgsync_status      # verify rows == documents
python manage.py pgsync_daemon      # continuous sync (systemd etc.)

Run modes

Mode How it works Postgres requirements
polling (default) Periodic forward pass None beyond read access; works on read-only and managed clusters
event Triggers + pg_notify + replication slot wal_level=logical, slot rights, triggers
wal Streams the logical replication slot wal_level=logical, slot rights

Why polling is the default

Polling does not need superuser access to change database settings, so it suits hosted Postgres (RDS, Cloud SQL, Supabase) where you may not control wal_level. When you control the database, wal gives real time sync with the lowest overhead.

Root deletes in polling mode

Deleting a root row leaves a stale document in the index, because there is no delete record to observe. Deletes on child rows are fine, because PGSync rebuilds the parent document. If you delete root rows permanently, use wal/event mode, or mark rows as deleted with a flag.


Celery beat

Schedule periodic sync without a dedicated daemon process:

settings.py
CELERY_BEAT_SCHEDULE = {
    "pgsync-pull": {
        "task": "django_pgsync.tasks.pgsync_pull",
        "schedule": 15.0,
    },
}

A cache lock prevents overlapping runs. Ticks that fire while a pull is still in flight simply skip, so short intervals are safe. With django-celery-beat the schedule is editable in the Django admin.


Learn more