Skip to content
Commitpedia
Log in Join & edit

Speed up a slow database query with a Redis cache: cache-aside in Python, measured and locked down

Put Redis in front of a slow query in a small Python app: cache-aside reads, TTLs with jitter, invalidation on writes, an eviction policy, hit-rate measurement and an ACL user that can only touch its own keys.

Not checked by a trusted editor yet Last edited Outdated?

Edit history of Speed up a slow database query with a Redis cache: cache-aside in Python, measured and locked down: 1 recorded edit
Edit history · 1 edit
Type
Article
Edits
1
Editors
1
First issued
15 Oct 2026
Last edit
15 Oct 2026
Reading
15 min

When the same expensive query runs again and again with the same answer, a cache can return that answer in well under a millisecond instead of asking the database each time. This tutorial adds Redis to a small Python program the most common way, called cache-aside: the application looks in Redis first, and only on a miss runs the query and stores the result. You then make the cache safe to run: entries expire, writes invalidate them, memory is capped, the hit rate is measured, and the application connects as a restricted ACL user.

It is for developers and administrators who know some Python and have heard of Redis but have not run it as a cache yet. The program uses only the Python standard library plus the official redis client package (redis-py), and SQLite stands in for the slow database.

What you will build

  • A Redis server on Linux with a memory limit and the allkeys-lru eviction policy.
  • cache_app.py: a command-line program that reads products from a database through a Redis cache, updates prices and invalidates the cached copy, and runs a small benchmark that reports the hit ratio.
  • Cache keys named app:product:<id>, each with a TTL of about five minutes.
  • An ACL user cacheapp that may only read, write and delete keys under app:, and an admin user for you, with the passwordless default user switched off.
  • A program that keeps working, only slower, when Redis is down.

Before you start

  • A Linux machine (Ubuntu or Debian in the examples) with sudo access. Redis and the program can run on the same machine for this tutorial; in production Redis usually runs on its own host on the private network.
  • Python 3.10 or later with venv. The current redis-py release requires it; check with python3 --version.
  • Redis 7.2 or later. The ACL rules use subcommand syntax and CLIENT SETINFO, and the eviction policies used here exist in all current versions. The install step below gives you the latest Redis Open Source.
  • Time: about an hour.
  • Knowledge: running Python scripts, basic SQL, and the idea of a key-value store: Redis keeps values under string keys in memory, and every key can have an expiry time (TTL).

Step 1: Install Redis and check that it runs

Redis publishes its own APT repository with current releases. These are the commands from the Redis installation guide:

Check before you run this

  • Runs with administrator rights. Make sure you understand every line.
sudo apt-get install lsb-release curl gpg
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
sudo chmod 644 /usr/share/keyrings/redis-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
sudo apt-get update
sudo apt-get install redis

The service should start automatically. If it did not, run sudo systemctl enable --now redis-server. Then check:

redis-cli PING
redis-cli INFO server | grep redis_version
redis-cli CONFIG GET bind
redis-cli CONFIG GET maxmemory

Expect PONG, a version of 7.2 or newer, a bind value limited to loopback, and maxmemory 0, which on 64-bit systems means no limit at all. You fix that in Step 6.

Step 2: Set up the project and a slow database

Create a directory and a virtual environment, and install the client:

mkdir ~/cache-demo && cd ~/cache-demo
python3 -m venv .venv
. .venv/bin/activate
pip install redis

The demo database is an SQLite file with 1,000 products. To make the effect visible on any machine, the function that reads a product waits 200 ms before running its query, standing in for a slow join or a remote API. Everything else in the program is real.

Step 3: Write the cache-aside program

Save this as cache_app.py:

Check before you run this

  • Deletes database data permanently.
import json
import os
import random
import sqlite3
import sys
import time

import redis

DB_PATH = os.environ.get("APP_DB", "products.db")
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
CACHE_TTL = int(os.environ.get("CACHE_TTL", "300"))
SLOW_QUERY_SECONDS = 0.2

