FastAPI vs Django Rate Limit Middleware

The decision here is which framework’s throttling model fits the service you are building: SlowAPI on FastAPI’s async ASGI stack, or django-ratelimit and DRF throttle classes on Django’s synchronous WSGI stack. This comparison sits under the FastAPI Throttling Patterns parent topic, and the wiring details for the Django side live in Django Rate Limit Configuration; this page is the head-to-head. The choice rarely turns on which can enforce 100/min — both can — but on how the limit check interacts with the concurrency model, where the counter lives, and what your team already operates. Pick the stack that matches your runtime, not the one with the prettier decorator.

A concrete frame: a public API serving 2,000 RPS with a 100 req/min per API key limit needs a shared counter either way. On FastAPI the limit check is an await against redis.asyncio that yields the event loop; on Django (sync) it is a blocking cache call inside a worker thread. Same Redis, same accuracy — different cost model under load.

FastAPI async limit check versus Django sync limit check over a shared Redis store FastAPI awaits a non-blocking Redis check on the event loop while Django performs a blocking cache call inside a worker thread, both reaching the same shared Redis counter. FastAPI / SlowAPI (ASGI) Django / DRF (WSGI) async event loop one loop, many tasks await redis.asyncio non-blocking, yields loop worker thread pool workers x threads blocking cache.incr holds thread until done shared Redis counter one key per identity

Problem framing: same limit, different runtime

Both ecosystems converge on Redis for distributed correctness, so the differentiators are upstream of the counter:

  • Concurrency model. FastAPI runs on an async event loop (Uvicorn/ASGI). A throttle check that awaits Redis frees the loop to serve other requests during the round-trip. Django’s mainstream deployment is synchronous (Gunicorn/WSGI); a blocking cache call occupies its worker thread for the full round-trip, so pool sizing matters more.
  • Insertion point. FastAPI offers Starlette middleware, the @limiter.limit decorator, and Depends(). Django offers a MIDDLEWARE entry, the @ratelimit decorator, and — for DRF — declarative throttle classes resolved inside the view’s check_throttles().
  • Ecosystem fit. If the service is already a DRF API, throttle classes need zero new dependencies and inherit DRF’s auth/permission ordering. If it’s a fresh async service, SlowAPI is the lowest-friction path.

Decision / comparison table

Dimension FastAPI + SlowAPI Django + django-ratelimit / DRF
Runtime model Async, ASGI (Uvicorn) — non-blocking Redis check on the event loop Sync, WSGI (Gunicorn) — blocking cache check in a worker thread
Primary enforcement @limiter.limit decorator; SlowAPIMiddleware; Depends() MIDDLEWARE; @ratelimit decorator; DRF throttle classes
Backend store limits library store via storage_uri (Redis/Memcached/in-memory) Django cache framework (RATELIMIT_CACHEdjango_redis) / DRF cache
Async Redis driver redis.asyncio — true non-blocking I/O Sync redis-py; async requires ASGI + sync_to_async plumbing
Key function key_func(request) -> str (synchronous) key string shortcut or callable (group, request) -> str
Default algorithm Configurable strategy (fixed-window, moving-window) Fixed window (TTL boundary); sliding needs custom Lua/backend
Burst at window edge Avoidable with moving-window strategy Up to ~2x at fixed-window transition unless custom
Multi-tier quotas Resolve in Depends() / dynamic limit callable DRF ScopedRateThrottle + DEFAULT_THROTTLE_RATES
429 headers Custom exception handler injects Retry-After Middleware reads request.ratelimit; DRF sets Retry-After
Best fit New async services, streaming, high fan-out I/O Existing Django/DRF APIs, sync ORM-heavy workloads

Selection rules:

  • Choose FastAPI + SlowAPI for a greenfield async service, especially one fanning out to many slow upstreams where the event loop’s non-blocking Redis check pays off.
  • Choose DRF throttle classes when you already run a DRF API — ScopedRateThrottle integrates with existing auth and needs no new library.
  • Choose django-ratelimit for non-DRF Django views that need decorator-level limits without adopting DRF.
  • Either is fine when the limit is simple and traffic is modest; let team familiarity and the existing stack decide.

Step-by-step: the same 100/min limit in both stacks

FastAPI + SlowAPI (async, Redis)

# fastapi_app.py — async ASGI throttling with a shared Redis store.
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware

def api_key_or_ip(request: Request) -> str:
    # Prefer the stable API-key identity; fall back to client IP.
    return request.headers.get("X-API-Key") or (
        request.client.host if request.client else "anon"
    )

