Monitor Django with Prometheus and Grafana

Expose Django metrics to Prometheus, handle Gunicorn multiprocess mode, add database and host exporters, build Grafana panels, and alert on what matters.

Problem statement

Logs tell you what happened to individual requests; error tracking tells you about exceptions. Neither answers questions like "is the site slower than yesterday?", "which endpoints drive the 5xx rate?", or "are we close to running out of database connections?". Those need metrics: numeric time series collected continuously and queried over time.

Prometheus and Grafana are a common open source stack for this. Adding metrics to Django has pitfalls that are not obvious from package READMEs:

  • Gunicorn runs several worker processes, and each has its own in-memory metrics, so scraping /metrics returns whichever worker answered
  • a public /metrics endpoint leaks internal details
  • metrics labeled with raw URLs create unbounded cardinality and overload Prometheus
  • application metrics alone miss database, host, and proxy saturation

This guide instruments Django with django-prometheus, handles multiple Gunicorn workers correctly, adds exporters for PostgreSQL, the host, and Nginx, and defines a small set of useful dashboards and alerts.

Metrics pipelineApplicationexposes /metricsPrometheusscrapes and storesGrafanadashboardsAlertmanagerroutes alertsMulti-process web servers need a shared metrics directory to report one total.

Quick answer

  1. Install django-prometheus, add it to INSTALLED_APPS, and wrap MIDDLEWARE with its before and after middlewares.
  2. Expose /metrics only on an internal network or protect it.
  3. For Gunicorn with multiple workers, enable prometheus_client multiprocess mode with PROMETHEUS_MULTIPROC_DIR and a child_exit hook.
  4. Run node_exporter, postgres_exporter, and an Nginx exporter alongside the app.
  5. Configure Prometheus scrape jobs and retention.
  6. Build Grafana panels for traffic, errors, latency (p50/p95/p99), saturation, and database health.
  7. Alert on symptoms users feel (error rate, latency, availability) plus key saturation signals.

Step-by-step solution

1. Install and configure django-prometheus

pip install django-prometheus
INSTALLED_APPS = [
    "django_prometheus",
    # ...
]

MIDDLEWARE = [
    "django_prometheus.middleware.PrometheusBeforeMiddleware",
    "django.middleware.security.SecurityMiddleware",
    # ... all other middleware ...
    "django_prometheus.middleware.PrometheusAfterMiddleware",
]

The before middleware must be first and the after middleware last, so the measured time covers the whole request.

Expose the metrics view:

# config/urls.py
from django.urls import include, path

urlpatterns = [
    path("", include("django_prometheus.urls")),
    # ...
]

This serves /metrics. The package can also instrument database and cache backends by swapping engine names (for example django_prometheus.db.backends.postgresql), which adds query and connection metrics per database alias.

2. Protect the metrics endpoint

/metrics reveals view names, request volumes, and error rates. Restrict it at the proxy:

location = /metrics {
    allow 10.0.0.0/16;   # Prometheus server network
    deny all;
    proxy_pass http://unix:/run/myapp/gunicorn.sock;
    proxy_set_header Host $host;
}

Alternatively, have Prometheus scrape Gunicorn directly on a private interface and never route /metrics through the public server block.

3. Handle multiple Gunicorn workers

Each worker process keeps its own counters. Without extra configuration, every scrape hits one random worker, and graphs jump around. prometheus_client supports a multiprocess mode in which workers write metrics to files in a shared directory and the metrics view aggregates them.

each worker has its own countersDefault✕each scrape hits one random worker✕counters jump up and down✕rates are meaninglessMultiprocess or per-worker ports✓PROMETHEUS_MULTIPROC_DIR shared✓or one exporter port per worker✓totals are correctThis is the most common reason Django request graphs look wrongdjango-deployment.com
Clear the multiprocess directory at every start.

Set the directory in the service environment:

[Service]
Environment=PROMETHEUS_MULTIPROC_DIR=/run/myapp/prometheus
RuntimeDirectory=myapp myapp/prometheus
ExecStart=/srv/myapp/venv/bin/gunicorn -c /srv/myapp/app/gunicorn.conf.py config.wsgi:application

RuntimeDirectory= creates both directories on start and removes them on stop, so stale metric files from previous runs never mix with current ones. The directory must be empty when the application starts.

Tell the client library when a worker exits, in gunicorn.conf.py:

from prometheus_client import multiprocess


def child_exit(server, worker):
    multiprocess.mark_process_dead(worker.pid)

