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.

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 /srv/app/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.

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.

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.

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[str]:
    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).

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(), 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.

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[str]:
    """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

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

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.1.0.tar.gz (143.5 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.1.0-py3-none-any.whl (23.4 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: redis_lua_py-0.1.0.tar.gz
  • Upload date:
  • Size: 143.5 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.1.0.tar.gz
Algorithm Hash digest
SHA256 4893532998978bb8b96a73641a7024420b23f21e2d0104abab4a6ee7d6a57a7e
MD5 d485f280e2686b5db15a4ff8d1034bdd
BLAKE2b-256 9895598777669a293f74fc9f2d6febdbe429c38e9b91e7e12009c853170da557

See more details on using hashes here.

Provenance

The following attestation bundles were made for redis_lua_py-0.1.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.1.0-py3-none-any.whl.

File metadata

  • Download URL: redis_lua_py-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 23.4 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.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 bc458772083467c07ecd5752d7b81eaee22053b1f4de9f34c19b8cafbd88a285
MD5 b5ac8f46e975e54c2fe394f7e4b2b66e
BLAKE2b-256 7e72f92f10076995be4c9a8aeaee086d9974e39b545cefa3c6207fe2cfc63d1f

See more details on using hashes here.

Provenance

The following attestation bundles were made for redis_lua_py-0.1.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

0.2.0

2 files

This release

0.1.0 This release

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