stats = {"hits": 0, "misses": 0, "errors": 0}

cache = redis.Redis.from_url(
    REDIS_URL,
    decode_responses=True,
    socket_connect_timeout=1,
    socket_timeout=1,
)


def db():
    return sqlite3.connect(DB_PATH)


def seed(count=1000):
    with db() as conn:
        conn.execute("DROP TABLE IF EXISTS products")
        conn.execute(
            "CREATE TABLE products ("
            " id INTEGER PRIMARY KEY,"
            " name TEXT NOT NULL,"
            " price REAL NOT NULL)"
        )
        conn.executemany(
            "INSERT INTO products (id, name, price) VALUES (?, ?, ?)",
            [(i, f"Product {i}", round(5 + i * 0.25, 2)) for i in range(1, count + 1)],
        )
    print(f"seeded {count} products into {DB_PATH}")


def load_product_from_db(product_id):
    time.sleep(SLOW_QUERY_SECONDS)
    with db() as conn:
        row = conn.execute(
            "SELECT id, name, price FROM products WHERE id = ?", (product_id,)
        ).fetchone()
    if row is None:
        return None
    return {"id": row[0], "name": row[1], "price": row[2]}


def cache_key(product_id):
    return f"app:product:{product_id}"


def get_product(product_id):
    key = cache_key(product_id)
    try:
        cached = cache.get(key)
    except redis.exceptions.RedisError as exc:
        stats["errors"] += 1
        print(f"cache unavailable, reading from database: {exc}", file=sys.stderr)
        return load_product_from_db(product_id)

    if cached is not None:
        stats["hits"] += 1
        return json.loads(cached)

    stats["misses"] += 1
    product = load_product_from_db(product_id)
    if product is not None:
        ttl = CACHE_TTL + random.randint(0, CACHE_TTL // 10)
        try:
            cache.set(key, json.dumps(product), ex=ttl)
        except redis.exceptions.RedisError as exc:
            stats["errors"] += 1
            print(f"could not write cache: {exc}", file=sys.stderr)
    return product


def update_price(product_id, new_price):
    with db() as conn:
        conn.execute(
            "UPDATE products SET price = ? WHERE id = ?", (new_price, product_id)
        )
    try:
        cache.delete(cache_key(product_id))
    except redis.exceptions.RedisError as exc:
        stats["errors"] += 1
        print(f"could not invalidate cache, entry expires by TTL: {exc}", file=sys.stderr)


def bench(requests=300, hot_ids=30):
    start = time.perf_counter()
    for _ in range(requests):
        get_product(random.randint(1, hot_ids))
    elapsed = time.perf_counter() - start
    lookups = stats["hits"] + stats["misses"]
    ratio = stats["hits"] / lookups if lookups else 0.0
    print(f"requests: {requests}  time: {elapsed:.2f}s  avg: {elapsed / requests * 1000:.1f} ms")
    print(f"hits: {stats['hits']}  misses: {stats['misses']}  errors: {stats['errors']}  hit ratio: {ratio:.1%}")


def main(argv):
    if len(argv) < 2:
        print("usage: cache_app.py seed | get ID | update ID PRICE | bench [REQUESTS]")
        return 2
    command = argv[1]
    if command == "seed":
        seed()
    elif command == "get":
        t0 = time.perf_counter()
        product = get_product(int(argv[2]))
        ms = (time.perf_counter() - t0) * 1000
        source = "cache" if stats["hits"] else "database"
        print(f"{product}  ({source}, {ms:.1f} ms)")
    elif command == "update":
        update_price(int(argv[2]), float(argv[3]))
        print(f"price of product {argv[2]} set to {argv[3]}")
    elif command == "bench":
        bench(int(argv[2]) if len(argv) > 2 else 300)
    else:
        print(f"unknown command: {command}")
        return 2
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))