In multiprocess mode, gauges need a multiprocess_mode (for example livesum or max) to define how values from different processes combine, and some metric types behave differently; check the prometheus_client documentation for custom metrics you add. django-prometheus's metrics view detects the multiprocess environment variable and aggregates across processes.

4. Add application-specific metrics

Generic request metrics are a start. Add a few business-relevant counters:

# orders/metrics.py
from prometheus_client import Counter, Histogram

orders_created = Counter(
    "shop_orders_created_total",
    "Orders created",
    ["payment_method"],
)

checkout_duration = Histogram(
    "shop_checkout_duration_seconds",
    "Time spent in the checkout service call",
    buckets=(0.1, 0.25, 0.5, 1, 2.5, 5, 10),
)
# orders/services.py
from .metrics import checkout_duration, orders_created


def place_order(cart, payment_method):
    with checkout_duration.time():
        order = _create_order(cart, payment_method)
    orders_created.labels(payment_method=payment_method).inc()
    return order

Keep label values to small, fixed sets. Never use user IDs, email addresses, order IDs, or raw URLs as labels; each unique combination creates a new time series.

5. Exporters for the rest of the stack

  • node_exporter on each server: CPU, memory, disk, network, filesystem usage.
  • postgres_exporter: connections, transaction rates, locks, replication lag, database size.
  • nginx-prometheus-exporter with Nginx stub_status: active connections, request rate.
  • redis_exporter if you use Redis: memory, evictions, connected clients.
  • Celery: a Celery exporter or Flower's metrics endpoint for queue lengths and task outcomes.
one exporter per componentdjango-prometheusrequests, latency, DB, cachenode_exporterCPU, memory, diskpostgres_exporter, redis_exporterconnections, memory, locksPrometheusscrapes, stores, alertsGrafanadashboardsEach exporter answers questions the others cannotdjango-deployment.com
Keep exporters on private ports that only Prometheus can reach.

Enable Nginx stub_status on a local-only location:

server {
    listen 127.0.0.1:8080;
    location = /stub_status {
        stub_status;
    }
}

6. Configure Prometheus

prometheus.yml:

global:
  scrape_interval: 30s

scrape_configs:
  - job_name: django
    metrics_path: /metrics
    static_configs:
      - targets: ["10.0.1.11:8000", "10.0.1.12:8000"]

  - job_name: node
    static_configs:
      - targets: ["10.0.1.11:9100", "10.0.1.12:9100", "10.0.2.5:9100"]

  - job_name: postgres
    static_configs:
      - targets: ["10.0.2.5:9187"]

Start Prometheus with an explicit retention (--storage.tsdb.retention.time=30d) sized to your disk.

7. Dashboards that answer real questions

Build panels around four signals for the web tier:

Traffic:

sum(rate(django_http_requests_total_by_view_transport_method_total[5m]))
panels that answer questionsAnswersRequest rateis traffic normal right now?Error ratio!are users seeing failures?p95 latencyare pages getting slower?Saturationare we running out of capacity?Put these four at the top; details go belowdjango-deployment.com
Group by view name, not raw URL, to keep series counts sane.

Error rate (5xx share):

sum(rate(django_http_responses_total_by_status_total{status=~"5.."}[5m]))
/
sum(rate(django_http_responses_total_by_status_total[5m]))

Latency p95 by view:

histogram_quantile(
  0.95,
  sum by (le, view) (rate(django_http_requests_latency_seconds_by_view_method_bucket[5m]))
)

Saturation: CPU and memory from node_exporter, database connections from postgres_exporter, Celery queue length.

Metric names can differ between django-prometheus versions; open /metrics in staging and confirm the exact names before building dashboards.

8. Alerts

Start with a short list tied to user impact:

