mrkeyoor.com_
Thu 06 Aug 15:38 UTC
PyPIUtilsupdated 06 Aug 2026

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.

Verdict

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.

API stability4/5The RateLimiter surface (hit, test, get_window_stats, clear) has kept the same shape across majors and the package ships py.typed with full annotations. Majors do arrive regularly and each one moves the Python floor and the supported client versions, so upgrades need the changelog.
Docs4/5limits.readthedocs.io walks through each strategy with a minute-by-minute worked example, documents every storage URI scheme and its options, and has separate async and benchmark pages. Small inaccuracies exist: the README's quick start writes storage.MongoDbStorage while the exported class is MongoDBStorage.
Maintenance5/5Pushed August 2026, releases through 5.8.0 in February 2026, and only 3 open issues (14 counting PRs) for a package pulled in by millions of installs a week, which means reports genuinely get closed. It is a single-maintainer project, which is the standing caveat.
Ecosystem4/5About 10.7M downloads a week, though most of that is flask-limiter and slowapi pulling it in transitively. Direct usage is less common, so search results tend to teach the wrapper's decorator API rather than this one.

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
Skip it if

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 consume

test() 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 only

clear() 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

PackageRegistryPick it when
flask-limiterPyPIYou are on Flask and want decorators, key functions and 429 handling built on this same engine.
slowapiPyPIYou are on FastAPI or Starlette and want the same wrapper treatment for async routes.
pyrate-limiterPyPIYou need token or leaky bucket behaviour with burst capacity rather than window counters.