limits
limits is the rate limiting primitive that sits underneath most Python rate limiters rather than being one itself. You give it a limit written as a string like 100/minute, pick a strategy (fixed window, moving window, or sliding window counter), point it at a storage backend (in-memory, Redis including cluster and sentinel, Memcached, or MongoDB), and it answers exactly one question: is this hit allowed. It knows nothing about HTTP. It will not extract a client IP, will not write a Retry-After header, will not return a 429, and does not integrate with any framework. The same method-for-method API exists twice, synchronously under limits.strategies and with await under limits.aio.strategies.
The correct choice when you are rate limiting something that is not an HTTP route, and a well-kept, well-typed library with only 3 open issues behind it. If you are on Flask or FastAPI, install flask-limiter or slowapi instead and let them depend on this.
Use it if
- You are rate limiting something that is not a web request: a queue consumer, an outbound third-party API client, a gRPC handler, or middleware you are writing yourself
- The counters must be shared across processes or pods, so an in-process token bucket is wrong and you need Redis, Memcached or MongoDB holding the state
- You want to choose the accuracy-versus-memory tradeoff on purpose: fixed window is one integer per key, moving window keeps a timestamp log and is exact, sliding window counter approximates with two counters
- You have both sync and async paths in the same codebase and want one mental model, since limits.aio mirrors the sync classes method for method
- You need the quota numbers, not just a yes or no: get_window_stats gives you remaining and reset_time so you can build rate limit headers
- You are on Flask or FastAPI and Starlette: flask-limiter and slowapi already wrap this exact library and hand you decorators, key functions, route exemptions and 429 responses. Using limits directly means reimplementing all of that
- You want token bucket or leaky bucket semantics with a refill rate and a burst allowance: all three strategies here are window-based, and pyrate-limiter models buckets natively
- You pair Memcached with the moving window strategy: MemcachedStorage declares only SlidingWindowCounterSupport, so a MovingWindowRateLimiter over it fails at call time rather than at construction. Only Redis, MongoDB and memory implement moving windows
- You already run an async redis-py connection pool and want to reuse it: the async Redis backend is built on coredis, async Memcached on memcachio and async MongoDB on motor, none of which are the clients the sync extras install
- You are on Python 3.9 or earlier: 5.x requires 3.10 or newer
- You need calendar-accurate periods: a per-month limit is 30 days of seconds and a per-year limit is 360 days, so billing-style quotas that must reset on the first of the month need your own window key
Setup reality
pip install limits gives you the in-memory backend and nothing else. Every real backend is an extra, and the sync and async variants pull different clients: limits[redis] installs redis-py while limits[async-redis] installs coredis, limits[memcached] installs pymemcache while limits[async-memcached] installs memcachio, and MongoDB splits between pymongo and motor the same way. Extras also exist for rediscluster and valkey. Missing dependencies do not blow up at import time, because storage classes resolve them lazily, so a mistyped extra surfaces as a ConfigurationError the first time you construct the storage in production rather than at deploy. Two more things worth knowing before you trust the output: WindowStats is a NamedTuple ordered (reset_time, remaining), which is the reverse of the order most people assume when unpacking it, and the moving window strategy on Redis runs Lua scripts, so a Redis proxy that blocks EVAL will break it while fixed window keeps working.
Patterns
Define limits from strings or classesparse-limit-string
from limits import parse, parse_many, RateLimitItemPerSecond
one_per_minute = parse("1/minute")
also_valid = parse("100 per hour")
tiered = parse_many("5/second; 100/minute; 1000/hour")
explicit = RateLimitItemPerSecond(10, 1, namespace="outbound-api")parse raises ValueError on anything it cannot read, so validate config strings at startup rather than on the first request. parse_many accepts comma, semicolon or pipe separators and returns a list you check in order, cheapest limit first.
Check and consume with a fixed windowfixed-window-hit
from limits import parse, storage, strategies
backend = storage.MemoryStorage()
limiter = strategies.FixedWindowRateLimiter(backend)
limit = parse("100/minute")
if not limiter.hit(limit, "user", user_id):
raise TooManyRequests()
handle_request()hit() both checks and consumes in one atomic step, which is what you want; checking then consuming separately is a race. The window starts on the first hit, not at the top of the minute, so bursts can straddle a boundary and let through up to twice the limit.
Enforce an exact rolling windowmoving-window-strategy
from limits import storage, strategies, parse
backend = storage.RedisStorage("redis://localhost:6379")
limiter = strategies.MovingWindowRateLimiter(backend)
allowed = limiter.hit(parse("10/minute"), "api", api_key)This stores one timestamp per hit, so a 10000/hour limit across 50000 keys is a lot of Redis memory; size it before shipping. MemcachedStorage does not implement moving window support at all and raises when you pair them.
Approximate a rolling window cheaplysliding-window-counter
from limits import storage, strategies, parse
backend = storage.storage_from_string("redis://localhost:6379")
limiter = strategies.SlidingWindowCounterRateLimiter(backend)
allowed = limiter.hit(parse("1000/minute"), "tenant", tenant_id)Two counters per key instead of a timestamp log, weighting the previous window by how far into the current one you are. The count is an estimate, so a client hitting hard right at a boundary can be allowed slightly over or blocked slightly under the true limit.
Build storage from a config stringstorage-from-uri
from limits.storage import storage_from_string
backend = storage_from_string("memory://")
backend = storage_from_string("redis://:password@localhost:6379/1")
backend = storage_from_string("redis+sentinel://host1:26379,host2:26379/mymaster")
backend = storage_from_string("memcached://localhost:11211")
backend = storage_from_string("mongodb://localhost:27017")
backend = storage_from_string("async+redis://localhost:6379")An unknown scheme raises ConfigurationError immediately, but a known scheme whose client package is missing only fails when the storage is first used. Prefix with async+ to get the asyncio variant of the same backend.
Emit quota headers from window statsrate-limit-headers
import time
from limits import parse
limit = parse("100/minute")
allowed = limiter.hit(limit, "user", user_id)
window = limiter.get_window_stats(limit, "user", user_id)
headers = {
"X-RateLimit-Limit": str(limit.amount),
"X-RateLimit-Remaining": str(window.remaining),
"X-RateLimit-Reset": str(int(window.reset_time)),
}
if not allowed:
headers["Retry-After"] = str(max(1, int(window.reset_time - time.time())))WindowStats unpacks as (reset_time, remaining), so tuple unpacking in the intuitive order silently swaps the two. Always use the attribute names. reset_time is absolute epoch seconds, while Retry-After must be a relative delta.
Peek at a limit without spending ittest-without-consuming
from limits import parse
limit = parse("1/second")
while not limiter.test(limit, "job", queue_name):
time.sleep(0.05)
limiter.hit(limit, "job", queue_name) # now actually consumetest() is genuinely useful for backing off before doing expensive work, but the gap between test and hit is a race under concurrency, so two workers can both pass the test. Treat it as a hint, and let hit() be the authority.
Charge more than one unit per callweighted-cost
from limits import parse
quota = parse("10000/hour")
tokens = estimate_tokens(prompt)
if not limiter.hit(quota, "llm", tenant_id, cost=tokens):
raise QuotaExceeded(f"needed {tokens} tokens")
if limiter.test(quota, "llm", tenant_id, cost=tokens):
...cost lets one limit cover uneven work such as tokens, bytes or database rows. A single call whose cost exceeds the whole limit can never succeed, so clamp or reject oversized requests before you get here.
The same limiter in async codeasync-rate-limit
from limits import parse
from limits.aio.storage import RedisStorage
from limits.aio.strategies import MovingWindowRateLimiter
backend = RedisStorage("async+redis://localhost:6379")
limiter = MovingWindowRateLimiter(backend)
async def handler(user_id: str) -> None:
if not await limiter.hit(parse("100/minute"), "user", user_id):
raise TooManyRequests()Import the storage from limits.aio.storage, not limits.storage; the class names are identical, and mixing them gives you a coroutine-returning method on a sync object or the reverse. The async Redis backend needs limits[async-redis], which installs coredis rather than redis-py.
Control the storage key for a limitnamespace-and-keys
from limits import RateLimitItemPerMinute
login = RateLimitItemPerMinute(5, namespace="login")
signup = RateLimitItemPerMinute(5, namespace="signup")
print(login.key_for("ip", request.remote_addr))
# LIMITS:login/ip/1.2.3.4/5/1/minute (shape of the composed key)
limiter.hit(login, "ip", request.remote_addr)Identifiers are joined into the key along with the amount and granularity, which means changing a limit from 5/minute to 10/minute starts a fresh key and resets everyone's counter. Distinct namespaces stop two features with the same numbers from sharing a bucket.
Clear a limit for one callerreset-a-limit
limiter.clear(parse("5/minute"), "login", user_id) # e.g. after a successful login
backend.reset() # wipe everything this storage owns; test suites onlyclear() must be given the identical limit and identifiers used for the hits, since it recomputes the same key; a different amount targets a different key and appears to do nothing. backend.reset() on Redis deletes every key in the limiter namespace, so never call it against a shared production instance.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| flask-limiter | PyPI | You are on Flask and want decorators, key functions and 429 handling built on this same engine. |
| slowapi | PyPI | You are on FastAPI or Starlette and want the same wrapper treatment for async routes. |
| pyrate-limiter | PyPI | You need token or leaky bucket behaviour with burst capacity rather than window counters. |