limiter = Limiter(
    key_func=api_key_or_ip,
    # moving-window avoids the fixed-window edge burst; Redis makes it shared.
    strategy="moving-window",
    storage_uri="redis://redis-primary:6379/2",
)

app = FastAPI()
app.state.limiter = limiter                       # bind BEFORE routers
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)             # add first = outermost

@app.get("/api/v1/orders")
@limiter.limit("100/minute")
async def list_orders(request: Request):
    return {"orders": []}

Django + DRF (sync, Redis cache)

# settings.py — point the DRF throttle cache at shared Redis.
CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://redis-primary:6379/2",
        "OPTIONS": {"CONNECTION_POOL_KWARGS": {"max_connections": 50}},
    }
}
REST_FRAMEWORK = {
    "DEFAULT_THROTTLE_CLASSES": ["api.throttles.ApiKeyThrottle"],
    "DEFAULT_THROTTLE_RATES": {"api_key": "100/min"},
}
# api/throttles.py — key on the API key, fall back to IP, shared across workers.
from rest_framework.throttling import SimpleRateThrottle

class ApiKeyThrottle(SimpleRateThrottle):
    scope = "api_key"

    def get_cache_key(self, request, view):
        ident = request.headers.get("X-API-Key") or self.get_ident(request)
        return self.cache_format % {"scope": self.scope, "ident": ident}
# api/views.py — DRF resolves the throttle inside check_throttles() before the handler.
from rest_framework.decorators import api_view, throttle_classes
from rest_framework.response import Response
from api.throttles import ApiKeyThrottle

@api_view(["GET"])
@throttle_classes([ApiKeyThrottle])
def list_orders(request):
    return Response({"orders": []})

Both enforce one shared 100/min counter per API key. The FastAPI path yields the event loop during the Redis round-trip; the Django path holds a worker thread, so size max_connections and the Gunicorn pool together.

Gotchas & edge cases

  • Django async is not free. Running Django under ASGI does not make django-ratelimit or DRF throttles non-blocking — the cache calls are still sync unless you wrap them. Don’t assume ASGI equals async I/O for throttling.
  • SlowAPI key_func must be synchronous. It resolves during decorator evaluation; an async key function or blocking I/O inside it errors or stalls the loop. Precompute in middleware and read from request.state.
  • Default windows differ in burst behavior. Django’s fixed window allows edge-of-window bursts; SlowAPI’s moving-window strategy doesn’t, but costs more Redis memory. See Fixed Window Counter Drift Explained.
  • Per-worker counters bite both stacks. SlowAPI’s in-memory store and Django’s LocMemCache both fragment per worker. Always use Redis for more than one worker or instance.
  • DRF throttle ordering. DRF runs throttles after authentication, so an unauthenticated request hits AnonRateThrottle, not your user-keyed class. Order your throttle list deliberately.

Verification & testing

Send more than the limit from one identity against each stack and confirm the aggregate accepted count matches the limit, not a multiple of it.

# Same key, 150 requests, against a 100/min endpoint on either stack.
for i in $(seq 1 150); do
  curl -s -o /dev/null -w "%{http_code}\n" \
    -H "X-API-Key: acct_42" http://localhost:8000/api/v1/orders
done | sort | uniq -c
# Expect ~100 lines of 200 and ~50 of 429 once Redis-backed on both stacks.

Frequently Asked Questions

Is FastAPI rate limiting faster than Django's?

Not at the counter — both hit Redis with comparable latency. FastAPI's advantage is that the awaited Redis check yields the event loop, so one process serves more concurrent requests during the round-trip. Django's sync check occupies a worker thread, so you size the thread pool to cover the wait. For I/O-bound, high-fan-out services the async model uses fewer resources.

Can I use the same Redis for both stacks during a migration?

Yes, but namespace the keys. SlowAPI's limits library and Django's cache framework use different key formats, so point them at the same Redis with distinct prefixes (e.g. a separate logical DB or a key prefix) to avoid collisions while both run.

Should I use DRF throttle classes or django-ratelimit?

If the API is already DRF, use throttle classes — they need no new dependency and integrate with DRF's auth and permission ordering. For plain Django views (no DRF), django-ratelimit gives decorator-level limits without adopting the full framework.

Does running Django under ASGI make throttling async?

No. ASGI changes how requests are served, but django-ratelimit and DRF throttles still make synchronous cache calls unless you explicitly wrap them with sync_to_async. Treat the throttle path as blocking and size your pools accordingly.

Which handles burst traffic more accurately out of the box?

SlowAPI, if you set strategy="moving-window", which avoids the fixed-window edge burst. Django's default and django-ratelimit use a fixed window that can permit up to roughly twice the rate across a window boundary unless you back it with a Lua sliding-window script.