Skip to main content

redis-lua-py: Redis Lua scripts as real Python functions.

PyPI Python CI license

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.

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.

Install

uv add redis-lua-py

What it compiles to

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. Pass @script(header=False) to drop the comment entirely.

Keys and arguments

A parameter annotated Key becomes KEYS, in declaration order. Everything else becomes ARGV.

This distinction is not cosmetic. Redis Cluster routes a script by its declared keys, and a key smuggled in as an argument is invisible to the router — the script will execute on the wrong node. Annotate every key.

ARGV always arrives in Lua as a string. Annotating a parameter int or float wraps it in tonumber for you, so limit above is a number by the time your comparison runs.

Annotate float for arithmetic, not for a value you mean to write back unchanged. tonumber makes it a Lua number, and Lua renders a number back to text with %.14g, so a value with more significant digits than that does not come back as it went in. Annotate str and call str() at the call site when the value is only being carried.

bool encodes to "1" or "0". Paired with an int annotation that deletes the 1 if flag else 0 from the call site: pass True, and the body gets 1.

bytes is passed through untouched — see Binary values.

Scripts accept positional or keyword arguments; keyword is clearer at the call site and is what the errors suggest.

Async

The same script object works with either client. Pass a sync client and you get a value; pass an async one and you get an awaitable.

from redis.asyncio import Redis

client = Redis()
remaining = await rate_limit(client, key="user:42", limit=10, ttl=60)

Script caching, EVALSHA, and the NOSCRIPT reload are handled by redis-py's own script machinery, which this defers to rather than reimplementing.

Binding a client

Passing the client to every call gets repetitive. bind attaches one:

limiter = rate_limit.bind(client)

limiter(key="user:42", limit=10, ttl=60)
limiter(key="user:43", limit=10, ttl=60)

A bound script exposes the same .lua, .keys and .args as the original, binds async clients just as well, and leaves the unbound form working — the script itself is unchanged and still usable against any other client.

Calling Redis commands

redis.<command>(...) becomes redis.call('<COMMAND>', ...). Underscores split into subcommand tokens, so redis.script_load(x) compiles to redis.call('SCRIPT', 'LOAD', x).

redis.pcall, redis.error_reply, redis.status_reply, redis.sha1hex, redis.log and cjson.encode / cjson.decode pass through under their own names.

Names are checked, not just uppercased

Uppercasing turns any attribute into a plausible command, which makes a name Redis does not have the one mistake with nothing standing in its way: it compiles, it survives review, and it raises the first time its branch runs — inside a script whose whole purpose was to be atomic.

So command names are checked at compile time against Redis' own command table, and a miss is refused where you can see it:

Redis has no EXPIRES command
  File "/srv/app/limits.py", line 14
    redis.expires(key, 60)
    ^
  hint: Did you mean redis.expire()?

The handful of redis-py method names that do not match the wire name are translated rather than refused, because each names exactly one command and nothing else: redis.delete(k) compiles to redis.call('DEL', k). Container commands are checked down to the subcommand, and a hyphenated one is reached through its underscores — redis.client_no_evict("on") compiles to redis.call('CLIENT', 'NO-EVICT', 'on').

redis.call(...) is deliberately never checked. It is the escape hatch for module commands, which are spelled with a dot anyway, and for anything a newer server has that the table does not:

redis.call("JSON.SET", doc, "$.status", '"done"')

The table is generated from the command definitions in the Redis source — the same files the server is built from — and currently tracks Redis 8.10. Regenerate it with uv run python scripts/generate_commands.py.

When the client is imported too

Import the client class and nothing collides, because the name redis is never taken:

from redis import Redis
from redis_lua_py import Key, redis, script

If you want the client module itself, the namespace is resolved by value rather than by spelling, so import it under any name you like:

import redis  # the client
from redis_lua_py import Key, script
from redis_lua_py import redis as r  # the script namespace


@script
def claim(queue: Key, now: int) -> list[bytes]:
    return r.zrangebyscore(queue, 0, now)


client = redis.Redis()

call is also exported as an alias of redis, if you would rather rename nothing at all.

Getting this wrong is caught rather than compiled. If the name in scope turns out to be redis-py, the script is refused instead of being quietly aimed at the client library:

'redis' is bound to redis-py here, not to the script namespace
  File "/srv/app/jobs.py", line 9
    return redis.zrangebyscore(queue, 0, now)
           ^
  hint: Import the namespace under another name (from redis_lua_py import
  redis as r), or the client under another name (import redis as redis_client).

Constants from the module

A script has no closure: the body runs on the server, where nothing from your Python process exists. A module-level constant is the exception worth making, because it is already a literal and can simply be folded in.