The parts that matter:

  • One client per process. redis.Redis.from_url() creates a client with a connection pool; reuse it instead of connecting per request. decode_responses=True returns strings instead of bytes.
  • Short timeouts. socket_connect_timeout and socket_timeout are set to 1 second, so an unreachable cache costs the application about a second at most per call before it falls back to the database.
  • Read path (get_product). Try GET; on a hit, decode the JSON and return. On a miss, run the slow query, then SET the result with ex= (seconds until it expires).
  • Write path (update_price). Update the database first, commit, then DEL the key. The next read misses and loads the fresh value. Deleting is simpler and safer than writing the new value into the cache, because a cached value is always rebuilt from the database.
  • Fail open. Every Redis call is wrapped in except redis.exceptions.RedisError, the base class of the client’s connection, timeout and response errors. If Redis is unavailable, the program reads from the database and says so on stderr.
  • Namespaced keys. All keys start with app:, which keeps them apart from other users of the same Redis and lets an ACL rule match them in Step 8.

Step 4: See a miss, a hit and the cached value

python cache_app.py seed
python cache_app.py get 42
python cache_app.py get 42

The first get reads from the database and takes a little over 200 ms; the second, a new process, finds the entry in Redis and takes a millisecond or two. The first lines of output look like this; the third line prints the same product followed by (cache, ...) and a time of a few milliseconds or less:

seeded 1000 products into products.db
{'id': 42, 'name': 'Product 42', 'price': 15.5}  (database, 207.1 ms)

Look at the entry from the Redis side:

redis-cli GET app:product:42
redis-cli TTL app:product:42

GET returns the JSON string, and TTL returns the remaining seconds, somewhere between 300 and 330. A TTL of -1 would mean the key never expires, and -2 that it does not exist.

Step 5: Invalidate on writes and choose TTLs

python cache_app.py update 42 99.50
redis-cli EXISTS app:product:42
python cache_app.py get 42

EXISTS prints 0 because the update deleted the key, and the following get comes from the database with the new price, then refills the cache.

Two rules keep cached data honest:

  • Every cache entry gets a TTL. Invalidation can fail (Redis briefly unreachable, a code path that forgets to delete, a change made directly in the database). The TTL is the upper bound on how long stale data can survive. Pick it from the business side: how old may a price be when a customer sees it?
  • Add jitter. The program adds up to 10% random seconds to the TTL. If thousands of entries were created in the same minute with the same TTL, they would also expire in the same minute and send a burst of misses to the database. Spreading the expiry times avoids that.

Cache-aside has a known race: a reader that loaded the old value just before an update can write it to the cache just after the update deleted the key. The TTL limits how long that stale entry lives. If that is not acceptable for some data, do not cache it, or use a shorter TTL for it.

Step 6: Cap memory and pick an eviction policy

Without a limit, Redis keeps growing until the operating system runs out of memory. With a limit but the default policy noeviction, commands that add data fail once the limit is reached, while reads keep working. For a cache, where every entry can be rebuilt, you want Redis to throw old entries away instead. The Redis documentation recommends allkeys-lru as a good default when some keys are read far more often than others:

redis-cli CONFIG SET maxmemory 256mb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
redis-cli CONFIG REWRITE
redis-cli INFO memory | grep -E '^(used_memory_human|maxmemory_human|maxmemory_policy):'

CONFIG REWRITE writes the running settings back into the redis.conf the server was started with (usually /etc/redis/redis.conf), so they survive a restart. Size maxmemory to your hot data plus headroom, and keep it well below the machine’s RAM: replication and persistence buffers are not counted toward the limit.

Other policies you may meet: allkeys-lfu evicts the least frequently used keys; the volatile-* variants only evict keys that have a TTL, which matters when one instance mixes cache entries with data that must stay, though separate instances are the cleaner answer.

Step 7: Measure the hit rate

A cache that is never hit is just extra latency. The bench command sends 300 reads spread over 30 “hot” product IDs, the kind of skewed access a cache is good at, and prints its own counters. Flush the demo keys first so the run starts cold. Warning: DEL removes keys permanently; the pattern below matches only this program’s keys.

