A Redis transaction is a connection-scoped command queue followed by an isolated execution window. MULTI starts the queue; EXEC runs accepted commands in order. The useful guarantee is that another client’s commands cannot run between those operations. It is not a promise to undo successful writes when a later command fails.
Admission and execution are different
A QUEUED reply means the server accepted a command for later execution, not that its intended write succeeded. Redis checks command existence and argument count while accepting the queue. Errors dependent on the current value, such as a list operation against a string, can instead appear during EXEC. Calling both categories syntax errors obscures their different outcomes.
A GET inside the queue also returns QUEUED immediately. Its actual result arrives in the EXEC response array. Application code cannot inspect that result and choose which later command to queue in the same transaction. Use WATCH for a decision made in the client, or a bounded server-side script for branching that must happen with the reads and writes.
Client-side buffering adds another stage. In redis-py, ordinary transactional pipeline methods collect commands locally; execute sends MULTI, the commands, and EXEC. Receiving a pipeline object from set is not a server acknowledgement. The exercise uses a reserved connection and raw command responses to expose the actual protocol stages.
Isolation starts at execution
MULTI does not reserve the keys against competing writers. While one client is still constructing its queue, another can update those keys. A queued GET observes the value when it executes, not a snapshot captured when MULTI arrived. A later queued GET also sees earlier writes from its own transaction.
Keep the entire transaction on one connection and do not share that connection with unrelated work. A pool that independently assigns connections to MULTI and EXEC would not preserve transaction state. Prefer the client’s transaction abstraction in application code; direct connection access here is an observation technique for the pinned client version.
When it breaks
A wrong argument count while queueing marks the transaction for rejection. EXEC then returns EXECABORT and none of the queued writes run. DISCARD explicitly removes an unexecuted queue. Neither action is an undo facility for commands already executed.
A runtime WRONGTYPE response behaves differently: the failing operation reports an error in its position, while earlier successful writes remain and later commands still run. Inspect every response before describing the business action as successful. A client exception does not establish that the server stopped at the offending command or reverted the batch.
With redis-py’s normal execute call, the client can raise a ResponseError after processing the transaction response. Disabling raise_on_error exposes execution errors as response elements, not as rollback. Transport failure is a separate case: losing the EXEC reply can leave the outcome unknown. Blindly retrying a non-idempotent transaction can duplicate effects.
Keep transactions short because their execution delays other clients. This isolation guarantee also does not select persistence, replication, or failover guarantees. A successful response is an execution result, not proof that an acknowledged write will survive every operational failure.
Check it quickly
Use a POSIX environment with a C compiler, make, curl, tar, and Python 3.10 or newer. This ran on macOS with Python 3.14.4, Redis 8.10.1, and redis-py 8.1.0. The setup compiles Redis core with libc and installs the client in a temporary virtual environment; it does not install a system service.
redis_lab=$(mktemp -d)
curl --fail --location --output "$redis_lab/redis.tar.gz" https://download.redis.io/releases/redis-8.10.1.tar.gz
tar -xzf "$redis_lab/redis.tar.gz" -C "$redis_lab"
make -C "$redis_lab/redis-8.10.1" build redis -j2 MALLOC=libc
export REDIS_SERVER="$redis_lab/redis-8.10.1/src/redis-server"
python3 -m venv "$redis_lab/venv"
"$redis_lab/venv/bin/python" -m pip install redis==8.1.0
Run the Python block with "$redis_lab/venv/bin/python" in the same shell. It starts Redis on a private Unix socket under /tmp, with TCP and persistence disabled. Cleanup stops only that process and removes its runtime directory. Downloads need network access; no existing Redis instance is contacted.
from contextlib import contextmanager
import os
from pathlib import Path
import subprocess
from tempfile import TemporaryDirectory
import time
import redis
from redis.backoff import NoBackoff
from redis.retry import Retry
@contextmanager
def instance():
with TemporaryDirectory(prefix="redis-lab-", dir="/tmp") as directory:
endpoint = str(Path(directory) / "redis.sock")
process = subprocess.Popen([
os.environ["REDIS_SERVER"], "--port", "0",
"--unixsocket", endpoint, "--unixsocketperm", "700",
"--save", "", "--appendonly", "no", "--dir", directory,
"--maxmemory-policy", "noeviction",
], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
client = redis.Redis(unix_socket_path=endpoint, decode_responses=False,
socket_timeout=2, retry=Retry(NoBackoff(), 0))
try:
deadline = time.monotonic() + 5
while True:
assert process.poll() is None, "Redis exited during startup"
try:
if client.ping():
break
except redis.ConnectionError:
if time.monotonic() >= deadline:
raise
time.sleep(0.02)
yield client
finally:
client.close()
if process.poll() is None:
process.terminate()
try:
_, errors = process.communicate(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.communicate()
raise
assert process.returncode == 0, errors.decode()
def command(connection, *arguments):
connection.send_command(*arguments)
return connection.read_response()
with instance() as client, client.client() as queued:
connection = queued.connection
client.set("value", "initial")
assert command(connection, "MULTI") == b"OK"
assert command(connection, "GET", "value") == b"QUEUED"
assert command(connection, "SET", "value", "own-write") == b"QUEUED"
assert command(connection, "GET", "value") == b"QUEUED"
assert client.get("value") == b"initial"
client.set("value", "other-client")
assert command(connection, "EXEC") == [b"other-client", b"OK", b"own-write"]
assert client.get("value") == b"own-write"
print("QUEUED does not execute; EXEC reads current state and then its own writes")
assert command(connection, "MULTI") == b"OK"
assert command(connection, "SET", "discarded", "yes") == b"QUEUED"
assert command(connection, "DISCARD") == b"OK"
assert client.get("discarded") is None
print("DISCARD removes the unexecuted queue")
assert command(connection, "MULTI") == b"OK"
assert command(connection, "SET", "before-arity", "yes") == b"QUEUED"
try:
command(connection, "SET", "missing-value")
except redis.ResponseError as error:
assert "wrong number of arguments" in str(error)
else:
raise AssertionError("invalid SET was accepted")
assert command(connection, "SET", "after-arity", "yes") == b"QUEUED"
try:
command(connection, "EXEC")
except redis.exceptions.ExecAbortError:
pass
else:
raise AssertionError("queue error did not abort EXEC")
assert client.mget("before-arity", "after-arity") == [None, None]
print("wrong arity fails during queueing; EXECABORT applies none of the queue")
client.set("wrong-type", "string")
assert command(connection, "MULTI") == b"OK"
assert command(connection, "SET", "before-runtime", "yes") == b"QUEUED"
assert command(connection, "LPUSH", "wrong-type", "item") == b"QUEUED"
assert command(connection, "SET", "after-runtime", "yes") == b"QUEUED"
responses = command(connection, "EXEC")
assert len(responses) == 3
assert responses[0] == responses[2] == b"OK"
assert isinstance(responses[1], redis.ResponseError)
assert "WRONGTYPE" in str(responses[1])
assert client.mget("before-runtime", "wrong-type", "after-runtime") == [
b"yes", b"string", b"yes",
]
print("runtime error occupies one reply; both surrounding writes remain applied")
The first assertion sequence queues GET, SET, and GET. A different connection changes the value before EXEC, so the replies contain other-client, OK, and own-write. Before EXEC, the queued SET has changed nothing. Together these observations distinguish deferred execution from a frozen snapshot and demonstrate reads of the transaction’s own writes.
The DISCARD case leaves its marker absent. The malformed SET then fails at queue admission, and EXECABORT leaves both surrounding markers absent even though their commands received QUEUED. Checking actual keys is important: an early error message alone would not prove the rest of the queue was discarded.
Finally, LPUSH targets an existing string. EXEC returns three elements: success, WRONGTYPE, success. Both surrounding markers remain written and the string remains unchanged. Four printed summaries follow assertions on responses and stored values; they are not assumed output.
This local exercise demonstrates the two error phases, explicit cancellation, and execution-time reads. It does not reproduce a lost connection, crash recovery, cluster routing, or durability guarantees. Those boundaries require separate tests before this mechanism is used for an irreversible business operation.
Sources
- Redis transactions, queue admission, execution isolation, and error handling, checked 2026-09-08.
- Redis MULTI, connection-scoped transaction entry, checked 2026-09-08.
- Redis EXEC, ordered replies and watched-key aborts, checked 2026-09-08.
- Redis DISCARD, discarding queued commands, checked 2026-09-08.
- redis-py 8.1.0 client implementation, pipeline buffering and response processing, checked 2026-09-08.
- Redis 8.10.1 build instructions, core-only compilation and allocator selection, checked 2026-09-08.