SESSION_TTL_SECONDS = 30 * 60


@script
def touch_session(session: Key) -> int:
    hits = redis.incr(session)
    redis.expire(session, SESSION_TTL_SECONDS)  # -> redis.call('EXPIRE', session, 1800)
    return hits

int, float, str, bytes and bool are folded, including through a dotted name — an IntEnum member, or an attribute of a settings object. Anything else is refused with the same caret as everything else, because there is no literal to fold:

'SESSION_TTL' is a module-level timedelta, which has no Lua literal
  File "/srv/app/sessions.py", line 18
    redis.expire(session, SESSION_TTL)
                          ^
  hint: Only an int, float, str, bytes or bool constant is folded into the
  script. Pass anything else as an argument, or name the literal it reduces to.

The value is read once, when the module is imported and the script compiles. A name rebound afterwards does not change the script — which is what "constant" means, but worth saying out loud.

Binary values

Nothing here decodes. KEYS, ARGV and every Lua string are byte strings, so a bytes argument arrives in the script as exactly those bytes and comes back as exactly those bytes.

import zlib


@script
def cache_compressed(key: Key, blob: bytes, ttl: int) -> int:
    redis.set(key, blob)
    redis.expire(key, ttl)
    return len(blob)


cache_compressed(client, key="report:42", blob=zlib.compress(report), ttl=300)

A bytes annotation is a passthrough: no tonumber, no decoding, no round trip through text. memoryview is accepted the same way. A bytes literal in a body is emitted as numeric escapes — b"\x00\xff" becomes '\000\255' — so it survives the journey to the server, where the script itself travels as text.

This is a guarantee rather than an observation: tests/test_binary.py round-trips non-UTF-8 bytes through ARGV, through a stored value, and back out of a returned GETRANGE, against both fakeredis and a real server.

What the caller gets

The return annotation describes the caller's side: the value that comes back from Redis, not the value the body hands to Lua. The compiler does not read it at all.

It is carried through to the call, so a script is a CompiledScript[R] and rate_limit above returns an int rather than Any:

remaining = rate_limit(client, key="user:42", limit=10, ttl=60)  # int

An async client gives you Awaitable[R], so await gets you back to R. bind carries it too, on both.

Redis renders every reply as bytes, which is what to annotate — and what to write in the body when a branch needs a placeholder:

@script
def preview(doc: Key) -> list[int | bytes]:
    size = redis.strlen(doc)
    if size == 0:
        return [0, b""]
    return [1, redis.getrange(doc, 0, 1023)]

b"" and "" compile to the same Lua string; only one of them also describes what the caller receives, which keeps the body and the signature agreeing about the same thing.

Inside a body, every value that came from Redis is Any — nothing about redis.get(k) is knowable ahead of time. Under mypy --strict that makes warn_return_any fire on a body that returns a command result directly, on the one function whose body Python never runs. Turn it off for the module your scripts live in:

[[tool.mypy.overrides]]
module = "myapp.scripts"
warn_return_any = false

The supported subset

Supported: assignment, augmented assignment, if/elif/else, for ... in over a table or range(), while, break, return, comparisons, arithmetic, f-strings, list and dict literals, len(), .append(), int(), float(), str(), min(), max(), abs(), module-level constants, and calls into redis and cjson.

Everything else raises UnsupportedSyntax when the module is imported, with a caret under the line at fault:

'and'/'or' are only supported in an if or while condition
  File "/srv/app/limits.py", line 12
    flag = a and b
           ^
  hint: In Python these return an operand, which does not survive the
  difference in truthiness. Use an if statement instead.

Failing at import, loudly, is deliberate. A body that looks like Python but is never run by Python is exactly where a quiet mistranslation would cost the most.

Where Lua differs from Python

These are the gaps that matter. Most are closed for you; the rest are refused.

Truthiness is closed. Lua counts 0 and '' as true. Any condition that is not already a boolean is routed through a generated __truthy helper, so if count: means what it means in Python.

Missing values are closed. A Redis command with nothing to return hands Lua false, not nil. This is the classic trap: a hand-written == nil never matches, so the branch silently never runs. x is None compiles to a helper accepting both, which also takes x as an argument — so if redis.hget(k, f) is None: does not run the command twice.

Indexing is closed. Lua tables are 1-based. items[0] compiles to items[1]. Write Python indices and let the compiler shift them. Negative indices are refused, because Lua has no equivalent.

Assignment scope is closed. Python scopes a name to the whole function; Lua's local scopes it to the enclosing block. A name assigned inside an if and read after it is hoisted to the top of the script, so it does not silently read back nil.

