An append-only file records Redis mutations for recovery, but choosing AOF is not the same as promising that no acknowledged write can ever disappear. Durability depends on the fsync policy, effective configuration, storage behavior, and which failure you mean. Start with a recovery objective rather than the reassuring word “log.”

Execution, logging, and acknowledgment

Redis executes commands against its in-memory dataset and then propagates their effects to AOF. This is not a rule that each command must reach disk before memory changes. With appendfsync always, Redis synchronizes appended data before sending replies; commands processed together may share a write and fsync. A lost connection still leaves the client uncertain about an operation whose reply never arrived.

The file is a recovery representation, not a permanent audit trail of every original request. Redis can transform commands for persistence, and rewriting retains the current dataset rather than its entire history. A deleted value is not guaranteed to remain recoverable from a rewritten log.

Choose the synchronization contract

appendfsync everysec schedules synchronization approximately once per second. Its usual tradeoff is roughly one second of exposed writes, not a universal loss bound under arbitrary storage stalls. appendfsync no leaves flushing to the operating system; it means Redis does not request fsync, not that data never reaches disk. Neither policy makes every successful reply evidence of durable storage.

appendfsync always offers a stronger local acknowledgment contract at a latency and throughput cost, but it cannot protect against losing the disk, a faulty storage stack, or deleting the wrong data. Also inspect no-appendfsync-on-rewrite: enabling it suspends normal fsync during background persistence work, weakening durability even when the main policy says always.

Rewriting creates a recoverable file set

Since Redis 7, AOF uses a manifest plus a base file and incremental files. With aof-use-rdb-preamble enabled, the base uses RDB format; later mutations live in incremental AOF files. That base belongs to AOF and is not the independently scheduled dump.rdb snapshot.

BGREWRITEAOF starts or schedules background work; its successful reply does not certify completion. Inspect INFO persistence for the completed rewrite and its status. Copying one conveniently named file, or racing a rewrite while copying the directory, can produce an unusable backup. Preserve a consistent manifest and its referenced active files.

When it breaks

Disk errors, exhausted space, and rewrite failures require monitoring, not merely an appendonly yes setting. Check aof_last_write_status and aof_last_bgrewrite_status alongside disk capacity, rewrite state, and application errors. Background work still consumes I/O and copy-on-write memory; “background” does not mean zero latency impact.

Truncated tails and corruption in the middle have different recovery semantics. Preserve the original artifact before inspection or repair: a repair can discard subsequent commands. AOF also replays application mistakes, so maintain independently retained backups. Do not turn a process-restart demonstration into a claim about power-loss safety, replication acknowledgment, or exactly-once retries.

Check it quickly

Use a POSIX machine with a C compiler, make, curl, tar, and Python 3.10 or newer. This exercise ran on macOS with Redis 8.10.1, redis-py 8.1.0, and Python 3.14.4. The setup builds Redis core with libc and installs the client in a temporary virtual environment.

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 creates a private Unix-socket server, without TCP, scheduled snapshots, or automatic AOF rewrites. AOF uses always and keeps fsync enabled during rewriting. Only the subprocess it creates is killed. Persistence files survive that process restart inside the temporary directory, which is removed after the checks.

from contextlib import contextmanager
import os
from pathlib import Path
import signal
import subprocess
from tempfile import TemporaryDirectory
import time

import redis
from redis.backoff import NoBackoff
from redis.retry import Retry


@contextmanager
def instance(directory, aof):
    endpoint = str(Path(directory) / "redis.sock")
    process = subprocess.Popen([
        os.environ["REDIS_SERVER"], "--port", "0",
        "--unixsocket", endpoint, "--unixsocketperm", "700",
        "--dir", directory, "--dbfilename", "dump.rdb", "--save", "",
        "--appendonly", "yes" if aof else "no", "--appendfsync", "always",
        "--appenddirname", "appendonlydir", "--appendfilename", "appendonly.aof",
        "--aof-use-rdb-preamble", "yes", "--no-appendfsync-on-rewrite", "no",
        "--auto-aof-rewrite-percentage", "0",
    ], 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, process
    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 in (0, -signal.SIGKILL), errors.decode()


def crash(process):
    assert process.poll() is None
    process.kill()
    assert process.wait(timeout=5) == -signal.SIGKILL


with TemporaryDirectory(prefix="redis-aof-", dir="/tmp") as directory:
    with instance(directory, aof=True) as (client, process):
        client.set("counter", 0)
        for expected in range(1, 5):
            assert client.incr("counter") == expected
        assert client.hset("profile", mapping={"name": "Ada", "level": "2"}) == 2
        client.set("deleted", "not retained")
        assert client.delete("deleted") == 1
        rewrites = client.info("persistence")["aof_rewrites"]
        assert client.bgrewriteaof()
        deadline = time.monotonic() + 5
        while True:
            state = client.info("persistence")
            if (state["aof_rewrites"] > rewrites
                    and not state["aof_rewrite_in_progress"]
                    and not state["aof_rewrite_scheduled"]):
                assert state["aof_last_bgrewrite_status"] == "ok"
                break
            assert time.monotonic() < deadline, "AOF rewrite did not finish"
            time.sleep(0.02)
        assert client.incr("counter") == 5
        assert client.info("persistence")["aof_last_write_status"] == "ok"
        assert not (Path(directory) / "dump.rdb").exists()
        print("rewrite completed; a later INCR returned 5; no standalone RDB")
        crash(process)

    aof_dir = Path(directory) / "appendonlydir"
    entries = []
    for line in (aof_dir / "appendonly.aof.manifest").read_text().splitlines():
        parts = line.split()
        entries.append(dict(zip(parts[::2], parts[1::2], strict=True)))
    active = [entry for entry in entries if entry["type"] in {"b", "i"}]
    assert {entry["type"] for entry in active} == {"b", "i"}
    for entry in active:
        assert (aof_dir / entry["file"]).is_file()
    base = next(aof_dir / entry["file"] for entry in active if entry["type"] == "b")
    assert base.read_bytes().startswith(b"REDIS")
    print("manifest references an RDB-format AOF base and an incremental AOF")

    with instance(directory, aof=True) as (client, process):
        assert client.get("counter") == b"5"
        assert client.hgetall("profile") == {b"name": b"Ada", b"level": b"2"}
        assert client.get("deleted") is None
        assert client.dbsize() == 2
        print("after SIGKILL and restart: counter=5, hash restored, deletion retained")

The test waits for an observed successful rewrite, then adds one more increment. After SIGKILL, the manifest references an RDB-format AOF base and incremental data, while no standalone dump.rdb exists. Restarting restores counter five, both hash fields, and the absence of the deleted key. Checking values and types is stronger than counting keys alone.

The manifest inspection is scoped to this fixture’s simple filenames; it is not a general backup parser. SIGKILL skips graceful shutdown but leaves the operating system running, including its filesystem cache. The result proves this local AOF recovery path, not a measured loss window for everysec or no, nor survival of hardware failure.

Sources