groups:
  - name: django
    rules:
      - alert: DjangoHighErrorRate
        expr: |
          sum(rate(django_http_responses_total_by_status_total{status=~"5.."}[5m]))
          / sum(rate(django_http_responses_total_by_status_total[5m])) > 0.02
        for: 10m
        labels:
          severity: page
        annotations:
          summary: "More than 2% of Django responses are 5xx"

      - alert: DjangoTargetDown
        expr: up{job="django"} == 0
        for: 5m
        labels:
          severity: page

      - alert: DiskAlmostFull
        expr: |
          (node_filesystem_avail_bytes{fstype!~"tmpfs|overlay"}
           / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"}) < 0.15
        for: 15m
        labels:
          severity: warn
alert on the error ratiosum(rate(django_http_responses_total_by_status_total{status=~"5.."}[5m]))/ sum(rate(django_http_responses_total_by_status_total[5m]))> 0.02for:10m1231Server errors only.2More than 2% of responses.3Must persist, so blips do not page anyone.A ratio scales with traffic; a raw count does notdjango-deployment.com
Pair it with an alert on the target being down.

Add latency and database connection alerts once you know normal values. Alerts that fire often without action erode trust; tune thresholds from observed baselines.

9. Verify

curl -s http://10.0.1.11:8000/metrics | grep -E "^django_http_requests_total" | head -3
curl -s http://prometheus.internal:9090/api/v1/targets | python3 -m json.tool | grep -E '"health"|"scrapeUrl"'

Verification check:

  • all targets show health: up in Prometheus
  • request counters increase steadily rather than jumping between scrapes (multiprocess mode working)
  • /metrics is not reachable from the public internet
  • a test alert (for example stopping a staging target) reaches your notification channel

Explanation

Metrics turn many individual events into trends you can graph and alert on. Prometheus pulls metrics from each component, and Grafana visualizes them. The Django-specific parts are about correctness (aggregating across Gunicorn workers), safety (keeping /metrics private and labels bounded), and coverage (combining app metrics with database, proxy, and host exporters so you can tell whether a slowdown comes from code or from saturation).

Edge cases / notes

Containers and Kubernetes

With one Gunicorn master per container, multiprocess mode still applies inside the container. In Kubernetes, use service discovery or the Prometheus Operator's PodMonitor or ServiceMonitor instead of static targets.

Managed monitoring services

Many hosted services accept Prometheus metrics via remote write or agents, which removes the need to operate Prometheus storage yourself.

Metrics versus traces

Metrics show that the checkout endpoint got slower; traces show which query or HTTP call inside it caused the slowdown. The two complement each other.

Instrumenting database and cache backends

django-prometheus can wrap Django's database and cache backends to count queries, errors, and connection events per alias:

DATABASES = {
    "default": {
        "ENGINE": "django_prometheus.db.backends.postgresql",
        # ... usual settings ...
    }
}

CACHES = {
    "default": {
        "BACKEND": "django_prometheus.cache.backends.redis.RedisCache",
        "LOCATION": os.environ["REDIS_URL"],
    }
}

This adds metrics such as query execution counts and cache hit and miss counters, which are useful for dashboards that relate request latency to database activity. Check the package documentation for the backends available in your installed version, since supported cache backends have changed over time.

Model-level metrics are also available through a mixin that counts inserts, updates, and deletes per model; add them only for models where those counts are meaningful, to keep series counts low.

Celery and queue metrics

Background work needs its own signals:

  • queue length per queue, from the broker (for Redis, the list length per queue name)
  • task outcomes (succeeded, failed, retried) per task name
  • task duration histograms for important tasks
  • oldest waiting task age, the best single signal of a backlog

A small exporter or a Celery signal handler that updates Prometheus counters can provide these; several community exporters exist. Alert on queue length or oldest task age exceeding thresholds tied to user expectations, such as emails delayed by more than five minutes.

Dashboard layout that works during incidents

Organize the main Grafana dashboard from the user's perspective down to infrastructure:

  1. Top row: request rate, 5xx rate, p95 latency, and availability for the site.
  2. Second row: latency and errors by view for the busiest endpoints.
  3. Third row: Gunicorn saturation (CPU per server, worker restarts), Celery queue lengths.
  4. Fourth row: PostgreSQL connections, transactions per second, locks, replication lag.
  5. Bottom row: host CPU, memory, disk, and network.

Add deployment annotations (for example from CI via the Grafana API) so latency changes can be matched to releases at a glance.

Common mistakes

  • Scraping multiple Gunicorn workers without multiprocess mode.
  • Exposing /metrics publicly.
  • Labeling metrics with URLs, user IDs, or other unbounded values.
  • Creating dozens of alerts before knowing baseline behavior.

Next steps

FAQ

How do I expose Prometheus metrics from Django?

Install django-prometheus, add its app and middlewares, and include its URLs to serve /metrics. Restrict that endpoint to your monitoring network.

Why do my Django Prometheus metrics jump around with Gunicorn?

Each Gunicorn worker keeps separate metrics, and each scrape reaches one worker. Enable prometheus_client multiprocess mode with PROMETHEUS_MULTIPROC_DIR and mark dead workers in a child_exit hook.

What should I alert on for a Django application?

Start with availability (targets down), error rate, and latency for key endpoints, plus saturation signals such as disk space and database connections. Add alerts as you learn normal behavior.

Can I use URL paths as Prometheus labels?

Avoid it. Paths with IDs create unbounded label values. Use view names or route patterns, which have a fixed set of values.

Learning path · step 7 of 13

Keeping it running

Keep going