redis-cli --scan --pattern 'app:product:*' | xargs -r redis-cli DEL
python cache_app.py bench 300

Output from a test run; your times will differ slightly:

requests: 300  time: 6.16s  avg: 20.5 ms
hits: 270  misses: 30  errors: 0  hit ratio: 90.0%

Each of the 30 IDs misses once and is then served from Redis; the total time is almost entirely those 30 slow queries. Run it a second time within five minutes and the hit ratio goes to 100%.

Redis keeps server-wide counters as well. The hit ratio is keyspace_hits / (keyspace_hits + keyspace_misses), and evicted_keys and expired_keys tell you why entries disappear:

redis-cli INFO stats | grep -E '^(keyspace_hits|keyspace_misses|evicted_keys|expired_keys):'

The counters are totals since the server started, so compare two readings taken some minutes apart. A low ratio with many evictions means maxmemory is too small for the hot data; a low ratio with few evictions but many expirations means the TTL is shorter than the time between reads.

Step 8: Lock Redis down with ACL users

Redis is designed for trusted clients on a trusted network, and on a fresh server the default user has no password and may run every command, including FLUSHALL and CONFIG. Give each client its own user with only what it needs.

First create an administrator for yourself and test it, so you cannot lock yourself out. ACL GENPASS generates a strong random secret; use one per user:

redis-cli ACL GENPASS
redis-cli ACL SETUSER admin on '>CHANGE_ME_ADMIN' '~*' '&*' +@all
REDISCLI_AUTH='CHANGE_ME_ADMIN' redis-cli --user admin ACL WHOAMI

The last command should print admin. Then create the application user. New users start with no permissions, and these rules add only what the program needs: the three data commands on keys under app:, PING, and CLIENT SETINFO, which client libraries send after connecting and are expected to ignore if it fails:

redis-cli ACL SETUSER cacheapp on '>CHANGE_ME_APP' resetchannels '~app:*' +get +set +del +ping '+client|setinfo'
redis-cli ACL DRYRUN cacheapp GET app:product:1
redis-cli ACL DRYRUN cacheapp GET other:1
redis-cli ACL DRYRUN cacheapp FLUSHALL

The first dry run prints OK; the other two print why cacheapp may not run them. ACL DRYRUN checks permissions without executing anything. Key patterns do not restrict whole-database commands such as FLUSHALL; only command rules do, which is why the user gets an explicit list of commands instead of +@all.

Point the program at the new user. redis-py reads the user name and password from the URL; percent-encode special characters in the password:

export REDIS_URL='redis://cacheapp:CHANGE_ME_APP@localhost:6379/0'
python cache_app.py get 7

When everything that uses this Redis has its own user, switch off the anonymous default user and save the users. Run this as admin; any client that still connects without credentials gets NOAUTH Authentication required. from now on:

export REDISCLI_AUTH='CHANGE_ME_ADMIN'
redis-cli --user admin ACL SETUSER default off
redis-cli --user admin CONFIG REWRITE
redis-cli --user admin ACL LIST

ACL SETUSER changes live only in memory until saved. With users defined in redis.conf, CONFIG REWRITE writes them there, and passwords are stored as SHA-256 hashes. For many users, the documentation recommends a separate file set with aclfile and managed with ACL SAVE and ACL LOAD; the two methods cannot be mixed.

Keep the network layer too: leave bind on loopback, or on loopback plus one private interface if the application runs elsewhere; keep protected-mode yes; allow port 6379 in the host firewall only from the application servers; and enable TLS when traffic leaves a trusted network, because AUTH is sent in clear text otherwise. Then use rediss:// in REDIS_URL.

Step 9: Make sure the app survives a cache outage

A cache must never be a single point of failure for reads. Stop Redis and call the program:

Check before you run this

  • Runs with administrator rights. Make sure you understand every line.
sudo systemctl stop redis-server
python cache_app.py get 7
python cache_app.py update 7 9.99
sudo systemctl start redis-server

