Module 1: Redis Fundamentals
Strings and Hashes
Overview
In capsule 02 you saw SET and GET — the "Hello World" of strings in Redis. But strings have far more: atomic counters that solve race conditions without locks, batch operations that save round-trips, and flags that make SET much more expressive. If all you know is SET key value, you're missing 70% of what strings are worth in Redis.
And then there are hashes: the data type any backend developer will use more than strings. A hash in Redis is like a Python dict — an object with individual fields you can read and update one at a time, without touching the others. When you cache a user profile, a session, or an object with 10 attributes, hashes beat strings because you can update a single field (HSET user:1 last_login "...") without rewriting the whole object.
This capsule covers both data types with real backend use cases: page view counters, rate limit counters, distributed locks, object caching, user sessions, and configuration stores. By the end, when you reach module 2 (caching patterns), strings and hashes will be the foundation of everything you cache.
Strings: beyond SET and GET
A string in Redis isn't just text. It's the simplest way to store any value: a number, a serialized string (JSON, pickled bytes), a timestamp, even small binary files. But its real power is in the special operations that only apply to strings.
INCR and DECR — atomic counters
127.0.0.1:6379> SET counter 0
OK
127.0.0.1:6379> INCR counter
(integer) 1
127.0.0.1:6379> INCR counter
(integer) 2
127.0.0.1:6379> INCR counter
(integer) 3
127.0.0.1:6379> GET counter
"3"
INCR increments the value by 1 and returns the new value. If the key doesn't exist, it creates it with the value 0 before incrementing:
127.0.0.1:6379> DEL pageviews
(integer) 1
127.0.0.1:6379> INCR pageviews
(integer) 1
127.0.0.1:6379> INCR pageviews
(integer) 2
DECR does the opposite:
127.0.0.1:6379> SET stock 100
OK
127.0.0.1:6379> DECR stock
(integer) 99
127.0.0.1:6379> DECR stock
(integer) 98
Why INCR is atomic (and why that matters)
Imagine you have an API with 4 uvicorn workers. Each one handles concurrent requests. They all want to increment a views:product:42 counter.
Without INCR (using GET + SET):
value = r.get("views:product:42") # Worker A reads 100
# Worker B reads 100 (race condition!)
new_value = int(value) + 1
r.set("views:product:42", new_value) # Worker A writes 101
# Worker B writes 101 (we lost an increment)
After 2 requests, the counter should be 102 but it's 101. The counters are broken under concurrency.
With INCR:
new_value = r.incr("views:product:42") # Worker A: returns 101
# Worker B: returns 102
INCR is atomic — Redis guarantees that concurrent operations don't step on each other. You don't need locks, semaphores, or transactions. This is one of the reasons Redis is ideal for rate limiting and counters.
INCRBY and DECRBY — incrementing by N
127.0.0.1:6379> SET balance 1000
OK
127.0.0.1:6379> INCRBY balance 250
(integer) 1250
127.0.0.1:6379> DECRBY balance 100
(integer) 1150
127.0.0.1:6379> INCRBY balance -500
(integer) 650
Useful when you want to add/subtract specific values, not just 1 at a time.
INCRBYFLOAT — floats
127.0.0.1:6379> SET price 99.99
OK
127.0.0.1:6379> INCRBYFLOAT price 5.50
"105.49"
127.0.0.1:6379> INCRBYFLOAT price -10.00
"95.49"
⚠️ Note: For financial data requiring exact precision, prefer integers (cents instead of dollars) or handle the logic outside Redis. Float arithmetic can introduce precision errors.
MSET and MGET — batch operations
127.0.0.1:6379> MSET key1 "value1" key2 "value2" key3 "value3"
OK
127.0.0.1:6379> MGET key1 key2 key3
1) "value1"
2) "value2"
3) "value3"
MSET sets multiple keys in a single operation. MGET reads multiple in one go.
Why it matters: Every command to Redis costs a network round-trip (~1 ms). If you need to read 10 keys, 10 separate GETs cost 10 ms. One MGET costs 1 ms. That's 10x faster.
SETEX and PSETEX — SET shortcuts with a TTL
# SETEX = SET with EXpiration in seconds
127.0.0.1:6379> SETEX session:abc 60 "user_id=42"
OK
127.0.0.1:6379> TTL session:abc
(integer) 60
# PSETEX = SET with EXpiration in milliseconds
127.0.0.1:6379> PSETEX cache:fast 5000 "value"
OK
127.0.0.1:6379> TTL cache:fast
(integer) 4
127.0.0.1:6379> PTTL cache:fast
(integer) 4234
These are equivalent to the SET ... EX seconds and SET ... PX milliseconds you saw in capsule 02. The SET ... EX syntax is more modern and supports more flags, so that's the idiomatic one.
SETNX and SET with NX — simple locks
SETNX (SET if Not eXists) only writes if the key does NOT exist:
127.0.0.1:6379> SETNX lock:resource:42 "worker-A"
(integer) 1 # It wrote (the key didn't exist)
127.0.0.1:6379> SETNX lock:resource:42 "worker-B"
(integer) 0 # It did NOT write (the key already existed)
127.0.0.1:6379> GET lock:resource:42
"worker-A"
This gives you an atomic lock: the first worker to arrive wins, the rest fail. Combined with a TTL, it becomes a lock with an automatic timeout:
127.0.0.1:6379> SET lock:resource:42 "worker-A" NX EX 30
OK # worker-A got the lock for 30 seconds
127.0.0.1:6379> SET lock:resource:42 "worker-B" NX EX 30
(nil) # worker-B couldn't (the lock is taken)
# (worker-A finishes its work, releases the lock)
127.0.0.1:6379> DEL lock:resource:42
(integer) 1
127.0.0.1:6379> SET lock:resource:42 "worker-B" NX EX 30
OK # now worker-B can
⚠️ Note: This is a simplified version of distributed locking. For real production with many servers, use the Redlock algorithm or a dedicated library. But for simple cases (a single worker, avoiding duplicate processing), this pattern is enough.
SET with XX — only if it exists
127.0.0.1:6379> SET cache:item "initial value"
OK
127.0.0.1:6379> SET cache:item "update" XX
OK # It worked (the key existed)
127.0.0.1:6379> SET new:item "new value" XX
(nil) # It failed (the key did NOT exist)
XX is the opposite of NX: it only writes if the key already exists. Useful for "update only if it's cached, don't create it if it wasn't."
Combining flags
# SET with a TTL only if it doesn't exist (a lock with a timeout)
SET lock:42 "owner" NX EX 30
# SET with a TTL only if it exists (a cache refresh)
SET cache:product:42 "data" XX EX 300
# SET with a TTL in milliseconds
SET fast:cache "value" PX 500
Combining flags makes SET extraordinarily expressive. Memorize these three because you'll use them constantly.
STRLEN — the string's length
127.0.0.1:6379> SET name "Alex Rodriguez"
OK
127.0.0.1:6379> STRLEN name
(integer) 14
Useful for quick validations without transferring the whole value.
APPEND — concatenating at the end
127.0.0.1:6379> SET log "Start "
OK
127.0.0.1:6379> APPEND log "event1 "
(integer) 13
127.0.0.1:6379> APPEND log "event2"
(integer) 19
127.0.0.1:6379> GET log
"Start event1 event2"
Use case: an event log where you accumulate text. But for structured event queues, prefer lists (next capsule).
Hashes: structured objects
A hash in Redis is a field → value mapping, all under one key. It's like a Python dict:
# This in Python
user = {
"name": "Alex",
"email": "alex@example.com",
"age": 30
}
# Is this in Redis
HSET user:1 name "Alex" email "alex@example.com" age 30
The key difference from strings: you can read and update individual fields without touching the rest.
HSET and HGET — individual fields
127.0.0.1:6379> HSET user:1 name "Alex"
(integer) 1 # 1 new field added
127.0.0.1:6379> HSET user:1 email "alex@example.com" age 30
(integer) 2 # 2 new fields added
127.0.0.1:6379> HGET user:1 name
"Alex"
127.0.0.1:6379> HGET user:1 age
"30"
HSET returns how many NEW fields were added (overwriting an existing field counts as 0):
127.0.0.1:6379> HSET user:1 name "Alejandro"
(integer) 0 # It overwrote, it didn't add a new one
127.0.0.1:6379> HGET user:1 name
"Alejandro"
HGETALL — reading the whole object
127.0.0.1:6379> HGETALL user:1
1) "name"
2) "Alejandro"
3) "email"
4) "alex@example.com"
5) "age"
6) "30"
It returns alternating field/value pairs. In redis-cli it looks like a numbered list, but the clients (redis-py included) convert it into a dict automatically.
HMGET — several fields at once
127.0.0.1:6379> HMGET user:1 name email
1) "Alejandro"
2) "alex@example.com"
More efficient than HGETALL when you only need some fields. More efficient than multiple HGETs when you need several.
HDEL — deleting fields
127.0.0.1:6379> HDEL user:1 age
(integer) 1 # 1 field deleted
127.0.0.1:6379> HGETALL user:1
1) "name"
2) "Alejandro"
3) "email"
4) "alex@example.com"
It deletes specific field(s). The hash still exists with the remaining fields. To delete the whole hash, use DEL user:1.
HEXISTS — checking a field
127.0.0.1:6379> HEXISTS user:1 email
(integer) 1 # It exists
127.0.0.1:6379> HEXISTS user:1 phone
(integer) 0 # It doesn't exist
HKEYS and HVALS — names only or values only
127.0.0.1:6379> HKEYS user:1
1) "name"
2) "email"
127.0.0.1:6379> HVALS user:1
1) "Alejandro"
2) "alex@example.com"
Useful for iterating without transferring all the data. For example, listing the available fields without reading the values.
HLEN — how many fields
127.0.0.1:6379> HLEN user:1
(integer) 2
The equivalent of len(dict) in Python.
HINCRBY and HINCRBYFLOAT — counters inside the hash
127.0.0.1:6379> HSET stats:user:1 logins 0 page_views 0
(integer) 2
127.0.0.1:6379> HINCRBY stats:user:1 logins 1
(integer) 1
127.0.0.1:6379> HINCRBY stats:user:1 page_views 25
(integer) 25
127.0.0.1:6379> HGETALL stats:user:1
1) "logins"
2) "1"
3) "page_views"
4) "25"
HINCRBY is like INCR but for a field inside a hash. Atomic just like INCR: safe under concurrency.
A real use case: per-user statistics. Instead of having 5 keys (stats:user:1:logins, stats:user:1:views, etc.), you have 1 hash with 5 fields. Better organization + less memory overhead.
Strings vs Hashes: when to use which
This is the most common decision you'll make in Redis. The simple rule:
Use strings when:
- ✅ The value is atomic (a number, a string, a serialized blob)
- ✅ You're going to read/write the whole value in every operation
- ✅ You need global counters (
INCR pageviews:total) - ✅ You need locks or flags (
SETNX lock:resource:42) - ✅ The value is small JSON and you always replace it whole
SET page:home:visits 12450
SET feature_flag:new_ui "enabled"
SET cache:api:/products "[{...}, {...}]" # Cached JSON, overwritten whole
Use hashes when:
- ✅ You have an object with multiple fields
- ✅ You're going to update individual fields (not the whole object)
- ✅ You want counters per field (
HINCRBY stats logins 1) - ✅ You sometimes need to read only some fields (
HMGET user:1 name email)
HSET user:1 name "Alex" email "alex@example.com" age 30 last_login "2026-04-25T10:00:00"
HSET session:abc123 user_id 1 logged_in_at "2026-04-25T10:00:00" ip "192.168.1.1"
HSET product:42 name "Laptop" price 99999 stock 15 category_id 3
Side-by-side comparison
Same data, two ways:
# As a string (serialized JSON)
SET user:1 '{"name":"Alex","email":"alex@example.com","age":30}'
# To update the email:
GET user:1 # Read EVERYTHING
# You modify it in code...
SET user:1 '{"name":"Alex","email":"new@example.com","age":30}' # Write EVERYTHING
# vs
# As a hash
HSET user:1 name "Alex" email "alex@example.com" age 30
# To update the email:
HSET user:1 email "new@example.com" # Only writes the changed field
Trade-offs:
| Aspect | String (JSON) | Hash |
|---|---|---|
| Updating one field | Read everything + write everything | Only writes the field |
| Reading one field | Read everything + parse JSON | HGET directly |
| Memory with many fields | Higher (JSON overhead) | Lower (compact representation) |
| Update atomicity | You need WATCH/MULTI | HSET is already atomic |
| Field types | Strings only (JSON serializes them) | Strings only as well, but HINCRBY gives you numbers |
Practical rule: If you're going to have objects with 3+ fields that are updated independently, use hashes. If it's an opaque value you always read/write whole, strings.
Real backend use cases
Case 1: A product view counter
Goal: Every time someone views /products/42, increment a counter.
# In your FastAPI endpoint
@app.get("/products/{product_id}")
def get_product(product_id: int):
r.incr(f"views:product:{product_id}")
# ... return product data
# In Redis
INCR views:product:42
INCR views:product:42
INCR views:product:42
GET views:product:42
"3"
Why strings with INCR: An atomic counter, simple, no race conditions. Perfect.
Case 2: Caching an API response
Goal: Cache GET /products for 5 minutes so you don't hit PostgreSQL on every request.
@app.get("/products")
def list_products():
cached = r.get("cache:api:products")
if cached:
return json.loads(cached)
products = db.query(Product).all()
serialized = [p.to_dict() for p in products]
r.set("cache:api:products", json.dumps(serialized), ex=300)
return serialized
# In Redis
SET cache:api:products '[{"id":1,...},...]' EX 300
GET cache:api:products
Why strings with a TTL: The JSON is treated as an opaque blob. It's always read/written whole. Automatic TTL.
Case 3: A cached user profile
Goal: Cache user data, but allow updating the last_login field without touching the rest.
@app.get("/users/{user_id}")
def get_user(user_id: int):
cached = r.hgetall(f"user:{user_id}")
if cached:
return cached
user = db.query(User).get(user_id)
user_dict = {
"name": user.name,
"email": user.email,
"age": str(user.age),
"last_login": user.last_login.isoformat()
}
r.hset(f"user:{user_id}", mapping=user_dict)
r.expire(f"user:{user_id}", 600)
return user_dict
@app.post("/users/{user_id}/login")
def record_login(user_id: int):
# Only updates last_login, doesn't touch name/email/age
r.hset(f"user:{user_id}", "last_login", datetime.now().isoformat())
Why a hash: Granular updates — you don't need to read and rewrite the whole object.
Case 4: A distributed lock for an idempotent task
Goal: Make sure an order-processing job only runs once, even if the event is duplicated.
def process_order(order_id: int):
lock_key = f"lock:process_order:{order_id}"
acquired = r.set(lock_key, "processing", nx=True, ex=60)
if not acquired:
logger.info(f"Order {order_id} already being processed, skipping")
return
try:
# ... the job's logic
process_payment(order_id)
send_confirmation_email(order_id)
finally:
r.delete(lock_key)
Why SETNX: Atomic, automatic cleanup with a TTL if the worker crashes, simple.
Case 5: A rate limit counter per IP (basic version)
Goal: Limit to 100 requests per minute per IP.
def check_rate_limit(ip: str) -> bool:
key = f"rate_limit:ip:{ip}"
current = r.incr(key)
if current == 1:
r.expire(key, 60) # first request: 60-second TTL
if current > 100:
return False # rate limited
return True
Why INCR + EXPIRE: Module 3 will cover professional rate limiting with a sliding window. This "fixed window" version is simple and works for basic cases.
Case 6: Feature flag configuration
Goal: Enable/disable features without a redeploy.
def is_feature_enabled(feature: str) -> bool:
enabled = r.hget("config:features", feature)
return enabled == "true"
# An admin operation (toggling from a panel)
def toggle_feature(feature: str, enabled: bool):
r.hset("config:features", feature, "true" if enabled else "false")
# In Redis
HSET config:features new_checkout "true" recommendations "true" beta_ui "false"
HGET config:features new_checkout
"true"
Why a hash: Multiple features under a single key, easy to read everything (HGETALL config:features), granular updates per feature.
Troubleshooting
Problem 1: (error) WRONGTYPE Operation against a key holding the wrong kind of value
Cause: You're using a hash command on a key that's a string (or vice versa).
127.0.0.1:6379> SET user:1 "Alex"
OK
127.0.0.1:6379> HGET user:1 name
(error) WRONGTYPE Operation against a key holding the wrong kind of value
Solution: Check the type with TYPE:
127.0.0.1:6379> TYPE user:1
string
If you need to convert it, delete the key first:
127.0.0.1:6379> DEL user:1
(integer) 1
127.0.0.1:6379> HSET user:1 name "Alex"
(integer) 1
Problem 2: INCR fails with (error) ERR value is not an integer
Cause: You're trying to INCR a string that isn't a number.
127.0.0.1:6379> SET counter "ten"
OK
127.0.0.1:6379> INCR counter
(error) ERR value is not an integer or out of range
Solution: Make sure the initial value is a number (or doesn't exist — in which case INCR creates it with the value 0).
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> INCR counter
(integer) 11
Problem 3: HMSET is deprecated in Redis 4+
Cause: You're following an old tutorial. HMSET (multi-set) was replaced by HSET, which now accepts multiple pairs.
Solution: Use HSET:
# Old (it still works but it's deprecated):
HMSET user:1 name "Alex" age 30
# Modern:
HSET user:1 name "Alex" age 30
Problem 4: Hashes show values as strings, not as numbers
Cause: Redis stores everything as bytes/strings. It doesn't distinguish the string "30" from the number 30.
127.0.0.1:6379> HSET stats:user:1 logins 5
(integer) 1
127.0.0.1:6379> HGET stats:user:1 logins
"5" # A string, even though it looks like a number
Solution: For numeric operations use HINCRBY (Redis treats it as an int internally). In your Python code, convert explicitly:
logins = int(r.hget("stats:user:1", "logins") or 0)
Problem 5: SET with NX doesn't honor the TTL after the first SET
Cause: If NX fails (because the key exists), the command does nothing — including not updating the TTL.
127.0.0.1:6379> SET lock:42 "A" NX EX 30
OK
# 25 seconds later, another attempt:
127.0.0.1:6379> SET lock:42 "B" NX EX 60
(nil) # It failed (the key existed)
127.0.0.1:6379> TTL lock:42
(integer) 5 # The original TTL, it was NOT renewed to 60
Solution: This is expected behavior for locks. If you want to "extend" an existing lock, use EXPIRE separately (after verifying ownership).
Problem 6: HGETALL returns an empty list on a freshly created hash
Cause: The hash was created but all its fields were deleted or expired.
Solution: Remember that a hash with no fields is deleted automatically:
127.0.0.1:6379> HSET user:1 name "Alex"
(integer) 1
127.0.0.1:6379> HDEL user:1 name
(integer) 1
127.0.0.1:6379> EXISTS user:1
(integer) 0 # The hash disappeared once it ran out of fields
Exercises
Exercise 1: A page view counter (Easy)
Implement a page view counter for 3 pages: /home, /products, /about. Use one key per page (pageviews:home, etc.). Increment each one 5 times. Check the totals.
See solution
127.0.0.1:6379> INCR pageviews:home
(integer) 1
# ... repeat 5 times for each page
# The quick way with bash:
# for page in home products about; do
# for i in {1..5}; do
# redis-cli INCR "pageviews:$page"
# done
# done
127.0.0.1:6379> MGET pageviews:home pageviews:products pageviews:about
1) "5"
2) "5"
3) "5"
Explanation: INCR creates the key with the value 0 if it doesn't exist, then increments. Using MGET you read all 3 in a single operation. Atomic — if your API has 4 workers, they can all increment concurrently without losing counts.
Exercise 2: Cache with a TTL (Easy)
Cache the result of a "product search" (a simulated JSON string) for 60 seconds. Check that it exists and read the value. Wait 65 seconds and verify it's gone.
See solution
127.0.0.1:6379> SET cache:search:laptops '[{"id":1,"name":"MacBook"},{"id":2,"name":"ThinkPad"}]' EX 60
OK
127.0.0.1:6379> TTL cache:search:laptops
(integer) 58
127.0.0.1:6379> GET cache:search:laptops
"[{\"id\":1,\"name\":\"MacBook\"},{\"id\":2,\"name\":\"ThinkPad\"}]"
# (wait 65 seconds)
127.0.0.1:6379> GET cache:search:laptops
(nil)
127.0.0.1:6379> EXISTS cache:search:laptops
(integer) 0
Explanation: This is exactly cache-aside (module 2). You store a value with a TTL, you read it while it's alive, Redis cleans it up on its own. The strategy comes down to the TTL: how long is it acceptable to serve a "stale" result?
Exercise 3: A user profile with a hash (Easy-Medium)
Create a hash user:42 with the fields: name, email, age, country. Read only name and email. Update age. Check the complete hash.
See solution
127.0.0.1:6379> HSET user:42 name "Sofia" email "sofia@example.com" age 28 country "Mexico"
(integer) 4
127.0.0.1:6379> HMGET user:42 name email
1) "Sofia"
2) "sofia@example.com"
127.0.0.1:6379> HSET user:42 age 29
(integer) 0 # 0 because it updated an existing field, it didn't add a new one
127.0.0.1:6379> HGETALL user:42
1) "name"
2) "Sofia"
3) "email"
4) "sofia@example.com"
5) "age"
6) "29"
7) "country"
8) "Mexico"
Explanation: HMGET is more efficient than two separate HGETs — a single round-trip. HSET with a single field updates it without affecting the others. HGETALL reads everything.
Exercise 4: A distributed lock for a single-run job (Medium)
Implement a lock to make sure the "end of day job" only runs once. Use SET ... NX EX 300. Simulate worker A acquiring the lock, another worker B trying to acquire it (and failing), and then releasing it with DEL after "completing the work."
See solution
# Worker A tries to acquire the lock
127.0.0.1:6379> SET lock:end_of_day_job "worker-A" NX EX 300
OK # It got it
# Worker B tries to acquire the lock (another process, in parallel)
127.0.0.1:6379> SET lock:end_of_day_job "worker-B" NX EX 300
(nil) # It did NOT get it (the lock is taken)
# Check who holds the lock
127.0.0.1:6379> GET lock:end_of_day_job
"worker-A"
# Check the lock's TTL (auto-cleanup if worker-A crashes)
127.0.0.1:6379> TTL lock:end_of_day_job
(integer) 295
# Worker A finishes the work and releases the lock
127.0.0.1:6379> DEL lock:end_of_day_job
(integer) 1
# Now worker B (or anyone) can acquire the lock
127.0.0.1:6379> SET lock:end_of_day_job "worker-B" NX EX 300
OK
Explanation: This pattern is the "fence" around idempotent jobs. The TTL is critical: if worker-A crashes without releasing the lock, after 300 seconds Redis releases it automatically. Without a TTL, a crash would leave the system blocked forever.
A professional improvement: In production, verify ownership before releasing so one worker doesn't release another's lock (a race condition if worker-A is slow and worker-B grabs the lock after the TTL):
import uuid
worker_id = str(uuid.uuid4())
lock_acquired = r.set("lock:job", worker_id, nx=True, ex=300)
if lock_acquired:
try:
# ... do work
pass
finally:
# Only release if WE are the owner
if r.get("lock:job") == worker_id:
r.delete("lock:job")
Exercise 5: User stats with HINCRBY (Medium)
Keep statistics for a user: logins, page_views, purchases. Use a hash stats:user:42. Increment logins 3 times, page_views 50 times, purchases 2 times. Read the whole hash.
See solution
127.0.0.1:6379> HINCRBY stats:user:42 logins 1
(integer) 1
127.0.0.1:6379> HINCRBY stats:user:42 logins 1
(integer) 2
127.0.0.1:6379> HINCRBY stats:user:42 logins 1
(integer) 3
127.0.0.1:6379> HINCRBY stats:user:42 page_views 50
(integer) 50
127.0.0.1:6379> HINCRBY stats:user:42 purchases 1
(integer) 1
127.0.0.1:6379> HINCRBY stats:user:42 purchases 1
(integer) 2
127.0.0.1:6379> HGETALL stats:user:42
1) "logins"
2) "3"
3) "page_views"
4) "50"
5) "purchases"
6) "2"
Explanation: HINCRBY is like INCR but for hash fields. Atomic, no read-modify-write needed, no need to initialize the field. If the field doesn't exist, it creates it with 0 before incrementing.
A real use case: Instead of having 3 separate keys (stats:user:42:logins, etc.), you have 1 hash with 3 fields. Better organization + less memory overhead. And HGETALL stats:user:42 gives you the complete snapshot in a single operation.
Exercise 6: Migrating a JSON string to a hash (Hard)
You have a user profile stored as JSON: SET user:1 '{"name":"Alex","email":"alex@x.com","age":30,"country":"AR"}'. Migrate this to a hash user:1 (after DEL, of course). Verify that you can update only age without rewriting everything. Measure the difference in the commands needed to update age before and after the migration.
See solution
# Before (with a JSON string)
127.0.0.1:6379> SET user:1 '{"name":"Alex","email":"alex@x.com","age":30,"country":"AR"}'
OK
# To update age, you need to:
# 1. GET the whole JSON
# 2. Parse it in code
# 3. Modify age
# 4. Serialize it again
# 5. SET the whole JSON
# Total: 2 round-trips to Redis + parsing/serialization on the client
# Migrating to a hash
127.0.0.1:6379> DEL user:1
(integer) 1
127.0.0.1:6379> HSET user:1 name "Alex" email "alex@x.com" age 30 country "AR"
(integer) 4
# To update age:
127.0.0.1:6379> HSET user:1 age 31
(integer) 0 # 0 because it updated an existing field
# Total: 1 round-trip, no parsing.
127.0.0.1:6379> HGETALL user:1
1) "name"
2) "Alex"
3) "email"
4) "alex@x.com"
5) "age"
6) "31"
7) "country"
8) "AR"
Comparison:
| Operation | As a JSON string | As a hash |
|---|---|---|
| Read everything | 1 GET + parse | 1 HGETALL |
| Read one field | 1 GET + parse + extract | 1 HGET |
| Update one field | 1 GET + parse + 1 SET | 1 HSET |
| Update atomicity | Needs WATCH/MULTI | Native |
Explanation: Hashes win when you have objects with fields that are updated independently. A JSON string only wins if the object is always treated as an opaque blob (read/written whole, no partial updates). In backend work with CRUD APIs, partial updates are the norm — use hashes.
Summary
In this capsule you learned:
Strings (beyond SET/GET):
INCR/DECR/INCRBY: atomic counters that are safe under concurrency, the foundation of rate limiting and statsMSET/MGET: batch operations to cut round-trips (10x faster than 10 GETs)SET ... EX seconds: SET with a TTL in a single operation (more efficient than SET + EXPIRE)SET ... NX: an atomic lock, writes only if the key doesn't exist (with a TTL = a lock with an automatic timeout)SET ... XX: updates only if it exists (a cache refresh that doesn't create the key if it was absent)- Use cases: counters, response caching, locks, basic rate limiting, simple feature flags
Hashes (structured objects):
HSET/HGET: individual fields, like a Python dictHGETALL/HMGET: full or selective readsHDEL/HEXISTS/HKEYS/HVALS/HLEN: complementary operationsHINCRBY/HINCRBYFLOAT: atomic counters per field- Use cases: user profiles, sessions, aggregated stats, configuration
The strings vs hashes decision:
- Strings: atomic values, opaque blobs, global counters, locks
- Hashes: objects with multiple updatable fields, stats by category, structured configuration
Critical rules:
INCR/HINCRBYare atomic by design — you don't need external locksSET ... NX EXis the idiomatic pattern for simple distributed locks- If your object has 3+ fields that are updated independently: use a hash
HMSETis deprecated in Redis 4+; useHSETwith multiple pairs
Additional resources
- Redis Strings Tutorial — Official docs with every string command
- Redis Hashes Tutorial — Official docs with every hash command
- SET command reference — Every flag (EX, PX, NX, XX, KEEPTTL, GET) in detail
- HSET command reference — The syntax with multiple pairs and its behavior
- Redis Distributed Locks — The official Redlock pattern for robust locks in production
- Redis Memory Optimization — How hashes save memory vs multiple strings (ziplist encoding)
What's next?
In Capsule 04 you cover the 3 remaining data types: Lists (FIFO/LIFO queues for event logs and job queues), Sets (unique collections for tags and tracking), and Sorted Sets (rankings with a score — the foundation of module 3's professional rate limiting). Sorted sets are especially important; we're going to give them time and depth because without them, sliding window rate limiting (module 3) would be inaccessible.
Keep Redis running. If you want to clean up what you did in this capsule:
127.0.0.1:6379> FLUSHDB
OK
Let's go.