Skip to main content
Technology & EngineeringRedis197 lines

Lua Scripting

Lua scripting in Redis for atomic multi-step operations

Quick Summary14 lines
You are an expert in writing and managing Lua scripts in Redis for atomic, server-side operations that cannot be achieved with individual commands.

## Key Points

- **Always pass keys via the KEYS array.** This is mandatory for Redis Cluster compatibility, which hashes keys to determine the correct shard.
- **Keep scripts short and fast.** Scripts block the entire Redis server. Target sub-millisecond execution. Move complex logic to application code when atomicity is not required.
- **Use `defineCommand` (ioredis) or `register_script` (redis-py).** These cache the script SHA and fall back to `EVAL` transparently, saving bandwidth on repeated calls.
- **Return structured data as JSON strings** from Lua via `cjson.encode()` when the result is complex. Redis Lua returns are limited to strings, integers, and arrays.
- **Test scripts with `redis.log()`** during development. Log output appears in the Redis server log.
- **Accessing keys not declared in KEYS.** The script will work on a single Redis instance but break on Cluster, where undeclared keys may live on a different shard.
- **Assuming Lua number precision.** Lua 5.1 (embedded in Redis) uses double-precision floats. Integers above 2^53 lose precision. Pass large numbers as strings and handle them accordingly.
- **Forgetting `tonumber()` on ARGV values.** All ARGV entries arrive as strings in Lua. Arithmetic on strings silently produces wrong results or errors.
skilldb get redis-skills/Lua ScriptingFull skill: 197 lines
Paste into your CLAUDE.md or agent config

Lua Scripting — Redis

You are an expert in writing and managing Lua scripts in Redis for atomic, server-side operations that cannot be achieved with individual commands.

Core Philosophy

Overview

Redis executes Lua scripts atomically on the server. While a script runs, no other command is processed, giving scripts the same isolation guarantees as a MULTI/EXEC transaction but with conditional logic, loops, and computed values. Lua scripting is the primary tool for implementing complex atomic operations that go beyond what pipelines and transactions offer.

Core Concepts

EVAL and EVALSHA

EVAL sends the full script text to Redis. EVALSHA sends only the SHA1 hash of a previously loaded script, saving bandwidth. ioredis supports defineCommand which handles SHA caching automatically.

KEYS and ARGV

Scripts receive two arrays: KEYS (the Redis keys the script will access) and ARGV (additional arguments). Always pass keys via the KEYS array so Redis Cluster can route the command to the correct shard.

Atomicity Guarantee

Redis is single-threaded for command execution. A Lua script runs to completion without interleaving, making it impossible for another client to observe an intermediate state.

Script Limits

Scripts must complete within the lua-time-limit (default 5 seconds). After this threshold, Redis starts accepting SCRIPT KILL and other limited commands but does not terminate the script automatically. Long-running scripts block all other operations.

Implementation Patterns

Compare-and-swap

import Redis from "ioredis";
const redis = new Redis();

// Atomic compare-and-swap: set key to newValue only if current value is oldValue
const casScript = `
  local current = redis.call('GET', KEYS[1])
  if current == ARGV[1] then
    redis.call('SET', KEYS[1], ARGV[2])
    return 1
  end
  return 0
`;

const swapped = await redis.eval(casScript, 1, "config:version", "v1", "v2");
// Returns 1 if swap succeeded, 0 if current value was not "v1"

Atomic transfer between two keys

const transferScript = `
  local from = tonumber(redis.call('GET', KEYS[1]) or '0')
  local amount = tonumber(ARGV[1])
  if from < amount then
    return -1  -- insufficient funds
  end
  redis.call('DECRBY', KEYS[1], amount)
  redis.call('INCRBY', KEYS[2], amount)
  return 1
`;

const result = await redis.eval(transferScript, 2, "balance:alice", "balance:bob", "50");
if (result === -1) {
  console.log("Insufficient funds");
}

Using defineCommand for reusable scripts

const redis = new Redis();

// Register a custom command
redis.defineCommand("acquireLock", {
  numberOfKeys: 1,
  lua: `
    if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
      return 1
    end
    return 0
  `,
});

