Filter concepts by levelShowing all levels.

Django · DRF Throttling

DRF Throttling and Rate Limiting

Anonymous, user and scoped throttles; the cache backend that decides whether they mean anything; abuse prevention and quotas

  • A throttle is a counter keyed on "who"IP for anonymous, user id when signed in, scope + key per endpoint
  • LocMemCache multiplies your limitworkers × instances counters — "100/hour" becomes 1200/hour on 4×3
  • throttle_classes replaces, never extendssetting it on a view drops the global defaults you meant to keep
  • Behind a proxy, set NUM_PROXIESotherwise every anonymous caller shares the load balancer's IP key
  • A quota is accounting, not shapingexact and durable — a transactional row, never a cache counter

1.Anonymous, user, and scoped throttling

A throttle counts requests against a key and refuses once the count exceeds a rate. DRF ships three classes that differ only in how the key is derived. `AnonRateThrottle` throttles unauthenticated callers by IP address. `UserRateThrottle` throttles by user id when signed in, falling back to the IP when not. `ScopedRateThrottle` reads a `throttle_scope` attribute off the view and applies the rate configured for that scope, which is how one endpoint gets a tighter limit than the rest of the API. Rates are strings like `"100/hour"` or `"5/min"`, and when a caller exceeds one, DRF returns 429 with a `Retry-After` header saying how long to wait.

Key features

  • `AnonRateThrottle` keys on the client IP and applies only to unauthenticated requests; authenticated ones pass through it untouched
  • `UserRateThrottle` keys on `user.pk` when authenticated and falls back to the IP when not, so listing it alone still limits anonymous traffic — just under the same bucket as everyone else behind that address
  • `ScopedRateThrottle` applies only to views that define `throttle_scope`; a view without one is not throttled by it at all
  • Rate strings are `<number>/<period>` where only the first character of the period counts — `s`, `m`, `h`, `d`, so `"5/min"` and `"5/m"` are identical
  • A throttled request returns 429 with `Retry-After` in seconds, from the class's `wait()` method
  • DRF's own docs note the implementations are open to race conditions under high concurrency, so a few extra requests may get through

The three shipped throttle classes

The three shipped throttle classes
ClassKeyApplies toRate comes from
`AnonRateThrottle`client IPunauthenticated requests only`DEFAULT_THROTTLE_RATES["anon"]`
`UserRateThrottle``user.pk`, or IP if anonymousevery request`DEFAULT_THROTTLE_RATES["user"]`
`ScopedRateThrottle`scope + `user.pk` or IPviews declaring `throttle_scope``DEFAULT_THROTTLE_RATES[<scope>]`
a custom subclasswhatever `get_cache_key()` returnswherever you list itits own `scope` attribute
Three classes, three keys — and why you need more than one

AnonRateThrottle

Unauthenticated only

signed-in requests skip it entirely

One key per address

a NAT puts thousands of people in one bucket

The only option before login

which is where the abuse actually is

UserRateThrottle

Precise for signed-in callers

one account cannot hide behind a shared address

The general per-account budget

generous — it is a backstop, not the real defence

Falls back to IP

so it also covers anonymous traffic, coarsely

ScopedRateThrottle

throttle_scope = "login"

the limit becomes a property of the endpoint

Tight where cost is high

password reset, email send, export, search

No scope, no throttle

a view without the attribute is untouched by this class

  • A request arrives
  • AnonRateThrottle — key = client IP
    • Unauthenticated only — signed-in requests skip it entirely
    • One key per address — a NAT puts thousands of people in one bucket
    • The only option before login — which is where the abuse actually is
  • UserRateThrottle — key = user.pk, or IP when anonymous
    • Precise for signed-in callers — one account cannot hide behind a shared address
    • The general per-account budget — generous — it is a backstop, not the real defence
    • Falls back to IP — so it also covers anonymous traffic, coarsely
  • ScopedRateThrottle — key = scope + user.pk or IP
    • throttle_scope = "login" — the limit becomes a property of the endpoint
    • Tight where cost is high — password reset, email send, export, search
    • No scope, no throttle — a view without the attribute is untouched by this class

Command

python
class PasswordResetView(APIView):
    throttle_classes = [ScopedRateThrottle]
    throttle_scope = "password_reset"     # rate from DEFAULT_THROTTLE_RATES["password_reset"]

A throttle is a counter keyed on "who". `AnonRateThrottle` keys on IP and covers only unauthenticated traffic; `UserRateThrottle` keys on the user id (IP when anonymous) — list both, since they cover different halves. `ScopedRateThrottle` moves the limit onto the endpoint via `throttle_scope`, which is how login and password reset get 5/min while the API keeps a generous default. Setting `throttle_classes` on a view replaces the defaults rather than adding to them, and behind a proxy you must set `NUM_PROXIES` or the IP key is meaningless.

