Skip to content

Which storage backend?

The storage= string is the one decision that matters at setup. Everything else has a working default.

Pick by deployment shape

memory
One process, nothing to keep
  • Scripts, notebooks, tests
  • A single long-running worker
  • No files, no services, no setup
  • State dies with the process
sqlite / redis
Restarts or multiple workers
  • SQLite: one machine, quota survives restarts
  • Redis: many workers or many machines, one shared quota
  • Cron jobs that must not re-spend the quota
  • Anything behind Gunicorn, Celery, or a job queue

The full comparison

memory sqlite:///file.db redis://host:6379/0
Survives restart โŒ โœ… โœ…
Shared across processes โŒ โœ… same machine โœ…
Shared across machines โŒ โŒ โœ…
External service none none Redis server
Extra install none none pip install smartratelimit[redis]
Per-call cost in-process dict file write + fsync one network round trip
Concurrency model RLock BEGIN IMMEDIATE transaction Server-side Lua script
Token consumption atomic atomic atomic

Choosing in one question

Can two things be calling this API at the same time, or after a restart?

  • No โ†’ memory. Don't pay for durability you won't read.
  • Yes, on one machine โ†’ sqlite:///ratelimit.db. Zero setup, and the CLI can read the same file.
  • Yes, across machines โ†’ redis://.... The only backend where "the limit" genuinely means one limit.

Things worth knowing before you commit

SQLite writes on every request. Each request reads and writes rows and commits. For a job doing a few requests per second that's invisible; for a tight loop against a very high quota it isn't free. If you're paced at 1000 requests/hour anyway, this never matters.

Token accounting is atomic on every backend. Consuming a token is one indivisible step inside the store, not a read in the client followed by a write: MemoryStorage does it under its lock, SQLiteStorage inside a BEGIN IMMEDIATE write transaction, RedisStorage in a server-side Lua script using the Redis clock. Two workers cannot both spend the same token, so the shared quota is exact rather than approximate. (Before 0.4.0 this was a client-side read-modify-write and workers did drift.)

SQLite serialises writers. One writer holds the database lock at a time. Connections use WAL and a five-second busy timeout, so concurrent limiters queue rather than erroring โ€” but a very high quota driven by many processes will feel that queue. That is the cost of the count being exact.

Redis keys expire on their own. Rate limits get a TTL of their window plus an hour; token buckets get 24 hours. Nothing to clean up.

Failure is a choice you make. By default an unreachable Redis fails open: the request goes out unpaced and a warning is logged, so a limiter outage cannot take your job down with it. When the limit guards something costly โ€” a paid API quota โ€” that is the wrong trade, and fail_closed=True raises StorageUnavailable instead. Redis is pinged at construction rather than on your first real request, because redis-py connects lazily and would otherwise look healthy right up until it silently stopped limiting.

Connection strings

RateLimiter(storage="memory")                                  # default

RateLimiter(storage="sqlite:///ratelimit.db")                  # relative path
RateLimiter(storage="sqlite:////var/lib/app/ratelimit.db")     # absolute path (4 slashes)
RateLimiter(storage="sqlite:///:memory:")                      # in-process SQLite

RateLimiter(storage="redis://localhost:6379/0")
RateLimiter(storage="redis://:password@localhost:6379/0")
RateLimiter(storage="redis://user:password@redis.internal:6379/1")

Anything else raises ValueError. Working with a backend directly โ€” a custom key prefix, a pre-built client โ€” is covered in Storage Backends.