redis.defineCommand("releaseLock", {
  numberOfKeys: 1,
  lua: `
    if redis.call('GET', KEYS[1]) == ARGV[1] then
      return redis.call('DEL', KEYS[1])
    end
    return 0
  `,
});

// Use the custom commands
const lockId = crypto.randomUUID();
const acquired = await (redis as any).acquireLock("lock:resource:42", lockId, "30000");

if (acquired) {
  try {
    await doWork();
  } finally {
    await (redis as any).releaseLock("lock:resource:42", lockId);
  }
}

Sliding window counter

const slidingWindowScript = `
  local key = KEYS[1]
  local now = tonumber(ARGV[1])
  local window = tonumber(ARGV[2])
  local limit = tonumber(ARGV[3])

  -- Remove entries outside the window
  redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

  -- Count remaining entries
  local count = redis.call('ZCARD', key)

  if count < limit then
    -- Add this request
    redis.call('ZADD', key, now, now .. '-' .. math.random(1000000))
    redis.call('PEXPIRE', key, window)
    return 1  -- allowed
  end

  return 0  -- rate limited
`;

async function checkRateLimit(userId: string, windowMs: number, maxRequests: number): Promise<boolean> {
  const result = await redis.eval(
    slidingWindowScript, 1,
    `ratelimit:${userId}`,
    String(Date.now()), String(windowMs), String(maxRequests)
  );
  return result === 1;
}

Bulk conditional update

const bulkUpdateScript = `
  local updated = 0
  for i = 1, #KEYS do
    local current = redis.call('GET', KEYS[i])
    if current == ARGV[1] then
      redis.call('SET', KEYS[i], ARGV[2])
      updated = updated + 1
    end
  end
  return updated
`;

// Update all keys from "pending" to "active"
const keys = ["job:1", "job:2", "job:3", "job:4"];
const count = await redis.eval(bulkUpdateScript, keys.length, ...keys, "pending", "active");
console.log(`Updated ${count} jobs`);

Best Practices

  • Always pass keys via the KEYS array. This is mandatory for Redis Cluster compatibility, which hashes keys to determine the correct shard.
  • Keep scripts short and fast. Scripts block the entire Redis server. Target sub-millisecond execution. Move complex logic to application code when atomicity is not required.
  • Use defineCommand (ioredis) or register_script (redis-py). These cache the script SHA and fall back to EVAL transparently, saving bandwidth on repeated calls.
  • Return structured data as JSON strings from Lua via cjson.encode() when the result is complex. Redis Lua returns are limited to strings, integers, and arrays.
  • Test scripts with redis.log() during development. Log output appears in the Redis server log.

Common Pitfalls

  • Accessing keys not declared in KEYS. The script will work on a single Redis instance but break on Cluster, where undeclared keys may live on a different shard.
  • Non-deterministic operations. Scripts that call TIME, RANDOMKEY, or use math.random() without seeding can behave unpredictably in replication scenarios. Redis 7+ relaxed some restrictions with script flags, but be cautious.
  • Exceeding lua-time-limit. A script that runs too long triggers a "BUSY" error for all other clients. There is no automatic kill; an admin must run SCRIPT KILL or SHUTDOWN NOSAVE if the script performed writes.
  • Assuming Lua number precision. Lua 5.1 (embedded in Redis) uses double-precision floats. Integers above 2^53 lose precision. Pass large numbers as strings and handle them accordingly.
  • Forgetting tonumber() on ARGV values. All ARGV entries arrive as strings in Lua. Arithmetic on strings silently produces wrong results or errors.

Anti-Patterns

Over-engineering for hypothetical scale. Building for millions of users when you have hundreds adds complexity without value. Solve today's problems first.

Ignoring the existing ecosystem. Reinventing functionality that mature libraries already provide well wastes time and introduces unnecessary risk.

Premature abstraction. Creating elaborate frameworks and utilities before you have enough concrete cases to know what the abstraction should look like produces the wrong abstraction.

Neglecting error handling at boundaries. Internal code can trust its inputs, but system boundaries (user input, APIs, file I/O) require defensive validation.

Skipping documentation for obvious code. What is obvious to you today will not be obvious to your colleague next month or to you next year.

Install this skill directly: skilldb add redis-skills

Get CLI access →