+ is arithmetic, not concatenation. Use an f-string, which compiles to Lua's ...

and / or work only in conditions. In Python they return an operand, not a boolean, and that does not survive the truthiness difference. Use an if.

There is no continue. Lua 5.1 does not have one. Invert the condition and nest the rest of the body.

A loop variable does not outlive its loop, unlike in Python.

A nil inside a returned table truncates the reply. Redis converts a returned array by walking it from the first element and stopping at the first nil, so the caller gets a shorter list rather than a null in the middle of one.

Which values are actually nil is the part worth being exact about. A command with nothing to return hands Lua false, and false converts to a null element without ending the array — return [1, redis.get(missing), 3] really does reach the caller as [1, None, 3]. What truncates is a genuine nil, and in practice that means a name that was not assigned on this path. The compiler warns where it can see one:

'first' is not assigned on every path to this return, and a nil in a returned
table truncates the reply there
  File "/srv/app/queue.py", line 31
    return [1, first, count]
               ^
  hint: Give it a value before the branch, so that every branch returns a
  table of the same shape.

and refuses a None written out in the table, since that one is never what anybody meant. Silence the warning with warnings.filterwarnings("ignore", category=NilTruncationWarning) if your script really does mean to stop there.

Return values follow Redis' own conversion rules: True becomes 1, False and None become nil, floats are truncated to integers. Return a string, or cjson.encode(...), when you need one preserved exactly.

A larger example

@script
def claim_jobs(queue: Key, processing: Key, now: int, limit: int) -> list[bytes]:
    """Atomically move due jobs from a sorted set into a processing hash."""
    ids = redis.zrangebyscore(queue, 0, now, "LIMIT", 0, limit)
    claimed = []
    for job_id in ids:
        if redis.zrem(queue, job_id) == 1:
            redis.hset(processing, job_id, now)
            claimed.append(job_id)
    return claimed
local queue = KEYS[1]
local processing = KEYS[2]
local now = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local ids = redis.call('ZRANGEBYSCORE', queue, 0, now, 'LIMIT', 0, limit)
local claimed = {}
for __i1 = 1, #ids do
  local job_id = ids[__i1]
  if redis.call('ZREM', queue, job_id) == 1 then
    redis.call('HSET', processing, job_id, now)
    claimed[#claimed + 1] = job_id
  end
end
return claimed

Testing your scripts

Replacing working Lua in a production path needs evidence. Two things supply most of it, and both are short.

Snapshot the Lua. .lua is the whole script, so a golden test is a string comparison — and the diff against the Lua you are replacing is the review.

from pathlib import Path

GOLDEN = Path(__file__).parent / "golden" / "rate_limit.lua"


def test_generated_lua_is_unchanged():
    assert rate_limit.lua == GOLDEN.read_text()

The header path is repo-relative, so this is stable across machines and CI. Use @script(header=False) if you would rather compare the body alone.

Run the behaviour, without a server. 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. This library's own suite runs that way and against a real Redis in CI, and the two agree — including on reply conversion, which is the part you would most want a real server for.

Development

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

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.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

redis_lua_py-0.2.0.tar.gz (164.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

redis_lua_py-0.2.0-py3-none-any.whl (35.7 kB view details)

Uploaded Python 3

File details

Details for the file redis_lua_py-0.2.0.tar.gz.

File metadata

  • Download URL: redis_lua_py-0.2.0.tar.gz
  • Upload date:
  • Size: 164.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for redis_lua_py-0.2.0.tar.gz
Algorithm Hash digest
SHA256 878b921bbc7e2b5b8ee36d6aefe246f9f8ce368cc0b4166763993185f289ab7c
MD5 aecfe1a40aba94e91c8afb863ee64e4a
BLAKE2b-256 8ec9953bacb9a8735c8172329054df74bb59964e02d81deeebd3e7c73eadd404

See more details on using hashes here.

Provenance

The following attestation bundles were made for redis_lua_py-0.2.0.tar.gz:

Publisher: release.yml on IgnaceMaes/redis-lua-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file redis_lua_py-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: redis_lua_py-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 35.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for redis_lua_py-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0b90b6620591dd65158375783e1e00b46c5b17456fc0814c2594bc06b425fcbc
MD5 9946922ef671d0749b2342316c8d87c9
BLAKE2b-256 b3c37d0532bb908f94d52b70265d5d91b0c096ae3c622c42cbd651ac02d4f05a

See more details on using hashes here.

Provenance

The following attestation bundles were made for redis_lua_py-0.2.0-py3-none-any.whl:

Publisher: release.yml on IgnaceMaes/redis-lua-py

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.2.1

2 files

This release

0.2.0 This release

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page