2.Custom throttles, Redis, and the multi-instance problem

DRF stores throttle counters in Django's cache. That detail decides whether your rate limit means anything: with the default `LocMemCache`, every process keeps its own counters, so four Gunicorn workers across three instances enforce twelve independent copies of a "100/hour" limit — an effective 1200/hour. Pointing the cache at Redis (or Memcached) gives every process one shared counter, and the configured rate becomes the real rate. A custom throttle subclasses `SimpleRateThrottle` and implements `get_cache_key()` — that one method decides what is being counted, which is how you throttle per API key, per tenant, or per target account rather than per caller.

Key features

  • Throttle counters live in Django's cache; the default `LocMemCache` is per-process, so counters do not add up across workers or instances
  • The multiplier is `workers × instances` — a "100/hour" limit on 4 workers across 3 hosts admits roughly 1200 requests an hour
  • A shared cache (Redis, Memcached) is the fix; set `cache = caches["throttling"]` on the throttle class to use a dedicated one
  • Restarting a process clears `LocMemCache`, so a deploy resets every limit — an attacker only has to wait for one
  • A custom throttle subclasses `SimpleRateThrottle`, sets `scope`, and implements `get_cache_key(request, view)`; returning `None` means "do not throttle this request"
  • DRF's docs state the shipped throttles are open to race conditions under high concurrency, and cache state is not durable — an eviction or flush resets every counter to zero

What the configured rate actually enforces

What the configured rate actually enforces
Cache backendCounter scope"100/hour" really meansSurvives a deploy?
`LocMemCache` (default)one Python process100 × workers × instancesno — cleared on restart
`FileBasedCache`one host100 × instancesyes, but slow and lock-prone
Memcachedthe whole cluster100yes, until eviction
Redisthe whole cluster100yes, and persistable
The same "100/hour" setting, on two cache backends

LocMemCache — counters per process

  • Each Gunicorn worker holds its own dict of counters.
  • 4 workers × 3 instances = 12 independent limits.
  • A "100/hour" rate admits about 1200 requests an hour.
  • Every deploy or worker restart resets all counters to zero.
  • Looks completely correct on a single-process dev server.

Redis — one shared counter

  • One counter per key, seen by every worker on every host.
  • The configured rate is the enforced rate.
  • Counters survive deploys and worker restarts.
  • Still not atomic — DRF documents a few extra requests under high concurrency.
  • A separate cache alias keeps throttle keys out of the page cache.
  • LocMemCache — counters per process
    • Each Gunicorn worker holds its own dict of counters.
    • 4 workers × 3 instances = 12 independent limits.
    • A "100/hour" rate admits about 1200 requests an hour.
    • Every deploy or worker restart resets all counters to zero.
    • Looks completely correct on a single-process dev server.
  • Redis — one shared counter
    • One counter per key, seen by every worker on every host.
    • The configured rate is the enforced rate.
    • Counters survive deploys and worker restarts.
    • Still not atomic — DRF documents a few extra requests under high concurrency.
    • A separate cache alias keeps throttle keys out of the page cache.

Command

python
class TenantRateThrottle(SimpleRateThrottle):
    scope = "tenant"
    cache = caches["throttling"]

    def get_cache_key(self, request, view):
        if not request.user.is_authenticated:
            return None            # None means "not throttled by this class"
        return f"throttle_tenant_{request.user.tenant_id}"

The rate you configure is a rate per counter, and `LocMemCache` gives you one counter per process — so `workers × instances` multiplies your limit, and a deploy resets it. A shared Redis or Memcached cache makes the configured rate the real rate; a separate cache alias keeps throttle keys out of a page-cache flush. A custom throttle is `SimpleRateThrottle` plus `get_cache_key()`, where returning `None` opts the request out. And never bill against a throttle: DRF documents its counters as approximate, so a real quota belongs in a transactional database row.

3.Abuse prevention and quotas

Rate limiting is one layer of abuse prevention, not the whole of it. Above it sits the edge — a CDN or WAF that drops obvious floods before they reach Python at all. Below it sit application-level defences that a request counter cannot express: locking an account after repeated failed logins, requiring a proof-of-work or CAPTCHA once a caller looks automated, and making enumeration endpoints answer identically whether or not the thing exists. A quota is a different mechanism again: throttles shape traffic over short windows and may be approximate, while a quota counts billable usage over a billing period and has to be exact — so it belongs in a database row updated inside a transaction, not in a cache counter.

