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?
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-lrueviction 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
cacheappthat may only read, write and delete keys underapp:, and anadminuser for you, with the passwordlessdefaultuser 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 withpython3 --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=Truereturns strings instead of bytes. - Short timeouts.
socket_connect_timeoutandsocket_timeoutare 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). TryGET; on a hit, decode the JSON and return. On a miss, run the slow query, thenSETthe result withex=(seconds until it expires). - Write path (
update_price). Update the database first, commit, thenDELthe 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
python cache_app.py get 42twice in a row: the second call reportscacheand takes a few milliseconds.python cache_app.py update 42 50, thenget 42: the new price appears immediately.- With
REDISCLI_AUTHset to the admin password,redis-cli --user admin TTL app:product:42returns a number between 0 and 330. redis-cli --user admin CONFIG GET maxmemory-policyreturnsallkeys-lru, andCONFIG GET maxmemoryreturns 268435456 (256 MB).redis-cli PINGwithout credentials fails withNOAUTH Authentication required.redis-cli --user admin ACL DRYRUN cacheapp FLUSHALLreports that the user has no permission.python cache_app.py benchreports a hit ratio around 90% on a cold cache.
If something goes wrong
NOAUTH Authentication required.The client did not send credentials and thedefaultuser is off or has a password. Check thatREDIS_URLcontains the user name and password, and for redis-cli setREDISCLI_AUTHand--user.WRONGPASS invalid username-password pair or user is disabled.Wrong password, wrong user name (they are case sensitive), or the user was created withouton. Inspect it as admin withACL GETUSER cacheapp.NOPERMerrors 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 thatevicted_keysis not climbing becausemaxmemoryis too small. OOM command not allowed when used memory > 'maxmemory'.The policy is stillnoeviction, or avolatile-*policy is set and the keys have no TTL. Setallkeys-lruas in Step 6.pip install redisfails 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 EXlets one request rebuild the entry while the others wait or serve the old value. - Export
keyspace_hits,keyspace_misses,evicted_keysand 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
See also
- 0236 Redis data types and commands: a quick reference Redis stores values under keys, and each value has a type: string, hash, list, set, sorted set or stream. The essential commands… Beginner · Reference · Any system Article Basics 15 Oct 2026
- 0237 Use Redis as a cache: TTLs, maxmemory and an eviction health report Set a memory limit and an eviction policy so Redis behaves like a cache, give keys TTLs, then run a bash report… Intermediate · How-to · Linux, macOS Script Administration & persistence · Basics 15 Oct 2026
- 0238 Redis persistence and high availability: RDB, AOF, replicas and Sentinel Redis keeps data in memory and writes it to disk with RDB snapshots, an append-only file, or both. Replicas copy data to… Intermediate · Concept · Linux Article Administration & persistence 15 Oct 2026
- 0239 Fix: (error) NOAUTH Authentication required. (and securing Redis with ACLs) Redis requires a login and your client sent a command without one. Pass the right user and password, and use the moment… Advanced · Fix · Linux Article Administration & persistence · Security & access 15 Oct 2026
- 0240 Fix: MISCONF Redis is configured to save RDB snapshots, but it’s currently unable to persist to disk Redis blocks writes because its last background snapshot failed. Find the cause in the log (full disk, permissions, failed fork), fix it,… Intermediate · Fix · Linux Article Administration & persistence 15 Oct 2026
Repos in the wild
- Redis server source code (redis/redis) redis/redis The official repository of Redis Open Source: the in-memory data structure server, redis-cli, Sentinel and the example redis.conf. GitHub
- Valkey: open source key-value datastore (valkey-io/valkey) valkey-io/valkey Valkey is a BSD-licensed key-value data structure server, forked from Redis just before its 2024 license change and backed by the Linux Foundation. GitHub
History
Every saved edit is kept. The plot on the sleeve draws one line per edit. Open any edit to see exactly what changed.
- Commitpedia +21,496 chars