limits review
limits 5.8.0 is a rate-limit engine for Python code that already knows what should be limited and what to do when capacity runs out. It parses rules such as `100/minute`, applies fixed-window, moving-window, or sliding-counter math, and stores counters in memory, Redis, Valkey, Memcached, or MongoDB. Sync and asyncio modules expose the same three decisions: consume with `hit()`, look ahead with `test()`, and read quota state with `get_window_stats()`. HTTP decorators, client identification, 429 responses, and headers belong to a framework wrapper. The current release adds explicit storage credentials and Redis Cluster startup nodes, including IPv6 locators.
limits 5.8.0 installed in 0.3 seconds and occupied 2 MB across 5 packages, with one deprecation warning and no audit findings in our sandbox. Use it when your code owns the quota key, storage, and exhaustion response; use an HTTP wrapper or token-bucket library when those are the actual requirements.
We installed it
| Install | ✓ · 0.3s | 5 packages on disk · 2 MB · 1 deprecation warning |
| Import | ✓ | import limits in 0.41s · pure Python · py.typed · requires Python >=3.10 |
| Known vulns | 0 | (pip-audit) |
Answers from our run
Does limits install cleanly?
Yes. In a fresh container with an empty cache, pip install limits finished in 0.3s, leaving 5 packages and 2 MB on disk. pip-audit reported no known vulnerabilities. The install printed 1 deprecation warning.
What does limits need to run?
Python >=3.10, and nothing compiled: it is pure Python. In our run import limits succeeded in 0.41s, and the package ships py.typed for type checkers.
limits or Flask-Limiter: which should you use?
Pick Flask-Limiter when a Flask app needs decorators, key functions, exemptions, response headers, and automatic 429 handling. limits 5.8.0 installed in 0.3 seconds and occupied 2 MB across 5 packages, with one deprecation warning and no audit findings in our sandbox.
When should you not use limits?
Routes need decorators, automatic client keys, exemptions, headers, and 429 responses. Flask-Limiter or SlowAPI supplies that missing HTTP layer.
Use it if
- Queue jobs, outbound calls, account actions, or custom middleware need one quota engine without a web-framework dependency.
- Several workers must share counters through Redis, Valkey, Memcached, or MongoDB.
- Your policy fits a fixed window, an exact moving timestamp log, or an approximate sliding counter.
- Application code needs remaining capacity and reset time so it can build its own wait policy, headers, or metrics.
- Routes need decorators, automatic client keys, exemptions, headers, and 429 responses. Flask-Limiter or SlowAPI supplies that missing HTTP layer.
- The policy calls for token-bucket refill or a separate burst allowance. Every documented strategy here is based on windows.
- An exact moving window must run on Memcached. Its adapter supports the sliding-counter route instead of the moving timestamp log.
- An asyncio service has to hand the limiter an existing redis-py pool. The documented async extra uses its own client path, so pool ownership may conflict.
- Resets must follow calendar months or a customer's timezone. `RateLimitItem` represents elapsed durations, not billing boundaries.
Setup reality
We installed limits 5.8.0 in a clean Python 3.12 Bookworm container. The install finished in 0.3 seconds, put 5 packages and 2 MB on disk, and printed one deprecation warning. import limits took 0.41 seconds, while pip-audit found zero known vulnerabilities. Package metadata lists 12 direct dependencies and Python 3.10 or newer. The distribution is pure Python, carries py.typed, and declares the MIT License.
MemoryStorage works immediately, but its counters disappear with the process and cannot coordinate multiple workers. Redis, Valkey, Memcached, and MongoDB each need the corresponding extra, service, URI, and credentials. Sync and async imports do not share every client package. Build the backend at process startup so a missing extra or bad connection fails before the first limited operation. Release 5.8.0 lets supported storages receive username and password as keyword options.
Algorithm choice changes accuracy and state. A fixed window holds one counter and admits bursts at the boundary. A moving window keeps request timestamps for an exact rolling decision. The sliding counter combines 2 buckets, trading precision for less stored data. Each backend implements a particular capability set, so an integration test must cover the exact storage and strategy pair, including any Redis proxy or cluster.
Treat hit() as the final permission check because the backend tests and consumes in one operation. test() spends nothing, so another worker can win the capacity before a later call to hit(). The limit plus every supplied identifier forms the storage key; changing 5/minute to 10/minute starts addressing another key. get_window_stats() reports an absolute reset timestamp and remaining capacity. Your application still has to translate that into its own wait behavior or HTTP headers.
Patterns
Parse configured quotas parse-limit
from limits import parse, parse_many
per_user = parse('100/minute')
tiers = parse_many('5/second; 100/minute; 1000/hour')Invalid quota text raises `ValueError`. Parse configuration during startup so a typo cannot wait until the first request or job.
Consume a fixed-window quota fixed-window-hit
from limits import parse
from limits.storage import MemoryStorage
from limits.strategies import FixedWindowRateLimiter
limiter = FixedWindowRateLimiter(MemoryStorage())
quota = parse('100/minute')
allowed = limiter.hit(quota, 'user', user_id)`MemoryStorage` belongs to one process, so a second worker gets another counter. A fixed window can admit traffic on both sides of its reset boundary.
Use an exact moving window in Redis redis-moving-window
from limits import parse
from limits.storage import RedisStorage
from limits.strategies import MovingWindowRateLimiter
storage = RedisStorage('redis://localhost:6379/0')
limiter = MovingWindowRateLimiter(storage)
allowed = limiter.hit(parse('10/minute'), 'api-key', api_key)A moving window records request times and only works on supporting backends. Exercise its Redis commands through the same proxy or cluster used in production.
Use an approximate sliding counter sliding-window-counter
from limits import parse
from limits.storage import storage_from_string
from limits.strategies import SlidingWindowCounterRateLimiter
storage = storage_from_string('redis://localhost:6379/0')
limiter = SlidingWindowCounterRateLimiter(storage)
allowed = limiter.hit(parse('1000/minute'), 'tenant', tenant_id)This algorithm weights the previous bucket against the current one. Its 2 counters use less state than a timestamp log and produce an approximation.
Construct storage from configuration build-storage-from-uri
from limits.storage import storage_from_string
storage = storage_from_string(
'redis://:secret@redis.internal:6379/1'
)Construct the backend at startup. A valid-looking URI still fails if the optional client is missing, credentials are wrong, or the server is unreachable.
Check capacity before expensive work peek-without-consuming
if limiter.test(quota, 'tenant', tenant_id, cost=estimated_cost):
prepare_request()
if not limiter.hit(quota, 'tenant', tenant_id, cost=actual_cost):
raise QuotaExceeded()`test()` does not reserve capacity. Another worker may consume the last unit before `hit()`, making `hit()` the only authoritative decision.
Charge a request by token count charge-variable-cost
token_budget = parse('10000/hour')
if not limiter.hit(
token_budget,
'llm-tokens',
tenant_id,
cost=token_count,
):
raise QuotaExceeded()The `cost` argument spends several units in one hit. A request larger than the entire quota cannot pass until the policy itself changes.
Build quota response data read-window-stats
import time
window = limiter.get_window_stats(quota, 'user', user_id)
response_headers = {
'X-RateLimit-Remaining': str(window.remaining),
'X-RateLimit-Reset': str(int(window.reset_time)),
'Retry-After': str(max(1, int(window.reset_time - time.time()))),
}`reset_time` is an absolute Unix timestamp. An HTTP `Retry-After` delay is relative seconds, so calculate it instead of copying that value.
Consume quota in asyncio code async-redis-limit
from limits import parse
from limits.aio.storage import RedisStorage
from limits.aio.strategies import MovingWindowRateLimiter
storage = RedisStorage('async+redis://localhost:6379/0')
limiter = MovingWindowRateLimiter(storage)
allowed = await limiter.hit(parse('50/minute'), 'user', user_id)Both classes must come from `limits.aio`. The async Redis extra follows a different client dependency path from the synchronous adapter.
Keep two equal quotas separate separate-namespaces
from limits import RateLimitItemPerMinute
login = RateLimitItemPerMinute(5, namespace='login')
password_reset = RateLimitItemPerMinute(5, namespace='password-reset')
limiter.hit(login, 'ip', client_ip)
limiter.hit(password_reset, 'ip', client_ip)Namespace and identifiers participate in the stored key. Equal numeric limits share capacity only when those key components also match.
Clear a caller's limit clear-one-counter
quota = parse('5/minute')
limiter.clear(quota, 'login', user_id)`clear()` needs the exact quota and identifiers passed to `hit()`. A different amount or namespace points at a different stored counter.
Close storage during shutdown close-storage
async def shutdown():
await storage.close()Shutdown differs between sync and async storage classes. A client pool supplied by the application may have to outlive the limiter instead of closing here.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| Flask-Limiter | PyPI | Pick it when a Flask app needs decorators, key functions, exemptions, response headers, and automatic 429 handling. |
| slowapi | PyPI | Pick it for rate limits attached directly to FastAPI or Starlette routes. |
| pyrate-limiter | PyPI | Pick it when bucket refill, scheduling, and bursts describe the policy better than window counters. |
More utils guides
lru-cache · ajv · type-fest · p-limit · find-up · js-yaml · the whole shelf →
How this guide is made: grounded in the library's documentation, release notes, changelog, and issue history, on a fixed rubric — not a hands-on install of every release. The 50 most-downloaded entries are additionally install-verified in clean containers. Corrections: contact the desk.

