ElastiCache Caching Patterns with RDS
Build a caching layer with ElastiCache Valkey in front of RDS MySQL.
Introduction
30 Second Summary
Every time your app needs data, it asks the database. The database looks it up on disk, packages it, and sends it back. Do that thousands of times per second and your database becomes the bottleneck. Caching stores frequently requested data in memory so repeat reads skip the database entirely.
In this project, you will build a caching layer with Amazon ElastiCache that sits in front of an RDS MySQL database. You will implement the two caching patterns tested on the SAA-C03 exam and see the performance difference with your own eyes.
What You'll Build
A Python application running on EC2 that queries a product catalog, first with no caching (slow), then with lazy loading (fast on repeat reads), then with write-through (always consistent). You run each pattern and watch the response times change in your terminal.
By the end of this project, you'll have:
- A working lazy loading cache where the first read takes milliseconds from the database, but every repeat read returns in sub-millisecond time from memory.
- A live demonstration of the stale data problem where you update the database, watch the cache serve old data, then fix it with TTL-based invalidation.
- A write-through pattern where every write updates both the database and cache simultaneously, so reads are always fresh with zero staleness.
- Secret Mission: Deploy ElastiCache Serverless, connect with IAM authentication, and compare the operational experience against your node-based cluster.
Are there any prerequisites?
You need an AWS account (post-July-2025 accounts receive $100 in credits that cover this project), Python 3.10+ installed locally, and VS Code. Familiarity with RDS MySQL basics (creating an instance and connecting) is helpful but not required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters. This project tackles one of the most testable SAA-C03 topics: how ElastiCache sits between your application and RDS to accelerate reads and reduce database load.
Set Up the Infrastructure
Before you can demonstrate any caching pattern, you need three pieces of infrastructure working together inside the same VPC. A database to store the data, a cache to sit in front of it, and a compute instance to run your Python code.
Without this foundation, there is nothing to test caching against. In this step, you will create an RDS MySQL database, an ElastiCache Valkey cluster with encryption and AUTH, and an EC2 instance to connect them all.
In this step, get ready to:
- Create an RDS MySQL instance to store product data.
- Create an ElastiCache Valkey cluster with encryption and AUTH token security.
- Launch an EC2 instance and install Python dependencies.
Create an RDS MySQL instance
Your product catalog needs a relational database. RDS MySQL gives you a managed database engine without any server administration.
- Sign in to the AWS Management Console.
- In the search bar at the top, type RDS and select RDS from the results.
- In the navigation pane on the left, click Databases.
- Click Create database.
- Under Choose a database creation method, select Standard create.
- Under Engine options, for Engine type, choose MySQL.
- For Engine version, select a MySQL 8.0 version from the dropdown.
- Under Templates, choose Free tier.
Don't see Free tier?
If your AWS account was created after July 15, 2025, you will see Sandbox instead of Free tier. Select Sandbox. Your $100 in AWS credits covers the cost of this project.
- Under Settings, for DB instance identifier, enter products-db.
- For Master username, enter admin.
- Clear the Auto generate a password checkbox if it is selected.
- For Master password, enter a password you will remember. Confirm it in the Confirm password field.
- Under Instance configuration, confirm db.t3.micro is selected.
- Under Connectivity, for Compute resource, choose Don't connect to an EC2 compute resource.
- For Public access, select No.
- Expand Additional configuration at the bottom of the page.
- For Initial database name, enter products.
- Click Create database.
The database status shows Creating. This takes 5-10 minutes to become Available.
Use the wait time wisely
You do not need to wait for RDS to finish before starting the next substep. Move on to creating the ElastiCache cluster now. Both resources will provision in parallel.
Create an ElastiCache Valkey cluster
The cache layer sits between your application and the database. You will create a subnet group first, then provision the Valkey cluster with encryption and an AUTH token.
- In the AWS Console search bar, type ElastiCache and select ElastiCache.
- In the left navigation pane, click Subnet groups.
- Click Create subnet group.
- For Name, enter elasticache-subnet-group.
- For Description, enter Subnet group for ElastiCache project.
- For VPC ID, choose your default VPC.
- All subnets are selected by default in the Selected subnets panel. Leave them as-is so your cache can use any Availability Zone.
- Click Create.
You should see a confirmation message that your subnet group was created. Now create the cluster itself.
- In the left navigation, click Valkey caches.
- Click Create Valkey cache.
- Under Configuration, for Deployment option, select Node-based cluster.
- For Creation method, confirm New cache is selected.
- Under Cluster settings, for Cluster mode, choose Disabled.
- For Name, enter products-cache.
- For Engine version, leave the default version selected.
- For Port, leave the default 6379.
- For node type, choose cache.t3.micro.
- For Number of replicas, enter 1.
- Under Connectivity, for Subnet groups, select elasticache-subnet-group (the one you just created).
- Scroll down to Advanced Valkey settings.
- Under Security, enable Encryption at rest.
- Enable Encryption in-transit.
- For the access control option that appears, select AUTH.
- Enter an AUTH token. This must be 16-128 printable characters. Use something memorable like MyCache2025Token!. Write it down. You will need it later.
- Click Next, review your settings, then click Create.
What is an AUTH token?
An AUTH token is a password that your application must provide when connecting to ElastiCache. It adds an authentication layer on top of VPC-level network security. AUTH can only be enabled when encryption in-transit is also enabled.
The cluster status shows Creating. This takes 5-10 minutes. While it provisions, note the Primary endpoint once it appears in the cluster details. You will need this in the next step.
Copy the primary endpoint now
Once the cluster shows Available, click the cluster name and find Primary endpoint in the details. Copy it somewhere safe. It looks like products-cache.xxxxxx.0001.use1.cache.amazonaws.com.
Remove the :6379 port suffix if it is included. You will provide the port separately in your code.
Launch an EC2 instance and install dependencies
ElastiCache is VPC-only. You cannot connect to it from your local machine. You need an EC2 instance inside the same VPC to run your Python scripts.
- In the AWS Console search bar, type EC2 and select EC2.
- Click Launch instance.
- For Name, enter cache-project.
- Under Application and OS Images, confirm Amazon Linux 2023 is selected.
- For Instance type, choose t2.micro.
- Under Key pair, select Proceed without a key pair (you will use EC2 Instance Connect instead of SSH).
- Under Network settings, click Edit.
- For VPC, select your default VPC (the same one used by RDS and ElastiCache).
- For Firewall (security groups), select Select existing security group.
- Choose the default security group for your VPC.
- Click Launch instance.
Wait for the instance state to show Running. This takes about 30 seconds.
Why the default security group?
Placing EC2, RDS, and ElastiCache in the same default VPC security group means they can already communicate with each other. The default security group has an inbound rule that allows all traffic from resources in the same security group.
If your default security group does not have this self-referencing rule, you will add it in the next set of actions.
Now configure the security group to ensure connectivity on the specific ports.
- In the EC2 console left navigation, click Security Groups (under Network & Security).
- Select the default security group for your VPC.
- Click the Inbound rules tab, then click Edit inbound rules.
- Click Add rule. For Type, choose MYSQL/Aurora. For Source, search for and select the same default security group.
- Click Add rule again. For Type, choose Custom TCP. For Port range, enter 6379. For Source, select the same default security group.
- Click Save rules.
Your security group now allows traffic on port 3306 (MySQL) and port 6379 (Valkey) between any resources sharing this security group.
Next, connect to your EC2 instance and install the Python packages.
- In the EC2 console left navigation, click Instances.
- Select the cache-project instance.
- Click Connect at the top of the page.
- On the EC2 Instance Connect tab, click Connect.
A terminal session opens in your browser. You are now logged into your EC2 instance.
- Install pip and the Python packages by running this command:
sudo yum install python3-pip -y && pip3 install redis==5.2.1 PyMySQL==1.2.0
What does this command do?
The first part (sudo yum install python3-pip -y) installs pip, the Python package manager, on Amazon Linux 2023.
The second part installs two packages: redis==5.2.1 (the Python client for Valkey/Redis) and PyMySQL==1.2.0 (the Python client for MySQL). Pinning exact versions ensures your code matches this project.
You should see output ending with Successfully installed followed by the package names.
Seeing a pip warning or error?
If you see a yellow warning about running pip as root, you can safely ignore it. The packages still installed.
If the command fails with a network error, verify your EC2 instance has internet access. The default VPC assigns a public IP, but if your subnet configuration differs, you may need to check that the route table has an internet gateway.
Help me troubleshoot my pip install on EC2
Now confirm that both your RDS instance and ElastiCache cluster have finished provisioning.
- Switch back to the RDS console. Click Databases and confirm your products-db instance shows status Available.
- Click the database name. Under Connectivity & security, copy the Endpoint value. You will need this in the next step.
Before you move on, predict what you expect to see when you check the ElastiCache cluster details. Do you think encryption will show as enabled?
- Switch to the ElastiCache console. Click Valkey caches in the left navigation.
- Click the products-cache cluster name to open its details.
- Confirm the status shows Available.
- In the cluster details, verify that Encryption in-transit shows Yes and Encryption at-rest shows Yes.
Both encryption indicators should show Yes. This confirms your cluster is secured with TLS for data in flight and AES-256 encryption for data stored on disk.
Cluster still showing Creating?
ElastiCache cluster creation can take up to 10 minutes. If you have been waiting longer than 15 minutes, verify you selected cache.t3.micro as the node type and that your VPC has at least two subnets in different Availability Zones.
Help me troubleshoot my ElastiCache cluster creation
Your infrastructure is ready. You have a database, a cache, and a compute instance that can reach both. Next up, you will seed the database with product data and measure what queries look like without any caching.
Seed the Database and Query Without Cache
Your infrastructure is in place. You have an RDS database, an ElastiCache cluster, and an EC2 instance that can talk to both.
Before adding a caching layer, you need baseline data and a baseline read path. Every read in this step hits RDS directly. This "before" measurement is what makes the value of caching visible in the next step.
In this step, get ready to:
- Seed your RDS MySQL database with a product catalog of 5 items.
- Query the database 5 times in a loop and measure response times with no caching.
- Establish a performance baseline that shows every query hits the database at the same speed.
Create and run the database seed script
Your RDS instance has an empty products database. You need to create a table and fill it with sample data so you have something to query and cache.
- First, grab your RDS endpoint. In the AWS console, open the RDS service.
- Click Databases in the left navigation pane.
- Click on your database instance name.
- Select the Connectivity & security tab and copy the Endpoint value. It looks like your-db-name.xxxxxxxxxx.us-east-1.rds.amazonaws.com.
Keep your endpoint handy
You will paste this RDS endpoint into multiple Python scripts throughout this project. Keep it in a text file or clipboard manager so you can access it quickly.
- Return to your EC2 Instance Connect terminal and create seed_db.py by running:
nano seed_db.py
- Paste the following connection setup code into the editor:
import pymysql
# Replace these with your actual RDS connection details
RDS_HOST = "your-rds-endpoint-here"
RDS_USER = "admin"
RDS_PASSWORD = "your-rds-password-here"
RDS_DB = "products"
# Connect to the RDS MySQL instance
connection = pymysql.connect(
host=RDS_HOST,
user=RDS_USER,
password=RDS_PASSWORD,
database=RDS_DB,
port=3306,
cursorclass=pymysql.cursors.DictCursor
)
What does this code do?
- RDS_HOST is the endpoint you just copied from the RDS console. This tells Python where to find your database.
- pymysql.connect() opens a TCP connection to your RDS instance on port 3306 (the MySQL default).
- DictCursor returns query results as Python dictionaries (column names as keys) instead of plain tuples.
- Below the connection setup, paste the code that creates the table and inserts product data:
# Create the products table and insert sample data
with connection:
with connection.cursor() as cursor:
cursor.execute("""
CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
category VARCHAR(100) NOT NULL
)
""")
cursor.execute("DELETE FROM products")
products = [
("Mechanical Keyboard", 89.99, "Electronics"),
("Wireless Mouse", 49.99, "Electronics"),
("USB-C Hub", 34.99, "Accessories"),
("Monitor Stand", 59.99, "Furniture"),
("Webcam HD", 79.99, "Electronics"),
]
cursor.executemany(
"INSERT INTO products (name, price, category) VALUES (%s, %s, %s)",
products
)
connection.commit()
print("Database seeded with 5 products.")
What does this code do?
- CREATE TABLE IF NOT EXISTS creates the products table only if it does not already exist, making the script safe to re-run.
- DELETE FROM products clears any existing rows so you always start with a clean set of 5 products.
- executemany() inserts all 5 product tuples in a single batch call to MySQL.
- connection.commit() finalizes the transaction. Without it, the inserts would be rolled back when the connection closes.
- Replace your-rds-endpoint-here with the endpoint you copied from the RDS console.
- Replace your-rds-password-here with the master password you set when creating the RDS instance.
- Save and exit nano by pressing Ctrl+O, then Enter, then Ctrl+X.
- Run the seed script:
python3 seed_db.py
You should see this output:
Database seeded with 5 products.
Seeing a connection error?
- Check that your RDS endpoint is correct. It must include the full hostname ending in .rds.amazonaws.com.
- Verify your security group allows inbound traffic on port 3306 from your EC2 instance.
- Confirm you are using password (not the deprecated passwd) in the connection call. PyMySQL 1.2.0 requires the full keyword.
Still stuck? Help me debug my PyMySQL connection to RDS
✔️ Awesome, I've got everything!
Great. Make sure you have saved seed_db.py and seen the success message in your terminal.
ⓧ I'd like to double check the full code
Your seed_db.py is longer than 30 lines, so confirm you have both sections in order:
- The connection setup block (import, config variables, pymysql.connect() call).
- The with connection: block (CREATE TABLE, DELETE, product list, executemany, commit, print).
Scroll through your file in nano (nano seed_db.py) and confirm each section is present in order.
Query the database without caching
Now you will query a single product 5 times in a loop, measuring how long each query takes. With no cache in place, every call goes directly to RDS.
- Create query_no_cache.py by running:
nano query_no_cache.py
- Paste the following imports, config, and query function:
import pymysql
import time
# Connection config (same as seed_db.py)
RDS_HOST = "your-rds-endpoint-here"
RDS_USER = "admin"
RDS_PASSWORD = "your-rds-password-here"
RDS_DB = "products"
def get_product_from_db(product_id):
# Open a fresh connection and query the product by ID
connection = pymysql.connect(
host=RDS_HOST,
user=RDS_USER,
password=RDS_PASSWORD,
database=RDS_DB,
port=3306,
cursorclass=pymysql.cursors.DictCursor
)
with connection:
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
return cursor.fetchone()
What does this function do?
- get_product_from_db() opens a new MySQL connection every time it is called, just like a real web application handling independent requests.
- The parameterized query %s prevents SQL injection by letting PyMySQL handle value escaping.
- fetchone() returns a single dictionary because you are looking up one product by its primary key.
- Below the function, paste the timing loop that calls it 5 times:
# Run the same query 5 times and measure each call
print("Querying product ID 1 five times (no cache):\n")
for i in range(5):
start = time.time()
product = get_product_from_db(1)
elapsed = (time.time() - start) * 1000
print(f" Query {i+1}: {product['name']} - ${product['price']} [{elapsed:.2f}ms] (from DB)")
What does this loop do?
- time.time() captures the clock before and after each query. The difference (multiplied by 1000) gives you milliseconds.
- Running the same query 5 times proves that every call takes roughly the same time. There is no speedup because nothing is cached.
- This is the baseline you will compare against when lazy loading delivers sub-millisecond cache hits in the next step.
- Replace your-rds-endpoint-here and your-rds-password-here with the same values you used in seed_db.py.
- Save and exit nano (Ctrl+O, Enter, Ctrl+X).
Before you run this, what do you think the timing will look like? Will all 5 queries be roughly the same speed, or will later ones be faster?
- Run the timing test:
python3 query_no_cache.py
You should see output like this:
Querying product ID 1 five times (no cache):
Query 1: Mechanical Keyboard - $89.99 [3.45ms] (from DB)
Query 2: Mechanical Keyboard - $89.99 [2.87ms] (from DB)
Query 3: Mechanical Keyboard - $89.99 [2.91ms] (from DB)
Query 4: Mechanical Keyboard - $89.99 [3.12ms] (from DB)
Query 5: Mechanical Keyboard - $89.99 [2.95ms] (from DB)
All 5 queries take roughly 2-5ms each. No query is faster than the others because every single one travels across the network to RDS and back.
Seeing different numbers or an error?
- Times slightly higher (5-10ms) are normal depending on your VPC network. The key observation is that all 5 times are similar. No speedup.
- If you see a KeyError, confirm you ran seed_db.py successfully first. The products table must have data.
- If you see a connection refused error, verify your security group still allows EC2 to reach RDS on port 3306.
Need more help? Help me debug my query_no_cache.py script
✔️ Awesome, I've got everything!
Great. You have confirmed that every query hits the database at a consistent speed with no caching.
ⓧ I'd like to double check the full code
Here is the complete query_no_cache.py for comparison:
import pymysql
import time
RDS_HOST = "your-rds-endpoint-here"
RDS_USER = "admin"
RDS_PASSWORD = "your-rds-password-here"
RDS_DB = "products"
def get_product_from_db(product_id):
connection = pymysql.connect(
host=RDS_HOST,
user=RDS_USER,
password=RDS_PASSWORD,
database=RDS_DB,
port=3306,
cursorclass=pymysql.cursors.DictCursor
)
with connection:
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
return cursor.fetchone()
print("Querying product ID 1 five times (no cache):\n")
for i in range(5):
start = time.time()
product = get_product_from_db(1)
elapsed = (time.time() - start) * 1000
print(f" Query {i+1}: {product['name']} - ${product['price']} [{elapsed:.2f}ms] (from DB)")
You now have a clear baseline. Every read takes 2-5ms because it travels to RDS, runs the query, and returns over the network. In the next step, you will put ElastiCache in front of this path and watch that number drop below 1ms on cache hits.
Implement Lazy Loading (Cache-Aside)
In the previous step, you saw every query hit the database directly. Each read took a consistent 2-5ms because there was no caching layer in between.
Now you will implement the most common caching pattern on the SAA-C03 exam: lazy loading (also called cache-aside). The application checks the cache first. On a miss, it reads from the database, stores the result in the cache, then returns it. The second read for the same data is dramatically faster because it comes from memory instead of disk.
In this step, get ready to:
- Create a Python script that connects to both ElastiCache and RDS.
- Implement the cache-aside pattern with cache hit and miss logic.
- Run 5 queries and observe the speed difference between cache misses and cache hits.
Create the lazy loading script with cache connection
The first thing your script needs is a connection to both the cache and the database. The cache connection uses TLS (encryption in transit) and the AUTH token you set when creating the cluster.
- In your EC2 Instance Connect session, create a new file called lazy_loading.py by running:
nano lazy_loading.py
- Paste the following imports, configuration variables, and cache connection:
import pymysql
import redis
import json
import time
RDS_HOST = "your-rds-endpoint-here"
RDS_USER = "admin"
RDS_PASSWORD = "your-rds-password-here"
RDS_DB = "products"
CACHE_HOST = "your-elasticache-primary-endpoint-here"
CACHE_PORT = 6379
CACHE_AUTH = "your-auth-token-here"
CACHE_TTL = 10
cache = redis.Redis(
host=CACHE_HOST,
port=CACHE_PORT,
password=CACHE_AUTH,
ssl=True,
ssl_cert_reqs="none",
decode_responses=True
)
What does this code do?
- The RDS variables are the same ones you used in query_no_cache.py. Replace them with your actual RDS endpoint and password.
- CACHE_HOST is your ElastiCache primary endpoint (from the cluster details page). CACHE_AUTH is the AUTH token you set during cluster creation.
- ssl=True enables the TLS connection required by your encryption-in-transit setting. decode_responses=True means redis-py returns strings instead of raw bytes.
- CACHE_TTL = 10 sets a 10-second time-to-live for cached entries. You will use this in the next step to fix stale data.
- Replace your-rds-endpoint-here and your-rds-password-here with your actual RDS values (same ones from seed_db.py).
- Replace your-elasticache-primary-endpoint-here with your cluster's primary endpoint from the ElastiCache console (without the port suffix).
- Replace your-auth-token-here with the AUTH token you set when creating the cluster.
- Save the file with Ctrl+O, press Enter, then exit with Ctrl+X.
- Test the connection by running:
python3 lazy_loading.py
If the connection succeeds, the script exits silently with no output. No error means your cache connection is working.
Seeing a connection error?
- If you see a timeout error, confirm your EC2 security group allows outbound traffic to port 6379 and that your ElastiCache cluster is in the same VPC as your EC2 instance.
- If you see an authentication error, double-check that CACHE_AUTH matches the exact AUTH token you set during cluster creation.
- If you see a certificate error, make sure ssl_cert_reqs="none" is present in your Redis connection.
Still stuck? Help me debug my ElastiCache connection error from EC2.
Add the cache-aside functions
With the connection verified, you need two functions. One fetches a product from RDS (the slow path). The other checks the cache first and only falls through to the database on a miss (the fast path).
- Open lazy_loading.py again by running:
nano lazy_loading.py
- Add the following two functions below the cache = redis.Redis(...) block:
def get_product_from_db(product_id):
connection = pymysql.connect(
host=RDS_HOST,
user=RDS_USER,
password=RDS_PASSWORD,
database=RDS_DB,
port=3306,
cursorclass=pymysql.cursors.DictCursor
)
with connection:
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
result = cursor.fetchone()
if result:
result["price"] = float(result["price"])
return result
def get_product_lazy_loading(product_id):
cache_key = f"product:{product_id}"
cached = cache.get(cache_key)
if cached:
return json.loads(cached), True
product = get_product_from_db(product_id)
if product:
cache.set(cache_key, json.dumps(product), ex=CACHE_TTL)
return product, False
What does this code do?
get_product_from_db() is the familiar RDS query from your previous script. The one difference: it converts the price from a Decimal to a float so it can be serialized to JSON for caching.
get_product_lazy_loading() is the cache-aside pattern in action:
- Check the cache with cache.get(cache_key). If the data exists, return it immediately (cache hit).
- If the cache returns None (cache miss), query the database instead.
- After fetching from the database, store the result in the cache with cache.set(key, value, ex=CACHE_TTL) so future reads are fast.
- The function returns a tuple: the product data plus a boolean indicating whether it was a cache hit.
💡 Why is it called lazy loading?
The cache only gets populated when data is actually requested. It loads data lazily (on demand) instead of eagerly pre-filling the entire cache. This means the cache only holds data that is actually being used.
Run the lazy loading demo
The functions are ready. Now you need a demo loop that queries the same product 5 times and prints whether each call was a cache hit or miss, along with the timing.
- Add the following demo code at the bottom of lazy_loading.py, below the two functions:
print("=== Lazy Loading Demo ===\n")
print("Querying product ID 1 five times:\n")
for i in range(5):
start = time.time()
product, was_hit = get_product_lazy_loading(1)
elapsed = (time.time() - start) * 1000
status = "CACHE HIT" if was_hit else "CACHE MISS"
print(f" Query {i+1}: {product['name']} - ${product['price']} [{elapsed:.2f}ms] ({status})")
What does this code do?
- It queries product ID 1 five times through the get_product_lazy_loading() function.
- Each call is timed with time.time() so you can see the speed difference between a miss (database roundtrip) and a hit (in-memory cache).
- The output labels each query as CACHE MISS or CACHE HIT so the pattern is unmistakable.
- Save the file with Ctrl+O, press Enter, then exit with Ctrl+X.
Before you run this, think about what you expect to see. The first query has never been cached. What about queries 2 through 5?
- Run the lazy loading demo by running:
python3 lazy_loading.py
You should see output showing CACHE MISS on query 1 (with timing around 2-5ms from the database) followed by CACHE HIT on queries 2 through 5 (with timing under 1ms from the cache). The speed difference is dramatic.
What should I see?
Query 1 is a cache miss. The key product:1 does not exist in the cache yet, so the function falls through to RDS, fetches the product, stores it in the cache, and returns it.
Queries 2-5 are cache hits. The key now exists in ElastiCache memory, so the function returns it immediately without touching the database. Memory reads are orders of magnitude faster than disk-based database queries.
🙋♀️ All queries showing CACHE MISS?
- Check that your CACHE_HOST does not include the :6379 port suffix. The port is set separately in CACHE_PORT.
- If you see a serialization error, confirm the result["price"] = float(result["price"]) line is present in get_product_from_db(). The Decimal type from MySQL cannot be serialized to JSON directly.
- If the script hangs on the first query, your security group may not allow EC2 to reach ElastiCache on port 6379.
Need more help? Help me debug why my lazy loading cache always misses.
✔️ Awesome, I've got everything!
Great. Double check you have saved lazy_loading.py and that your output shows the CACHE MISS / CACHE HIT pattern.
ⓧ I'd like to double check the full code
Scroll up and confirm your lazy_loading.py contains these three sections in order:
- Imports, RDS config, cache config, and the cache = redis.Redis(...) connection block.
- The get_product_from_db() and get_product_lazy_loading() functions.
- The demo loop that queries product ID 1 five times and prints CACHE HIT or CACHE MISS.
You have just proven that a single cache layer turns repeated database reads into sub-millisecond memory lookups. But there is a hidden problem. What happens when the underlying data changes in RDS while the cache still holds the old value? In the next step, you will see this stale data problem happen live and fix it with TTL.
Observe Stale Data and Fix with TTL
In the previous step, you proved that lazy loading makes repeat reads dramatically faster by serving data from the cache instead of the database.
But what happens when the underlying database changes while the cache still holds the old value? This is the stale data problem, and it is one of the most heavily tested ElastiCache concepts on the SAA-C03 exam. In this step, you will trigger stale data yourself and then watch TTL (Time-to-Live) resolve it automatically.
In this step, get ready to:
- Update a product price directly in RDS while the old price is still cached.
- Read from cache and witness stale data being served.
- Watch TTL expire and see the cache automatically serve fresh data.
Add the stale data demonstration
Your lazy loading function already sets a 10-second TTL on every cached entry (via ex=CACHE_TTL). But within that 10-second window, any direct change to the database is invisible to clients reading from the cache.
- In your EC2 Instance Connect session, open lazy_loading.py for editing.
- Find the last line of your script (the final print statement inside the for loop).
- Add the following stale data demo code below it:
print("\n=== Stale Data Demo ===\n")
print("Updating price in DB to 199.99 (cache still has old value)...")
connection = pymysql.connect(
host=RDS_HOST, user=RDS_USER, password=RDS_PASSWORD,
database=RDS_DB, port=3306
)
with connection:
with connection.cursor() as cursor:
cursor.execute("UPDATE products SET price = 199.99 WHERE id = 1")
connection.commit()
product, was_hit = get_product_lazy_loading(1)
print(f" Read after DB update: ${product['price']} ({'CACHE HIT - STALE!' if was_hit else 'CACHE MISS'})")
print(f"\nWaiting {CACHE_TTL} seconds for TTL to expire...")
time.sleep(CACHE_TTL + 1)
product, was_hit = get_product_lazy_loading(1)
print(f" Read after TTL expiry: ${product['price']} ({'CACHE HIT' if was_hit else 'CACHE MISS - fresh from DB'})")
What does this code do?
This code creates a three-phase demonstration of the stale data problem and its resolution.
- Phase 1: It connects directly to RDS and updates product 1's price from 89.99 to 199.99. The cache knows nothing about this change.
- Phase 2: It immediately reads product 1 via get_product_lazy_loading(). Since the cache still holds the old value (within TTL), it returns the stale price.
- Phase 3: It sleeps for CACHE_TTL + 1 seconds (11 total), allowing the cached entry to expire. The next read is a cache miss, fetching the fresh 199.99 price from the database.
- Save lazy_loading.py.
Run the script and observe stale data
Before you run this, think about it: the lazy loading demo at the top of the script will query product 1 five times, populating the cache with the current database value. Then the stale data demo will change the price in the database. What do you think the cache will return when you read product 1 immediately after that database update?
- Run the updated script by executing:
python3 lazy_loading.py
The script will take about 11 seconds to complete because of the TTL wait. Watch the output as it prints.
You should see three distinct phases in the Stale Data Demo section:
- After the DB update: the cache returns the OLD price (89.99) marked as CACHE HIT - STALE!. The database has 199.99, but the cache is serving outdated data.
- A 10-second wait while TTL counts down.
- After TTL expiry: the cache returns None (miss), so the function fetches the fresh price 199.99 from the database marked as CACHE MISS - fresh from DB.
Why does this matter for the SAA-C03?
The exam tests whether you understand this trade-off. Lazy loading gives you fast reads and resilience (if the cache goes down, the app still works via the database). But any direct database change leaves the cache stale until the TTL expires.
If the exam describes a scenario where data freshness is critical and direct DB writes happen frequently, lazy loading alone is insufficient. You need either a shorter TTL (more DB load) or the write-through pattern you will build next.
🙋♀️ Not seeing stale data?
- If the stale data read shows CACHE MISS instead of CACHE HIT - STALE!, your TTL from a previous run already expired. Run the script again immediately. The lazy loading demo at the top re-populates the cache, so the stale data demo should work on the second run.
- If you see a connection error to ElastiCache, confirm your AUTH token in CACHE_AUTH matches exactly what you set when creating the cluster.
Still stuck? Help me debug why my stale data demo is not showing CACHE HIT on the read after the DB update.
✔️ Awesome, I've got everything!
Great. Make sure you saved lazy_loading.py with the stale data demo code included.
ⓧ I'd like to double check the full code
Here is the complete lazy_loading.py with the stale data demo added at the bottom.
import pymysql
import redis
import json
import time
RDS_HOST = "your-rds-endpoint-here"
RDS_USER = "admin"
RDS_PASSWORD = "your-rds-password-here"
RDS_DB = "products"
CACHE_HOST = "your-elasticache-primary-endpoint-here"
CACHE_PORT = 6379
CACHE_AUTH = "your-auth-token-here"
CACHE_TTL = 10
cache = redis.Redis(
host=CACHE_HOST,
port=CACHE_PORT,
password=CACHE_AUTH,
ssl=True,
ssl_cert_reqs="none",
decode_responses=True
)
def get_product_from_db(product_id):
connection = pymysql.connect(
host=RDS_HOST,
user=RDS_USER,
password=RDS_PASSWORD,
database=RDS_DB,
port=3306,
cursorclass=pymysql.cursors.DictCursor
)
with connection:
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
result = cursor.fetchone()
if result:
result["price"] = float(result["price"])
return result
def get_product_lazy_loading(product_id):
cache_key = f"product:{product_id}"
cached = cache.get(cache_key)
if cached:
return json.loads(cached), True
product = get_product_from_db(product_id)
if product:
cache.set(cache_key, json.dumps(product), ex=CACHE_TTL)
return product, False
print("=== Lazy Loading Demo ===\n")
print("Querying product ID 1 five times:\n")
for i in range(5):
start = time.time()
product, was_hit = get_product_lazy_loading(1)
elapsed = (time.time() - start) * 1000
status = "CACHE HIT" if was_hit else "CACHE MISS"
print(f" Query {i+1}: {product['name']} - ${product['price']} [{elapsed:.2f}ms] ({status})")
print("\n=== Stale Data Demo ===\n")
print("Updating price in DB to 199.99 (cache still has old value)...")
connection = pymysql.connect(
host=RDS_HOST, user=RDS_USER, password=RDS_PASSWORD,
database=RDS_DB, port=3306
)
with connection:
with connection.cursor() as cursor:
cursor.execute("UPDATE products SET price = 199.99 WHERE id = 1")
connection.commit()
product, was_hit = get_product_lazy_loading(1)
print(f" Read after DB update: ${product['price']} ({'CACHE HIT - STALE!' if was_hit else 'CACHE MISS'})")
print(f"\nWaiting {CACHE_TTL} seconds for TTL to expire...")
time.sleep(CACHE_TTL + 1)
product, was_hit = get_product_lazy_loading(1)
print(f" Read after TTL expiry: ${product['price']} ({'CACHE HIT' if was_hit else 'CACHE MISS - fresh from DB'})")
You just experienced the fundamental weakness of lazy loading. The cache happily serves stale data for up to 10 seconds after the database changed. Next, you will implement the write-through pattern that eliminates this staleness entirely by writing to both the cache and database simultaneously.
Implement Write-Through Pattern
In the last step, you saw lazy loading's biggest weakness. The cache served stale data for a full 10 seconds after you updated the database.
Write-through caching eliminates that problem entirely by writing to both the cache AND the database on every write. Reads always find fresh data in cache. The trade-off is higher write latency (two writes per operation) and wasted memory if cached data is never read. Knowing when to pick each pattern is a key SAA-C03 exam concept.
In this step, get ready to:
- Create write_through.py with the write-through caching pattern.
- Run a test proving cache consistency after writes.
- Compare lazy loading vs write-through trade-offs for the SAA-C03 exam.
Create the write-through script
The write-through script needs the same connections as your lazy loading script (both ElastiCache and RDS), but uses a longer TTL since write-through keeps cache fresh on every write.
- Create a new file called write_through.py on your EC2 instance by running:
nano write_through.py
- Add the imports, configuration, and cache connection by pasting this code:
import pymysql
import redis
import json
import time
RDS_HOST = "your-rds-endpoint-here"
RDS_USER = "admin"
RDS_PASSWORD = "your-rds-password-here"
RDS_DB = "products"
CACHE_HOST = "your-elasticache-primary-endpoint-here"
CACHE_PORT = 6379
CACHE_AUTH = "your-auth-token-here"
CACHE_TTL = 300
cache = redis.Redis(
host=CACHE_HOST,
port=CACHE_PORT,
password=CACHE_AUTH,
ssl=True,
ssl_cert_reqs="none",
decode_responses=True
)
What does this code do?
This is the same connection setup you used in lazy_loading.py, with one key difference.
- CACHE_TTL = 300 sets a 5-minute TTL instead of 10 seconds. Because write-through actively updates the cache on every write, TTL here is a safety net against unbounded cache growth rather than a staleness fix.
- Replace your-rds-endpoint-here, your-rds-password-here, your-elasticache-primary-endpoint-here, and your-auth-token-here with the same values you used in lazy_loading.py.
- Below the cache connection, add the read path functions by pasting this code:
def get_product_from_db(product_id):
connection = pymysql.connect(
host=RDS_HOST,
user=RDS_USER,
password=RDS_PASSWORD,
database=RDS_DB,
port=3306,
cursorclass=pymysql.cursors.DictCursor
)
with connection:
with connection.cursor() as cursor:
cursor.execute("SELECT * FROM products WHERE id = %s", (product_id,))
result = cursor.fetchone()
if result:
result["price"] = float(result["price"])
return result
def read_product(product_id):
cache_key = f"product:{product_id}"
cached = cache.get(cache_key)
if cached:
return json.loads(cached), True
product = get_product_from_db(product_id)
if product:
cache.set(cache_key, json.dumps(product), ex=CACHE_TTL)
return product, False
What does this code do?
- get_product_from_db() is the same DB helper from your lazy loading script. It queries RDS directly and returns a product dictionary.
- read_product() implements the cache-aside read path: check cache first, if miss then query DB and store the result. This is identical to lazy loading's read logic.
Write-through only changes the WRITE path. The read path stays the same as lazy loading.
- Save the file with Ctrl+O, press Enter, then exit with Ctrl+X.
- Verify your file has no syntax errors by running:
python3 -c "import write_through; print('Module loaded OK')"
You should see Module loaded OK printed. This confirms your connections and function definitions are valid.
Seeing a connection error or syntax error?
- If you see a Redis connection error, confirm your CACHE_HOST and CACHE_AUTH values match what you used in lazy_loading.py.
- If you see a syntax error, check for missing colons at the end of def lines and matching indentation (4 spaces per level).
Help me debug my write_through.py import error.
Add the write-through function and test it
The read path is identical to lazy loading. The difference is what happens on writes. In write-through, every write updates the database AND the cache simultaneously.
- Open write_through.py again by running:
nano write_through.py
- Below the read_product function, add the write-through function by pasting this code:
def update_product_price(product_id, new_price):
connection = pymysql.connect(
host=RDS_HOST, user=RDS_USER, password=RDS_PASSWORD,
database=RDS_DB, port=3306, cursorclass=pymysql.cursors.DictCursor
)
with connection:
with connection.cursor() as cursor:
cursor.execute(
"UPDATE products SET price = %s WHERE id = %s",
(new_price, product_id)
)
connection.commit()
product = get_product_from_db(product_id)
cache_key = f"product:{product_id}"
cache.set(cache_key, json.dumps(product), ex=CACHE_TTL)
return product
What does this code do?
This is the core of write-through caching. Every price update follows three steps in order:
- First, update the database (UPDATE products SET price). The DB is always the source of truth.
- Then, read the updated product back from the DB to get the full current state.
- Finally, write that full state to the cache with cache.set() and the 300-second TTL. The cache is now instantly fresh.
Compare this to lazy loading, where you updated the DB but the cache kept serving old data until TTL expired.
- Below the update_product_price function, add the test scenario and pattern comparison by pasting this code:
print("=== Write-Through Demo ===\n")
print("1. Reading product 1 (populates cache via lazy load):")
product, was_hit = read_product(1)
print(f" {product['name']} - ${product['price']} ({'HIT' if was_hit else 'MISS'})\n")
print("2. Updating price to $249.99 via WRITE-THROUGH (DB + cache simultaneously):")
start = time.time()
product = update_product_price(1, 249.99)
elapsed = (time.time() - start) * 1000
print(f" Write completed in {elapsed:.2f}ms\n")
print("3. Reading immediately after write (should be fresh, no stale data):")
product, was_hit = read_product(1)
print(f" {product['name']} - ${product['price']} ({'HIT' if was_hit else 'MISS'})")
print(f" Cache returned $249.99 immediately - NO STALE DATA!\n")
print("=== Pattern Comparison (SAA-C03 Exam Key Points) ===\n")
print(" Lazy Loading (Cache-Aside):")
print(" + Only requested data is cached (memory efficient)")
print(" + Cache failures don't break the app (resilient)")
print(" - Cache miss penalty on first read (cold start)")
print(" - Stale data until TTL expires\n")
print(" Write-Through:")
print(" + Cache always has fresh data (no staleness)")
print(" + No cache miss penalty for written data")
print(" - Higher write latency (two writes per operation)")
print(" - Caches data that may never be read (wasted memory)")
print(" - Missing data not yet written still causes misses\n")
print(" Best Practice: Combine both + TTL")
print(" Write-through for writes + lazy loading for reads + TTL as safety net")
What does this test do?
- Step 1 reads product 1 to populate the cache (same as lazy loading).
- Step 2 updates the price to $249.99 using write-through. This updates BOTH the database and the cache in one call.
- Step 3 immediately reads the product again. If write-through works correctly, the cache should return $249.99 with no delay or staleness.
The comparison summary at the end prints the exam-relevant trade-offs between both patterns.
- Save the file with Ctrl+O, press Enter, then exit with Ctrl+X.
Before you run this, think about what you expect to see. When you read the product immediately after the write-through update, will the cache return the new price ($249.99) or the old price?
- Run the write-through demo by running:
python3 write_through.py
You should see the cache return $249.99 immediately after the write with a HIT status. No stale data. No waiting for TTL to expire.
Why is this different from lazy loading?
With lazy loading, you updated the DB and the cache still served the old price for 10 seconds. With write-through, the cache is updated at the same time as the DB. The read immediately after the write returns fresh data from cache.
The trade-off is that every write now takes longer (two operations instead of one). You also cache data that might never be read again. The SAA-C03 tests this trade-off directly.
🙋♀️ Seeing a connection refused or auth error?
- If the script fails on the cache connection, confirm your ElastiCache cluster status is still Available in the console. Check that your AUTH token matches exactly (case-sensitive).
- If you see a MySQL error, confirm the RDS instance is still running and that product ID 1 exists in the products table.
Help me troubleshoot my write_through.py connection error.
✔️ Awesome, I've got everything!
Great. Double check you saved write_through.py and that your output shows the cache returning $249.99 immediately after the write-through update.
ⓧ I'd like to double check the full code
Scroll up and confirm your write_through.py contains these sections in order:
- Imports (pymysql, redis, json, time) and configuration constants with CACHE_TTL = 300.
- Redis cache connection with ssl=True and decode_responses=True.
- get_product_from_db() function that queries RDS and converts price to float.
- read_product() function with cache-aside read logic.
- update_product_price() function that writes to DB first, then sets cache.
- Demo section: read, write-through update to $249.99, immediate read showing fresh data.
- Pattern comparison print block for SAA-C03 exam points.
You now have both caching patterns implemented and tested side by side. You saw lazy loading's stale data problem and write-through's instant consistency. The SAA-C03 best practice is to combine both: write-through for writes, lazy loading for reads, and TTL as a safety net.
Secret mission
Deploy ElastiCache Serverless and Compare
The SAA-C03 exam asks when to choose Serverless vs node-based ElastiCache. In this challenge, you will deploy an ElastiCache Serverless cache, configure IAM-based authentication (required for Serverless), connect from EC2, and compare the operational experience against your node-based cluster.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The ElastiCache cluster, RDS instance, and EC2 instance all accumulate charges while running.
Cost warning
Your node-based ElastiCache cluster and RDS instance each cost approximately $0.017/hour. Combined with EC2, that is roughly $0.05/hour while running. If you completed the Secret Mission, the ElastiCache Serverless cache has a minimum cost of $6/month for Valkey.
If you are not continuing immediately, delete these resources to avoid unexpected charges.
Resources you used:
- ElastiCache Valkey cluster (node-based, cache.t3.micro with 1 replica).
- ElastiCache Serverless cache (Secret Mission only).
- RDS MySQL instance (db.t3.micro).
- EC2 instance (t2.micro, Amazon Linux 2023).
- ElastiCache subnet group.
Keep everything running
No action needed. Choose this if you are still actively working through the project or plan to revisit within the next hour or two.
- Your resources will continue to accumulate charges at approximately $0.05/hour for node-based resources.
- If you completed the Secret Mission, the Serverless cache adds a minimum of $6/month while it exists.
- Your EC2 instance remains connected and your Python scripts are intact on it.
Pause - I'll come back to this later
Shut down running processes and stop your EC2 instance to reduce costs while keeping your configuration intact.
- In the EC2 console, select your instance, choose **Instance state**, then choose **Stop instance**. A stopped instance does not incur compute charges. It retains its data and configuration.
- Your ElastiCache cluster and RDS instance will continue to charge while running. There is no way to stop an ElastiCache cluster. If you will be away for more than a few hours, consider deleting the ElastiCache cluster and RDS instance (use the Delete tab) and recreating them later.
Delete - I don't want to use this again
Remove all project resources completely. This is the recommended path if you have finished the project.
Delete resources in this order to avoid dependency issues.
- Delete the ElastiCache Serverless cache (Secret Mission). In the ElastiCache console, choose **Valkey Caches** in the left navigation. Select the radio button next to your Serverless cache. Choose **Actions**, then **Delete**. Re-enter the cache name in the confirmation screen and choose **Delete**.
- Delete the ElastiCache node-based cluster. In the ElastiCache console, choose the engine running your cluster. Select the cluster name. Choose **Actions**, then **Delete**. In the confirmation screen, choose **Delete**. Wait until the cluster disappears from the list.
- Delete the RDS MySQL instance. Open the RDS console at https://console.aws.amazon.com/rds/. In the navigation pane, choose **Databases**. Select your database instance. Choose **Actions**, then **Delete**. Uncheck **Create final snapshot** to skip it. Enter delete me in the confirmation box and choose **Delete**.
- Terminate the EC2 instance. In the EC2 console, select your instance. Choose **Instance state**, then **Terminate instance**. Confirm the termination. This permanently deletes the instance and all Python scripts on it.
- Delete the ElastiCache subnet group. In the ElastiCache console, choose **Subnet groups** in the left navigation. Select your subnet group and choose **Delete**. This only works after both caches are fully deleted.
Nice Work!
Nice Work!
You just built a production-grade database caching layer with Amazon ElastiCache Valkey fronting RDS MySQL. You can now explain exactly when to use each caching pattern on the SAA-C03 exam, backed by code you ran yourself.
You've learned how to:
- Build a secured ElastiCache Valkey cluster with encryption at rest, encryption in transit (TLS), and AUTH token authentication fronting an RDS MySQL database.
- Implement lazy loading (cache-aside) and write-through caching patterns with measurable cache hit/miss timing to prove the performance difference.
- Experience the stale data problem firsthand and fix it with TTL-based invalidation, understanding exactly when the SAA-C03 exam wants you to pick each pattern.
- Deploy ElastiCache Serverless with IAM authentication and compare it to node-based deployment for SAA-C03 decision-making (Secret Mission).
Ready to quiz yourself?