Both commands still work. get prints a cache unavailable, reading from database: line with the connection error on stderr and returns the product after the slow query; update changes the database and warns that it could not invalidate the cache. The stale entry for product 7, if there was one, disappears when its TTL runs out, which is exactly why every entry has one. If stale data after an outage is not acceptable, delete the affected keys once Redis is back.

The 1-second timeouts matter here: with a black-holed network instead of a stopped service, each call waits for the timeout before falling back.

Check that it works

  1. python cache_app.py get 42 twice in a row: the second call reports cache and takes a few milliseconds.
  2. python cache_app.py update 42 50, then get 42: the new price appears immediately.
  3. With REDISCLI_AUTH set to the admin password, redis-cli --user admin TTL app:product:42 returns a number between 0 and 330.
  4. redis-cli --user admin CONFIG GET maxmemory-policy returns allkeys-lru, and CONFIG GET maxmemory returns 268435456 (256 MB).
  5. redis-cli PING without credentials fails with NOAUTH Authentication required.
  6. redis-cli --user admin ACL DRYRUN cacheapp FLUSHALL reports that the user has no permission.
  7. python cache_app.py bench reports a hit ratio around 90% on a cold cache.

If something goes wrong

  • NOAUTH Authentication required. The client did not send credentials and the default user is off or has a password. Check that REDIS_URL contains the user name and password, and for redis-cli set REDISCLI_AUTH and --user.
  • WRONGPASS invalid username-password pair or user is disabled. Wrong password, wrong user name (they are case sensitive), or the user was created without on. Inspect it as admin with ACL GETUSER cacheapp.
  • NOPERM errors from the program. The user lacks a command or the key does not match ~app:*. ACL LOG (as admin) shows the denied command and key; add the command to the user’s rules only if the program really needs it.
  • Every request is a miss. Check that the key the program writes is the one it reads (redis-cli --user admin --scan --pattern 'app:*'), that the TTL is not tiny, and that evicted_keys is not climbing because maxmemory is too small.
  • OOM command not allowed when used memory > 'maxmemory'. The policy is still noeviction, or a volatile-* policy is set and the keys have no TTL. Set allkeys-lru as in Step 6.
  • pip install redis fails or installs an old version. Your Python is older than the client supports. Use Python 3.10 or later in the virtual environment.

Clean up

Remove the demo keys and users (run as admin). Warning: if other applications use this Redis, check the pattern and the user names before deleting:

export REDISCLI_AUTH='CHANGE_ME_ADMIN'
redis-cli --user admin --scan --pattern 'app:product:*' | xargs -r redis-cli --user admin DEL
redis-cli --user admin ACL SETUSER default on
redis-cli --user admin ACL DELUSER cacheapp
redis-cli --user admin CONFIG REWRITE

Turning default back on restores the original open configuration; leave it off on any machine that is reachable from a network. Delete the admin user last if you no longer need it, then remove the project directory with rm -r ~/cache-demo. To remove Redis completely: sudo apt-get purge redis redis-server redis-tools, which also deletes its configuration.

Next steps

  • Cache “not found” results for a short time too (negative caching), so lookups of missing IDs do not reach the database every time.
  • Protect against a stampede on very hot keys: when a popular key expires, many requests miss at once. A short lock key with SET ... NX EX lets one request rebuild the entry while the others wait or serve the old value.
  • Export keyspace_hits, keyspace_misses, evicted_keys and memory use to your monitoring and alert on a falling hit ratio.
  • Enable TLS for client connections and switch the URL to rediss://.
  • Read about Redis persistence and replication before you store anything in Redis that cannot be rebuilt from another source.

Official documentation

Did this work for you?

No votes yet. Be the first.

If it did not work, the report form below tells the editors what went wrong.

See also

Repos in the wild

History

Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.

  1. Commitpedia +21,496 chars

Report a problem

Something wrong, unsafe or copied? Tell the editors.
What is wrong?