Key features

  • A throttle limits requests per short window and may be approximate; a quota counts billable usage per period and must be exact and durable
  • Enforce a quota with `select_for_update()` inside `transaction.atomic()`, or with an `F()` expression plus a database `CheckConstraint` — never with a cache counter
  • Raise `rest_framework.exceptions.Throttled` from anywhere to produce a 429 with `Retry-After`, so a quota check reports the same way a throttle does
  • Account lockout after repeated failed logins is a different control from a rate limit: it targets one identity across all addresses, where an IP throttle targets one address across all identities
  • Uniform responses close enumeration: password reset, signup, and login should answer identically whether or not the account exists, and in similar time
  • The edge (CDN/WAF) is where volumetric abuse belongs — traffic dropped there costs no Python process, no database connection, and no cache round trip

Which control catches which abuse

Which control catches which abuse
AbuseVolume?The control that actually works
Scraping the whole cataloguehighedge rate limiting + per-account throttle + pagination depth caps
Credential stuffinghigh, spread across IPsaccount lockout keyed on the *username*, not the IP
Account enumeration via resetlow — 3 requestsidentical responses and timing; a throttle cannot help
Expensive query abuselow — 1 requestquery complexity limits and `statement_timeout`
Overusing a paid plananya transactional quota row, checked before the work
Signup spammediumCAPTCHA or proof-of-work after the first failure signal, plus email verification
Layered defence — each layer catches what the one above cannot see

Edge — CDN / WAF

volumetric floods and known-bad sources, dropped before any Python process is involved

DRF throttles

per-IP and per-account pacing; approximate by design, and the right tool for scraping and brute force

Identity controls

account lockout keyed on the username, so credential stuffing spread across thousands of IPs still trips

Response uniformity

reset, signup and login answer the same whether or not the account exists — closes enumeration a throttle cannot

Query cost limits

bounded ranges, capped pagination depth, statement_timeout — for abuse that needs one request, not many

Quota accounting

a transactional row per account per period; exact, durable, and defensible on an invoice

  1. Edge — CDN / WAF — volumetric floods and known-bad sources, dropped before any Python process is involved
  2. DRF throttles — per-IP and per-account pacing; approximate by design, and the right tool for scraping and brute force
  3. Identity controls — account lockout keyed on the username, so credential stuffing spread across thousands of IPs still trips
  4. Response uniformity — reset, signup and login answer the same whether or not the account exists — closes enumeration a throttle cannot
  5. Query cost limits — bounded ranges, capped pagination depth, statement_timeout — for abuse that needs one request, not many
  6. Quota accounting — a transactional row per account per period; exact, durable, and defensible on an invoice

Command

python
with transaction.atomic():
    usage = AccountUsage.objects.select_for_update().get(account=account, period=period)
    if usage.calls >= usage.limit:
        raise Throttled(detail="Monthly quota exhausted.")
    usage.calls = F("calls") + 1
    usage.save(update_fields=["calls"])

Rate limiting is one layer. Volumetric abuse belongs at the edge, credential stuffing needs lockout keyed on the username rather than the IP, enumeration needs identical responses and timings, and single-request abuse needs query cost limits — none of which a request counter can express. Keep quotas separate from throttles: a throttle shapes traffic and may be approximate, while a quota is accounting and must be exact, so it lives in a transactional row with a database constraint. Raise `Throttled` for it so clients see the familiar 429 and `Retry-After`.

Remember

  • List AnonRateThrottle and UserRateThrottle together — they cover different halves of the traffic, and the abusable endpoints are the ones with no user yet.
  • ScopedRateThrottle moves the limit onto the endpoint via throttle_scope, which is how login gets 5/min while the API keeps a generous default.
  • DRF documents its throttles as open to race conditions under high concurrency: approximate is fine for abuse control and wrong for billing.
  • Rate limiting cannot see low-volume abuse — enumeration, one expensive query, credential stuffing spread across IPs each need their own control.

Pro tip

  • Give throttling its own cache alias: counters and cached pages have different eviction needs, and a page-cache flush should not reset every rate limit.
  • Log the throttle key alongside the 429 — without it you cannot distinguish one abusive caller from a thousand legitimate users behind one NAT.

Next up

  • API Design
  • API Idempotency
  • API Error Handling

Sources

  • DRF Throttling — django-rest-framework.org/api-guide/throttling/
  • Every claim checked against Django REST Framework 3.15 docs · checked 2026-09-04

Spotted a mistake on this page? Tell us.