Write Redis Lua scripts as real Python functions, not strings.
1
stars
6
commits
Python
primary language
Sep 12, 2026
updated
Write Redis Lua scripts as real Python functions, not as strings.
Compiled at import, checked by mypy, sent with EVALSHA. Sync and async redis-py.
Documentation · Quickstart · API reference · Changelog
from redis_lua_py import Key, redis, script
@script
def rate_limit(key: Key, limit: int, ttl: int) -> int:
current = redis.incr(key)
if current == 1:
redis.expire(key, ttl)
if current > limit:
return -1
return limit - current
The body is never executed by Python. It is read as source when the module is
imported, compiled to Lua, and sent to Redis with EVALSHA. Your editor
highlights it, your linter sees it, and mypy checks the signature — none of
which is true of a string.
Define scripts at module level, where they compile once at import. A script
defined inside a function recompiles on every call, and one defined through
exec has no source to read and is refused.
from redis import Redis
client = Redis()
remaining = rate_limit(client, key="user:42", limit=10, ttl=60)
Importing the client as from redis import Redis leaves the name redis free
for the script namespace, so the two never collide.
uv add redis-lua-py
Python 3.11+, and redis-py 5.0+ as the only dependency.
Nothing is hidden. Every script exposes the Lua it produced:
>>> print(rate_limit.lua)
-- rate_limit
-- Generated by redis-lua-py from src/limits.py:6. Do not edit.
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, ttl)
end
if current > limit then
return -1
end
return limit - current
Read it in review, paste it into redis-cli, check it into a golden test. The
point of this library is to generate Lua you would have been willing to write.
The header is part of the body, and the body is what EVALSHA hashes, so the
path in it is relative to your project root rather than absolute — the same
script has the same SHA on a laptop, in CI and in a container, and the server's
script cache is cold once per script rather than once per environment.
Key becomes KEYS, which is what Redis Cluster
routes on; an int or float is wrapped in tonumber for you.redis.expires(...) is
refused where you can see it rather than raised inside a script whose whole
purpose was to be atomic.int, float, str, bytes or bool is read once, at
import, and written into the script as a literal.bytes is a passthrough in both directions.CompiledScript[R], and an async client gives you
Awaitable[R].bind when
passing the client every time gets repetitive.false versus nil,
block scope, and the nil that truncates a returned table.Full documentation: ignacemaes.com/redis-lua-py.
fakeredis embeds a real Lua interpreter, so your script executes for real against an in-process server:
import fakeredis
def test_rate_limit_refuses_past_the_limit():
client = fakeredis.FakeRedis()
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1
Install it with uv add --dev "fakeredis[lua]"; the lua extra is what brings
the interpreter. .lua is the whole script, so a golden snapshot is a string
comparison — see
Testing your scripts.
uv sync
uv run pytest
uv run ruff check
uv run mypy
Tests run against fakeredis, which
executes real Lua, so uv run pytest needs no server. Set REDIS_URL to also
run them against a live Redis:
REDIS_URL=redis://localhost:6379/0 uv run pytest
src/redis_lua_py/_commands.py is generated from the Redis source. Refresh it
when a Redis release adds commands:
uv run python scripts/generate_commands.py 8.10.1
The docs site is built with Zensical; uv run zensical serve previews it with live reload.
Pull requests are squash-merged and their titles must follow Conventional Commits: the title becomes the changelog entry and decides the version bump. See CONTRIBUTING.md.
MIT
3 commits
3 commits
Python
100.0%
Write Redis Lua scripts as real Python functions, not strings.
1
stars
6
commits
Python
primary language
Sep 12, 2026
updated
Write Redis Lua scripts as real Python functions, not as strings.
Compiled at import, checked by mypy, sent with EVALSHA. Sync and async redis-py.
Documentation · Quickstart · API reference · Changelog
from redis_lua_py import Key, redis, script
@script
def rate_limit(key: Key, limit: int, ttl: int) -> int:
current = redis.incr(key)
if current == 1:
redis.expire(key, ttl)
if current > limit:
return -1
return limit - current
The body is never executed by Python. It is read as source when the module is
imported, compiled to Lua, and sent to Redis with EVALSHA. Your editor
highlights it, your linter sees it, and mypy checks the signature — none of
which is true of a string.
Define scripts at module level, where they compile once at import. A script
defined inside a function recompiles on every call, and one defined through
exec has no source to read and is refused.
from redis import Redis
client = Redis()
remaining = rate_limit(client, key="user:42", limit=10, ttl=60)
Importing the client as from redis import Redis leaves the name redis free
for the script namespace, so the two never collide.
uv add redis-lua-py
Python 3.11+, and redis-py 5.0+ as the only dependency.
Nothing is hidden. Every script exposes the Lua it produced:
>>> print(rate_limit.lua)
-- rate_limit
-- Generated by redis-lua-py from src/limits.py:6. Do not edit.
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, ttl)
end
if current > limit then
return -1
end
return limit - current
Read it in review, paste it into redis-cli, check it into a golden test. The
point of this library is to generate Lua you would have been willing to write.
The header is part of the body, and the body is what EVALSHA hashes, so the
path in it is relative to your project root rather than absolute — the same
script has the same SHA on a laptop, in CI and in a container, and the server's
script cache is cold once per script rather than once per environment.
Key becomes KEYS, which is what Redis Cluster
routes on; an int or float is wrapped in tonumber for you.redis.expires(...) is
refused where you can see it rather than raised inside a script whose whole
purpose was to be atomic.int, float, str, bytes or bool is read once, at
import, and written into the script as a literal.bytes is a passthrough in both directions.CompiledScript[R], and an async client gives you
Awaitable[R].bind when
passing the client every time gets repetitive.false versus nil,
block scope, and the nil that truncates a returned table.Full documentation: ignacemaes.com/redis-lua-py.
fakeredis embeds a real Lua interpreter, so your script executes for real against an in-process server:
import fakeredis
def test_rate_limit_refuses_past_the_limit():
client = fakeredis.FakeRedis()
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 1
assert rate_limit(client, key="u:42", limit=2, ttl=60) == 0
assert rate_limit(client, key="u:42", limit=2, ttl=60) == -1
Install it with uv add --dev "fakeredis[lua]"; the lua extra is what brings
the interpreter. .lua is the whole script, so a golden snapshot is a string
comparison — see
Testing your scripts.
uv sync
uv run pytest
uv run ruff check
uv run mypy
Tests run against fakeredis, which
executes real Lua, so uv run pytest needs no server. Set REDIS_URL to also
run them against a live Redis:
REDIS_URL=redis://localhost:6379/0 uv run pytest
src/redis_lua_py/_commands.py is generated from the Redis source. Refresh it
when a Redis release adds commands:
uv run python scripts/generate_commands.py 8.10.1
The docs site is built with Zensical; uv run zensical serve previews it with live reload.
Pull requests are squash-merged and their titles must follow Conventional Commits: the title becomes the changelog entry and decides the version bump. See CONTRIBUTING.md.
MIT
3 commits
3 commits
Python
100.0%