PyrateLimiter
A fast, async-friendly rate limiter for Python — pluggable algorithms and backends.
Documentation · Quickstart · Backends · Migrating from v3
[!NOTE] Upgrading from v3.x? The v4 API is simpler and has breaking changes — see the Migration Guide.
Contents
- Features
- Installation
- Quickstart
- How it works
- Core concepts
- Defining rates & buckets
- Algorithms
- Everyday usage
- Backends
- Web request rate limiting
- Advanced usage
- Examples
Features
- 🧮 Pluggable algorithms — sliding-window log (exact, the default), fixed window, and GCRA / token bucket (constant memory).
- ⏱️ Multiple rates at once — e.g. 5/second and 1000/hour on the same key.
- 🔑 Per-key limits — track different services, users, or resources independently.
- 🧩 Pluggable backends — in-memory, SQLite, Redis (sync and async), Postgres, and multiprocessing.
- ⚡ Sync & async — the same API works in both; async paths never block the event loop.
- 🎀 Direct calls or decorators —
limiter.try_acquire(...)or@limiter.as_decorator(...). - 🚦 Blocking or non-blocking — wait for a permit (with optional timeout) or fail fast.
Installation
PyrateLimiter requires Python 3.10+.
pip install pyrate-limiter
# or
conda install --channel conda-forge pyrate-limiter
Optional backends pull in their own drivers:
pip install "pyrate-limiter[all]" # redis + psycopg (Postgres) + filelock
Or just the one you need:
pip install "pyrate-limiter[redis]" # RedisBucket, RedisStateStore
pip install "pyrate-limiter[postgres]" # PostgresBucket
pip install "pyrate-limiter[filelock]" # multi-process SQLite
Quickstart
Limit to 5 requests per 2 seconds:
from pyrate_limiter import Duration, Rate, Limiter
# A Limiter with a single rate, backed by an in-memory bucket
limiter = Limiter(Rate(5, Duration.SECOND * 2))
# Blocking (default): waits until a permit is available
for i in range(6):
limiter.try_acquire("my-resource")
print(f"acquired {i}")
# Non-blocking: returns False immediately when the bucket is full
if not limiter.try_acquire("my-resource", blocking=False):
print("rate limited!")
Prefer a one-liner? The limiter_factory covers the common cases:
from pyrate_limiter import Duration, limiter_factory
limiter = limiter_factory.create_inmemory_limiter(rate_per_duration=5, duration=Duration.SECOND)
limiter.try_acquire("my-resource")
How it works
flowchart TB
user([Your application]):::ext
user -->|"limit a key: try_acquire · try_acquire_async · @as_decorator"| limiter
subgraph pyrate["PyrateLimiter"]
direction TB
limiter["Limiter<br/>public API"]:::api
factory["BucketFactory<br/>routes each key to its bucket"]:::core
leaker["Leaker<br/>background cleanup"]:::leak
clock(["Clock<br/>time source"]):::clk
limiter --> factory
factory -->|routes to| backends
leaker -.->|periodic leak| backends
backends -->|reads time from| clock
subgraph backends["Bucket backend — choose one"]
direction LR
mem["InMemory"]:::bkt
sqlite["SQLite"]:::bkt
redis["Redis<br/>sync · async"]:::bkt
pg["Postgres"]:::bkt
mp["Multiprocess"]:::bkt
end
end
classDef api fill:#E5484D,color:#ffffff,stroke:#E5484D;
classDef core fill:#242A33,color:#ffffff,stroke:#242A33;
classDef bkt fill:#EEF2F6,color:#242A33,stroke:#CBD5E1;
classDef clk fill:#ffffff,color:#242A33,stroke:#242A33;
classDef leak fill:#F2C94C,color:#242A33,stroke:#E0B53C;
classDef ext fill:#ffffff,color:#242A33,stroke:#9AA5B1;
The bucket analogy — a bucket represents a fixed capacity (a service, an API quota, …). It fills as requests arrive and drains over time. When the bucket is full, new requests are delayed (blocking) or rejected (non-blocking).
Exactly how it drains is the algorithm's business. By default a
request stops counting once it falls out of a rolling window; with
GCRA/TokenBucket the bucket genuinely leaks at a
constant rate, which is the Leaky Bucket algorithm
proper.
Core concepts
| Component | Role |
|---|---|
Clock |
Timestamps incoming items. Only needs now() -> int (sync or async). |
Bucket |
Stores timestamped items; enforces the rates; leak()s out expired items. |
BucketFactory |
Timestamps & routes each item to the right bucket; schedules background leaking. |
Limiter |
The friendly façade. Sync/async, blocking/non-blocking, direct call or decorator; thread-safe via RLock (and asyncio.Lock when async). |
For simple cases you only ever touch Limiter and Rate — the rest is wired up for you.
Defining rates & buckets
An API might allow 500/hour, 1000/day, and 10000/month. Express each as a Rate(limit, interval):
from pyrate_limiter import Duration, Rate
rates = [
Rate(500, Duration.HOUR), # 500 requests per hour
Rate(1000, Duration.DAY), # 1000 requests per day
Rate(10000, Duration.WEEK * 4), # ~10000 requests per month
]
[!IMPORTANT] Rates must be ordered generous-to-tight: increasing interval, increasing limit, and a non-increasing
limit/intervalratio. Ill-formed lists raiseValueErrorat construction. Check a list yourself withvalidate_rate_list(rates).
Pass the rates straight to a Limiter (uses an in-memory bucket), or build a specific bucket:
from pyrate_limiter import InMemoryBucket, Limiter
limiter = Limiter(rates) # shortcut: in-memory bucket
# equivalent to:
limiter = Limiter(InMemoryBucket(rates))
limiter.try_acquire("hello world")
See Backends for Redis, SQLite, Postgres, and multiprocessing.
Algorithms
Every bucket takes an algorithm=. The default is a sliding window log:
exact, but it stores one entry per consumed unit.
from pyrate_limiter import FixedWindow, InMemoryBucket, Rate, Duration
bucket = InMemoryBucket([Rate(100, Duration.HOUR)], algorithm=FixedWindow())
| Algorithm | Stores | Trade-off |
|---|---|---|
SlidingWindowLog (default) |
One entry per consumed unit | Exact. Never allows a burst above limit in any rolling interval. |
FixedWindow |
One entry per consumed unit | Coarser: up to 2 * limit can pass across a boundary. Cheap to reason about. |
GCRA / TokenBucket |
A couple of numbers, whatever the traffic | Constant memory and a smooth output rate. Needs a StateBucket. |
Reach for FixedWindow when you are mirroring an upstream API that genuinely
resets on a boundary — a quota that refreshes on the hour, or at midnight UTC.
Its boundary burst is real and worth seeing:
bucket = InMemoryBucket([Rate(3, Duration.SECOND)], algorithm=FixedWindow())
# 3 requests just before the boundary, 3 more just after it:
# 6 requests inside ~100ms, both windows within their limit.
GCRA / Token bucket
The window algorithms keep one stored entry per consumed unit. That is what
makes them exact, and it is also why a RedisBucket sorted set grows without
bound on long windows. GCRA keeps one number per rate instead — the moment
the bucket would next be empty — so storage stays the same size forever.
Use a StateBucket:
from pyrate_limiter import Duration, Limiter, Rate, StateBucket, TokenBucket
# 5 requests/second sustained, up to 10 in one lump
bucket = StateBucket([Rate(5, Duration.SECOND, burst=10)], algorithm=TokenBucket())
limiter = Limiter(bucket)
Or the one-liner:
from pyrate_limiter import limiter_factory
limiter = limiter_factory.create_token_bucket_limiter(rate_per_duration=5, burst=10)
TokenBucket is GCRA — a bucket of burst tokens refilling at
limit / interval admits exactly what GCRA does with an emission interval of
interval / limit. Two names, one implementation.
burst controls how bursty you allow the output to be. It defaults to
limit (classic token bucket: a full bucket at rest). burst=1 produces a
perfectly smooth drip with no reserve at all.
For Redis, use a RedisStateStore:
from redis import Redis
from pyrate_limiter import Rate, RedisStateStore, StateBucket, Duration
redis = Redis.from_url("redis://localhost:6379")
store = RedisStateStore(redis, "my-key") # sync or async client
bucket = StateBucket([Rate(1000, Duration.MINUTE)], store=store)
The transition runs as a Lua script, so the read-modify-write is atomic across
clients, and the key carries a TTL so idle keys clean themselves up. For a
1000/minute limit at saturation this is one hash field of ~100 bytes against
roughly 89 KB of sorted set — and no leak() to schedule.
[!NOTE] A
StateBucketkeeps no per-item log, sopeek()returnsNoneandleak()is a no-op.count()reports how many units are currently owed — an estimate that drains with time, not a log length.
Clocks matter more here. GCRA measures elapsed time, so state shared between
machines needs a shared clock. RedisStateStore defaults the bucket to
WallClock (epoch ms) for exactly this reason; local stores default to
MonotonicClock.
Everyday usage
Blocking, non-blocking & timeout
try_acquire blocks by default until a permit frees up:
from pyrate_limiter import Rate, Limiter, Duration
limiter = Limiter(Rate(3, Duration.SECOND))
for i in range(5):
limiter.try_acquire("item") # blocks when the bucket is full
Fail fast instead with blocking=False:
if not limiter.try_acquire("item", blocking=False):
print("rate limited!")
In async code use try_acquire_async, optionally with a timeout (seconds):
acquired = await limiter.try_acquire_async("item", timeout=5)
if not acquired:
print("timed out waiting for a permit")
The buffer_ms Limiter parameter (default 50) adds a small slack to absorb clock drift:
from pyrate_limiter import Rate, Duration, InMemoryBucket, Limiter
bucket = InMemoryBucket([Rate(5, Duration.SECOND)])
limiter = Limiter(bucket, buffer_ms=100)
Weight
Items can carry weight (default 1). An item of weight W consumes W unit-slots atomically — either all W fit or none do:
BigItem(weight=5, name="item") → 5 × item(weight=1, name="item", same timestamp)
limiter.try_acquire("the-sun", weight=10)
Decorator
as_decorator wraps any sync or async function:
from pyrate_limiter import Rate, Duration, Limiter
limiter = Limiter(Rate(5, Duration.SECOND))
@limiter.as_decorator(name="api_call", weight=1)
def handle_something(*args, **kwargs):
...
@limiter.as_decorator(name="background_job", weight=2)
async def handle_something_async(*args, **kwargs):
...
Context manager
Limiter releases its resources (background leak tasks, connections) on exit:
from pyrate_limiter import Rate, Duration, Limiter
with Limiter(Rate(5, Duration.SECOND)) as limiter:
limiter.try_acquire("item")
# resources released here
# …or close manually
limiter = Limiter(Rate(5, Duration.SECOND))
try:
limiter.try_acquire("item")
finally:
limiter.close()
asyncio & event loops
Use try_acquire_async so waiting uses asyncio.sleep instead of blocking the loop. With a sync bucket, wrap it in BucketAsyncWrapper:
from pyrate_limiter import BucketAsyncWrapper, InMemoryBucket, Rate, Duration, Limiter
limiter = Limiter(BucketAsyncWrapper(InMemoryBucket([Rate(5, Duration.SECOND)])))
await limiter.try_acquire_async("item")
Backends
| Backend | Sync | Async | Persistent | Multi-process | Best for |
|---|---|---|---|---|---|
| InMemoryBucket | ✅ | (wrap) | ❌ | ❌ | single process, fastest |
| SQLiteBucket | ✅ | ❌ | ✅ | ✅ (file lock) | persistence / one host, many processes |
| RedisBucket | ✅ | ✅ | ✅ | ✅ | distributed across hosts |
| PostgresBucket | ✅ | ❌ | ✅ | ✅ | distributed, already on Postgres |
| MultiprocessBucket | ✅ | (wrap) | ❌ | ✅ | a single multiprocessing pool |
| BucketAsyncWrapper | — | ✅ | — | — | make any sync bucket async-safe |
Every bucket takes a List[Rate].
InMemoryBucket
from pyrate_limiter import InMemoryBucket, Rate, Duration
bucket = InMemoryBucket([Rate(5, Duration.MINUTE * 2)])
RedisBucket
Stores items in a sorted set (key = item name, score = timestamp). Use the init classmethod — it works for sync and async clients (just await it for async):
from pyrate_limiter import RedisBucket, Rate, Duration
rates = [Rate(5, Duration.MINUTE * 2)]
# sync
from redis import ConnectionPool, Redis
redis_db = Redis(connection_pool=ConnectionPool.from_url("redis://localhost:6379"))
bucket = RedisBucket.init(rates, redis_db, "bucket-key")
# async
from redis.asyncio import ConnectionPool as AsyncPool, Redis as AsyncRedis
redis_db = AsyncRedis(connection_pool=AsyncPool.from_url("redis://localhost:6379"))
bucket = await RedisBucket.init(rates, redis_db, "bucket-key")
RedisBucket stores one sorted-set member per consumed unit for exact sliding-window checks. For high-volume, long-window limits such as daily or monthly quotas, the retained sorted set can grow large; use shorter windows or a coarser counter-based backend if predictable memory and latency are more important than exact per-item history.
SQLiteBucket
Persists state to SQLite (sync only):
from pyrate_limiter import SQLiteBucket, Rate, Duration, Limiter
rate = Rate(5, Duration.MINUTE)
# set use_file_lock=True to share one DB file across processes on a host
bucket = SQLiteBucket.init_from_file([rate], use_file_lock=False)
limiter = Limiter(bucket)
init_from_file(rates, table="rate_bucket", db_path=None, create_new_table=True, use_file_lock=False) — db_path=None uses a temp file; use_file_lock=True uses filelock for multi-process access on a single host.
PostgresBucket
Requires psycopg[pool] (install via the [all] extra). Sync only. Use the built-in PostgresClock, or a custom time source:
from pyrate_limiter import PostgresBucket, Rate, PostgresClock
from psycopg_pool import ConnectionPool
pool = ConnectionPool("postgresql://postgres:postgres@localhost:5432")
bucket = PostgresBucket(pool, "my_bucket_table", [Rate(3, 1000), Rate(4, 1500)])
MultiprocessBucket
Shares a ListProxy across a multiprocessing pool / ProcessPoolExecutor, guarded by a multiprocessing lock. See in_memory_multiprocess.py.
Under contention
bucket.waitingestimates can be off, so prefertry_acquire(..., blocking=True)(the default) — the item keeps retrying instead of returningFalseon a transient miss.
BucketAsyncWrapper
Wraps a sync bucket so every method returns an awaitable, letting the Limiter use asyncio.sleep during delays. See asyncio & event loops.
Web request rate limiting
Drop-in helpers for the popular HTTP clients live in pyrate_limiter.extras:
AIOHTTP
from pyrate_limiter import Duration, limiter_factory
from pyrate_limiter.extras.aiohttp_limiter import RateLimitedSession
limiter = limiter_factory.create_inmemory_limiter(rate_per_duration=2, duration=Duration.SECOND)
session = RateLimitedSession(limiter)
HTTPX
import httpx
from pyrate_limiter import Duration, limiter_factory
from pyrate_limiter.extras.httpx_limiter import AsyncRateLimiterTransport, RateLimiterTransport
limiter = limiter_factory.create_inmemory_limiter(rate_per_duration=1, duration=Duration.SECOND)
with httpx.Client(transport=RateLimiterTransport(limiter=limiter)) as client:
client.get("https://example.com")
async with httpx.AsyncClient(transport=AsyncRateLimiterTransport(limiter=limiter)) as client:
await client.get("https://example.com")
HTTPX2
import httpx2
from pyrate_limiter import Duration, limiter_factory
from pyrate_limiter.extras.httpx2_limiter import AsyncRateLimiterTransport, RateLimiterTransport
limiter = limiter_factory.create_inmemory_limiter(rate_per_duration=1, duration=Duration.SECOND)
with httpx2.Client(transport=RateLimiterTransport(limiter=limiter)) as client:
client.get("https://example.com")
async with httpx2.AsyncClient(transport=AsyncRateLimiterTransport(limiter=limiter)) as client:
await client.get("https://example.com")
Requests
from pyrate_limiter import Duration, limiter_factory
from pyrate_limiter.extras.requests_limiter import RateLimitedRequestsSession
limiter = limiter_factory.create_inmemory_limiter(rate_per_duration=2, duration=Duration.SECOND)
session = RateLimitedRequestsSession(limiter)
Advanced usage
Custom routing with BucketFactory
When items must be routed to different buckets (per user, per endpoint, …), implement a BucketFactory. At minimum, define wrap_item and get:
from pyrate_limiter import (
AbstractBucket, BucketFactory, RateItem, MonotonicClock,
InMemoryBucket, Rate, Duration, Limiter,
)
class SingleRouteFactory(BucketFactory):
def __init__(self, clock, bucket):
self.clock = clock
self.bucket = bucket
self.schedule_leak(bucket) # run background leaking for this bucket
def wrap_item(self, name: str, weight: int = 1) -> RateItem:
return RateItem(name, self.clock.now(), weight=weight)
def get(self, _item: RateItem) -> AbstractBucket:
return self.bucket
To create buckets on demand, use self.create(bucket_class, *args, **kwargs) — it builds the bucket and schedules its leak:
class PerNameFactory(BucketFactory):
def __init__(self, clock):
self.clock = clock
self.buckets = {}
def wrap_item(self, name: str, weight: int = 1) -> RateItem:
return RateItem(name, self.clock.now(), weight=weight)
def get(self, item: RateItem) -> AbstractBucket:
if item.name not in self.buckets:
self.buckets[item.name] = self.create(InMemoryBucket, [Rate(5, Duration.SECOND)])
return self.buckets[item.name]
Then hand the factory to a Limiter:
limiter = Limiter(SingleRouteFactory(MonotonicClock(), InMemoryBucket([Rate(5, Duration.SECOND)])))
limiter.try_acquire("the-earth")
limiter.try_acquire("the-sun", weight=100)
Custom & distributed clocks
In v4 each bucket owns its time source via bucket.now() — the Limiter no longer takes a clock= parameter. To make distributed workers agree on "now" (e.g. a shared Redis/DB clock), either override now() on a bucket subclass (works on every backend, keeps leak consistent), or assign a clock to buckets that delegate to self._clock (e.g. InMemoryBucket, PostgresBucket):
from pyrate_limiter import AbstractClock, InMemoryBucket, RedisBucket, Rate, Duration
class RedisClock(AbstractClock):
def __init__(self, redis):
self.redis = redis
def now(self) -> int:
seconds, microseconds = self.redis.time()
return seconds * 1000 + microseconds // 1000
# Option A — override now() (recommended)
class RedisTimeBucket(RedisBucket):
def now(self) -> int:
seconds, microseconds = self.redis.time()
return seconds * 1000 + microseconds // 1000
# Option B — inject a clock into a bucket that uses self._clock
bucket = InMemoryBucket([Rate(5, Duration.SECOND)])
bucket._clock = RedisClock(redis_client)
Built-in clocks: MonotonicClock (default), MonotonicAsyncClock, PostgresClock, SQLiteClock.
Leaking
Buckets shouldn't hold items forever. Each bucket implements leak(current_timestamp=None) to drop expired items, and BucketFactory.schedule_leak(bucket) runs that in the background (default interval 10 s):
factory.schedule_leak(bucket) # background leak for this bucket
Change the interval (in milliseconds) via the leak_interval property:
class MyFactory(BucketFactory):
def __init__(self, clock, buckets):
self.clock = clock
self.leak_interval = 5000 # leak every 5s
for bucket in buckets:
self.schedule_leak(bucket)
Concurrency
Locking is handled at the Limiter level. try_acquire takes a thread RLock; try_acquire_async takes a loop-local asyncio.Lock in front of the RLock; MultiprocessBucket adds a multiprocessing lock on top. (SQLiteBucket manages its own locking.)
Custom backends
Implement pyrate_limiter.AbstractBucket to add your own backend. The test suite doubles as a conformance spec:
- Fork the repo.
- Implement your bucket against
AbstractBucket. - Add a
create_buckettotests/conftest.pyand wire it into thecreate_bucketfixture. - Run the suite — if it passes, your backend is good to go.
Examples
- asyncio_ratelimit.py — rate-limiting asyncio tasks
- asyncio_decorator.py — the decorator with async functions
- httpx_ratelimiter.py — HTTPX, sync / async / multiprocess
- httpx2_ratelimiter.py — HTTPX2, sync / async / multiprocess
- in_memory_multiprocess.py — multiprocessing with an in-memory bucket
- sqlite_filelock_multiprocess.py — multiprocessing with SQLite + a file lock
- token_bucket.py — GCRA / token bucket: burst, then a steady drip
Full docs at pyratelimiter.readthedocs.io · Contributing · Changelog
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pyrate_limiter-4.5.0.tar.gz.
File metadata
- Download URL: pyrate_limiter-4.5.0.tar.gz
- Upload date:
- Size: 123.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
098345fff3a52b84dee9bcf6973f184c8b3ef8d34e1f4f781ac0773e3984598b
|
|
| MD5 |
a7feb2b3ad92730ecbf756fc72790750
|
|
| BLAKE2b-256 |
624348693393af06b9fffbaea6bb8fe03be3c3f17be5d1423dab347d1aad1dde
|
Provenance
The following attestation bundles were made for pyrate_limiter-4.5.0.tar.gz:
Publisher:
build_test.yml on vutran1710/PyrateLimiter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyrate_limiter-4.5.0.tar.gz -
Subject digest:
098345fff3a52b84dee9bcf6973f184c8b3ef8d34e1f4f781ac0773e3984598b - Sigstore transparency entry: 2650215831
- Sigstore integration time:
-
Permalink:
vutran1710/PyrateLimiter@b7845aac21832ef912c9cb908a7ffc2e102f5652 -
Branch / Tag:
refs/tags/v4.5.0 - Owner: https://github.com/vutran1710
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
build_test.yml@b7845aac21832ef912c9cb908a7ffc2e102f5652 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pyrate_limiter-4.5.0-py3-none-any.whl.
File metadata
- Download URL: pyrate_limiter-4.5.0-py3-none-any.whl
- Upload date:
- Size: 57.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e7320a715adf404cb7b2f399e05ceb59cae165275b21f7708eb1b9fd9608e91c
|
|
| MD5 |
bf19af0f410df6f343432062343dff28
|
|
| BLAKE2b-256 |
64dc02f88649feeabf4282c8c194a6f6060ca858041bab1204365b5d8ef8a95e
|
Provenance
The following attestation bundles were made for pyrate_limiter-4.5.0-py3-none-any.whl:
Publisher:
build_test.yml on vutran1710/PyrateLimiter
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pyrate_limiter-4.5.0-py3-none-any.whl -
Subject digest:
e7320a715adf404cb7b2f399e05ceb59cae165275b21f7708eb1b9fd9608e91c - Sigstore transparency entry: 2650215889
- Sigstore integration time:
-
Permalink:
vutran1710/PyrateLimiter@b7845aac21832ef912c9cb908a7ffc2e102f5652 -
Branch / Tag:
refs/tags/v4.5.0 - Owner: https://github.com/vutran1710
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
build_test.yml@b7845aac21832ef912c9cb908a7ffc2e102f5652 -
Trigger Event:
push
-
Statement type: