Filter concepts by levelShowing all levels.

Python · What a 5-Year Python Engineer Should Be Able to Explain

Database

Concepts
6

All eight roadmap questions in this subheading are answered here. The PostgreSQL and MongoDB topics teach the mechanics far more deeply, and are linked throughout — but coverage is computed per topic against that topic's own roadmap (validate-content.js check 11), so a PostgreSQL concept cannot credit a line in the Python roadmap the way an earlier draft of this file assumed. Python's own §25 Databases was removed from scope (CONTENT_REMOVAL.md), which leaves no other home for them. So each is authored here as what a Python engineer must be able to explain — the decision and the failure it prevents — pointing at the owning topic for the depth rather than re-deriving it.

Python overview

Database

The six database answers a backend engineer is expected to give without notes, each stated as the decision it drives — with the PostgreSQL and MongoDB topics linked for the mechanics behind them.

What an index is, and when it hurts

coreintermediate

An index is a second, sorted copy of one or more columns, kept alongside the table and pointing back at the rows. It turns "look at every row until you find it" into "jump straight to it". It hurts when the database has to maintain it on every write, or when the query it is meant to help matches most of the table anyway.

Think of it as

An index is the alphabetical index at the back of a book. Finding "deadlock" takes seconds instead of reading every page — but the index only exists because someone built it, it takes up pages of its own, and every time the book is revised the index has to be rebuilt too. A book with an index for every word on every page would be mostly index.

What we're doing: Count the comparisons a lookup costs with and without an index, then show the two ways an index stops being worth it.

index_behaviour.pypython
rows = [(i, f"user{i}@example.com") for i in range(1, 100_001)]


def full_scan(target):
    """No index: look at each row until the value matches."""
    comparisons = 0
    for row_id, email in rows:
        comparisons += 1
        if email == target:
            return row_id, comparisons
    return None, comparisons


# The index: the same column, kept sorted, pointing back at the row.
email_index = sorted((email, row_id) for row_id, email in rows)


def index_lookup(target):
    """With an index: halve the remaining range on every comparison."""
    lo, hi, comparisons = 0, len(email_index), 0
    while lo < hi:
        mid = (lo + hi) // 2
        comparisons += 1
        if email_index[mid][0] < target:
            lo = mid + 1
        else:
            hi = mid
    if lo < len(email_index) and email_index[lo][0] == target:
        return email_index[lo][1], comparisons
    return None, comparisons


target = "user99999@example.com"
print("full scan   ->", full_scan(target))
print("index lookup->", index_lookup(target))

# Cost 1: every index is written on every insert.
for n in (0, 1, 3, 5):
    print(f"{n} indexes -> {1 + n} structures written per INSERT")

# Cost 2: an index on a column with two lopsided values.
from collections import Counter

statuses = ["archived" if i % 20 == 0 else "active" for i in range(100_000)]
counts = Counter(statuses)
for value in ("active", "archived"):
    share = counts[value] / len(statuses)
    print(f"status = {value!r}: {counts[value]} rows, {share:.1%} of the table")
7
The scan has no choice but to compare every row until it matches — the cost grows in step with the table.
15
This is the index: the same data, sorted, with a pointer home. It is a second structure, which is exactly why it costs something to keep.
35
99,999 comparisons against 17 for the same answer. That ratio is the entire argument for indexes.
39
And the entire argument against adding them freely: the write path pays for every index, on every insert, forever.
Output
full scan   -> (99999, 99999)
index lookup-> (99999, 17)
0 indexes -> 1 structures written per INSERT
1 indexes -> 2 structures written per INSERT
3 indexes -> 4 structures written per INSERT
5 indexes -> 6 structures written per INSERT
status = 'active': 95000 rows, 95.0% of the table
status = 'archived': 5000 rows, 5.0% of the table

Why this works: The same index is excellent and useless depending on the question asked of it. Looking up one email goes from 99,999 comparisons to 17 — a real, order-of-magnitude win. But an index on `status` cannot help `WHERE status = 'active'`, because that matches 95,000 of 100,000 rows: following the index to 95% of the table, row by row, is slower than reading the table in order, so the planner ignores the index and scans. The same index on `WHERE status = 'archived'` is genuinely useful at 5%. Selectivity, not the existence of the index, decides.

Adding an index for every column that appears in a WHERE clause

Wrong

sql
CREATE INDEX ON orders (status);
CREATE INDEX ON orders (customer_id);
CREATE INDEX ON orders (created_at);
CREATE INDEX ON orders (updated_at);
CREATE INDEX ON orders (currency);
-- "every query is covered now"

Better

sql
-- Index the selective predicates the slow queries actually use.
CREATE INDEX ON orders (customer_id, created_at DESC);

-- status is 95% one value: a partial index costs almost nothing
-- and serves the only query that needs it.
CREATE INDEX ON orders (created_at) WHERE status = 'archived';

What you see: Reads are no faster than before — the planner was never going to use most of those indexes — and writes have become measurably slower, because every insert now updates six structures instead of one. Bulk imports that used to take minutes take an hour.

Why: An index is a permanent tax on the write path in exchange for a conditional benefit on the read path, and the benefit only arrives when a query is both slow and selective. Indexing a column with two lopsided values buys nothing for the common predicate, because the planner correctly refuses to use it. The rule that survives contact with production is to index from the slow-query list, not from the column list.

Remember: An index is a sorted copy of a column that turns a full scan into a seek — about 17 comparisons instead of 100,000 on a 100k-row table. It costs a write on every insert, update and delete, and it buys nothing for a predicate matching most of the table. Index from the slow-query list, not from the column list.

See also: why indexes trade storage and write cost for read speed · selectivity and cardinality · leftmost prefix behavior · why indexing every column is harmful · optimizing a slow query

What a transaction is, and what isolation means

coreintermediate

A transaction is a group of statements that either all take effect or none of them do. Isolation is the separate question of what one transaction can see of another one that is running at the same time. The first protects you from crashing halfway; the second protects you from other people.

Think of it as

Atomicity is the bracket around the work: nothing inside it is visible until the closing bracket, and an error throws the whole bracket away. Isolation is how soundproof the bracket is — at the loosest setting you overhear other transactions mid-sentence, at the strictest the database behaves as if yours were the only one running. Every level between the two trades a guarantee for concurrency.

What we're doing: Show atomicity as all-or-nothing, then show the lost update that isolation exists to prevent.

transactions.pypython
class Store:
    def __init__(self, data):
        self.data = dict(data)

    def transaction(self):
        return Txn(self)


class Txn:
    """Writes are buffered and applied only on a clean exit."""

    def __init__(self, store):
        self.store = store
        self.pending = {}

    def __enter__(self):
        return self

    def get(self, key):
        return self.pending.get(key, self.store.data[key])

    def set(self, key, value):
        self.pending[key] = value

    def __exit__(self, exc_type, exc, tb):
        if exc_type is None:
            self.store.data.update(self.pending)
        # An exception leaves self.pending unapplied: that is the rollback.
        return False


store = Store({"alice": 100, "bob": 50})
print("start         :", store.data)

# A transfer that fails after the debit. Nothing must reach the store.
try:
    with store.transaction() as tx:
        tx.set("alice", tx.get("alice") - 30)
        raise RuntimeError("card declined")
except RuntimeError as exc:
    print("failed        :", exc)
print("after rollback:", store.data)

# The same transfer, completing.
with store.transaction() as tx:
    tx.set("alice", tx.get("alice") - 30)
    tx.set("bob", tx.get("bob") + 30)
print("after commit  :", store.data)

# Isolation: two transactions read 100, each computes from its own read.
balance = 100
t1_read = balance
t2_read = balance
balance = t1_read - 30   # T1 commits 70
balance = t2_read - 50   # T2 commits 50, and T1's withdrawal is gone
print("read-modify-write ->", balance)

# The same two withdrawals as relative updates.
balance = 100
balance -= 30
balance -= 50
print("relative update   ->", balance)
14
Buffering the writes is what makes the block atomic — the store never sees a half-finished transfer, because it sees nothing at all until the end.
26
Commit is "apply the buffer", so rollback is simply never reaching this line. A real database has more work to do; the guarantee it offers is this one.
39
Alice is debited and the failure happens before Bob is credited. Without the transaction, 30 units would have vanished.
53
Both transactions read 100 before either wrote. Neither did anything wrong on its own, and one withdrawal has disappeared.
60
Expressing the change as relative rather than absolute removes the race, with no isolation level involved.
Output
start         : {'alice': 100, 'bob': 50}
failed        : card declined
after rollback: {'alice': 100, 'bob': 50}
after commit  : {'alice': 70, 'bob': 80}
read-modify-write -> 50
relative update   -> 20

Why this works: The two halves answer two different questions. Atomicity handles the failure that is yours: the transfer that dies between the debit and the credit leaves the store untouched, so money is never destroyed by a crash. Isolation handles the failure that belongs to someone else: both withdrawals succeeded, neither raised, and 30 units are gone anyway because the second write was computed from a value that was already stale. That is why "we wrap it in a transaction" is not by itself an answer to a concurrency bug — the transaction was there, and it was the isolation level and the read-modify-write shape that let the update be lost.

Catching the exception inside the transaction block

Wrong

python
with store.transaction() as tx:
    tx.set("alice", tx.get("alice") - 30)
    try:
        charge_card()          # raises
    except PaymentError:
        log.warning("payment failed")   # swallowed
    tx.set("bob", tx.get("bob") + 30)   # never reached in the real flow

Better

python
try:
    with store.transaction() as tx:
        tx.set("alice", tx.get("alice") - 30)
        charge_card()                    # raises out of the with block
        tx.set("bob", tx.get("bob") + 30)
except PaymentError:
    log.warning("payment failed")        # handled OUTSIDE, after rollback

What you see: Running the wrong version leaves `{'alice': 70, 'bob': 50}` — Alice debited, Bob never credited, and no error anywhere. The block exited cleanly, so the transaction committed the half it had.

Why: The transaction rolls back because an exception propagates out of the block, and swallowing it inside removes the only signal the transaction had. The handler belongs outside the `with`, where it runs after the rollback has already happened. This is the single most common way a transaction that is present in the code fails to protect anything.

Remember: A transaction is all-or-nothing; isolation is what you can see of everyone else's. Handle the exception outside the block or the rollback never happens, keep network calls out of open transactions, and prefer a relative or conditional update to read-modify-write — that shape loses updates at every isolation level PostgreSQL uses by default.

See also: atomicity and transaction boundaries · dirty non repeatable and phantom reads · postgresqls behavior under its isolation levels · optimistic vs pessimistic concurrency · what causes database deadlocks

What causes database deadlocks

standardadvanced

A deadlock happens when two transactions each hold a lock the other one needs, and each is waiting for the other to release it first — neither can proceed, so the database detects the cycle and kills one transaction (rolling it back) to break it. The classic cause is two transactions locking the same two rows in opposite order.

Think of it as

Two transactions locking rows in opposite order is two people trying to pass each other in a single-lane hallway — Transaction A grabs row 1 and waits for row 2 (which B holds); Transaction B grabs row 2 and waits for row 1 (which A holds). Neither can back up on their own. The database is the traffic cop who eventually notices the standoff and forces one car to reverse (rolls back one transaction) so the other can get through.

python
def has_deadlock_cycle(waits_for: dict) -> bool:
    """waits_for: {tx_id: tx_id_it_is_blocked_on}. A deadlock exists if
    following the chain from any transaction leads back to itself."""
    for start in waits_for:
        seen, current = set(), start
        while current in waits_for:
            if current in seen:
                return True
            seen.add(current)
            current = waits_for[current]
    return False

What we're doing: Model two transactions locking two rows in opposite order as a wait-for graph, and detect the resulting deadlock cycle the same way a database's deadlock detector would.

deadlock_detection.pypython
def has_deadlock_cycle(waits_for):
    """waits_for: {tx_id: tx_id_it_is_blocked_on}."""
    for start in waits_for:
        seen, current = set(), start
        while current in waits_for:
            if current in seen:
                return True
            seen.add(current)
            current = waits_for[current]
    return False


# Transaction A: locked row 1, now wants row 2 (held by B) -> waits on B
# Transaction B: locked row 2, now wants row 1 (held by A) -> waits on A
opposite_order = {"A": "B", "B": "A"}
print("opposite lock order deadlocks:", has_deadlock_cycle(opposite_order))

# Both transactions lock row 1 THEN row 2, in the SAME order -- B simply
# waits for A to finish and release row 1; no cycle, no deadlock
same_order = {"B": "A"}
print("consistent lock order deadlocks:", has_deadlock_cycle(same_order))
14
A waits on B and B waits on A — following the chain from either one leads back to itself, which is exactly the cycle a deadlock detector looks for.
18
With consistent lock ordering, only B waits on A — following that chain from B reaches A, which is not itself waiting on anyone, so there is no cycle.
Output
opposite lock order deadlocks: True
consistent lock order deadlocks: False

Why this works: The only structural difference between the two scenarios is lock ORDER, not which rows are involved or how many transactions there are — opposite_order creates a genuine A-waits-B-waits-A cycle that has_deadlock_cycle correctly flags, while same_order has only a one-directional wait (B waits on A, nothing waits on B), which resolves the moment A commits and releases its lock, exactly the fix real deadlock prevention relies on.

Treating a deadlock and ordinary lock contention as the same problem

Wrong

python
# "The transaction is stuck waiting on a lock -- must be a deadlock,
# let's just retry it and hope it clears"
# (retrying blind treats every stall the same way, whether or not
# a real cycle exists)

Better

python
# Ordinary contention: transaction just waits -- it WILL proceed once the
# lock holder commits. No cycle, no error, nothing to fix but throughput.
#
# Real deadlock: the database itself detects the cycle and raises an
# error (e.g. psycopg2.errors.DeadlockDetected) -- THAT is the signal
# to actually fix lock ordering, not every slow-to-acquire lock.

What you see: A transaction that is simply waiting its turn behind a long-running transaction gets misdiagnosed as "deadlocked" and retried repeatedly, when it was always going to succeed on its own once the first transaction finished — the real fix (shortening the long-running transaction) never gets identified.

Why: Contention is normal and self-resolving: one transaction waits, the lock holder eventually commits, the waiter proceeds. A deadlock is structurally different — a genuine cycle that can NEVER resolve on its own, which is exactly why PostgreSQL raises a distinct, explicit error for it rather than just making both transactions wait indefinitely. Conflating the two means fixing the wrong problem: a deadlock error means "fix lock ordering," while ordinary slow contention means "look at why the lock is held so long."

Remember: A deadlock is a genuine cycle — A waits on a lock B holds while B waits on a lock A holds — almost always caused by two transactions locking the same rows in opposite order; the fix is consistent lock ordering, not blind retries.

See also: select for update and row locking · race conditions in real workflows · nowait and skip locked

How to optimize a slow query

standardadvanced

Before touching a query planner, run through a fixed checklist in order: is there an index on the columns in WHERE/JOIN/ORDER BY, is the query actually a disguised N+1 (one query per row instead of one query total), is it selecting more columns/rows than it needs (SELECT * with no LIMIT), and only after ruling those out does reading an actual execution plan become the next step.

Think of it as

A slow query is a symptom with several common causes, in a rough order of how often each one turns out to be the answer — checking them in that order before opening a query plan is what separates "diagnosed in two minutes" from "guessed for an hour." A missing index or a hidden N+1 explains the vast majority of slow queries in ordinary application code, long before anything genuinely needs a query planner's help.

python
CHECKLIST = [
    "1. Index on WHERE/JOIN/ORDER BY columns?",
    "2. Disguised N+1 (query-per-row instead of one query)?",
    "3. SELECT * / missing LIMIT pulling more than needed?",
    "4. WHERE clause sargable (no function wrapping an indexed column)?",
    "5. Only now: read EXPLAIN ANALYZE",
]

What we're doing: Run a simulated N+1 pattern and its single-query fix side by side, counting actual query executions to make the diagnostic concrete rather than abstract.

diagnose_slow_query.pypython
queries_executed = []

def run_query(label):
    queries_executed.append(label)
    return f"result of {label}"


def n_plus_one_version(order_ids):
    queries_executed.clear()
    run_query("SELECT * FROM orders")           # 1 query to get orders
    for order_id in order_ids:
        run_query(f"SELECT * FROM items WHERE order_id={order_id}")   # N more queries
    return len(queries_executed)


def single_query_version(order_ids):
    queries_executed.clear()
    run_query("SELECT * FROM orders JOIN items ON items.order_id = orders.id")
    return len(queries_executed)


order_ids = [1, 2, 3, 4, 5]
print("N+1 version query count:", n_plus_one_version(order_ids))
print("JOIN version query count:", single_query_version(order_ids))
9
One query fetches the orders — this alone is not the problem.
10
A query INSIDE the loop, one per order_id, is the N+1 pattern — the "slow query" a profiler flags is really N separate round trips, not one query that itself is slow.
17
A single JOIN query replaces all N+1 of the loop version with exactly one round trip, regardless of how many orders exist.
Output
N+1 version query count: 6
JOIN version query count: 1

Why this works: With 5 order_ids, the N+1 version executes 1 + 5 = 6 total queries — a count that GROWS with the number of orders — while the JOIN version always executes exactly 1, regardless of how many orders exist. A profiler watching total time spent in the database would see the N+1 version as "slow," but the actual fix has nothing to do with indexes or query plans on any single query — it is replacing N+1 round trips with one.

Jumping straight to "add an index" before checking whether the real problem is an N+1

Wrong

python
# profiler shows 200ms spent in the database for this page load
# "must need an index" -- adds an index, page load barely improves
# (because it was 50 queries of 4ms each, not one slow 200ms query)

Better

python
# check query COUNT first, not just total time:
# connection.queries shows 50 near-identical queries -> N+1, not a slow query
# fix: eager-load with a JOIN or prefetch, THEN re-measure before considering an index

What you see: Adding an index does not meaningfully improve the page load time, because the real cost was 50 round trips to the database (network + connection overhead each), not any single query being slow enough for an index to fix.

Why: Total time in the database and query COUNT are different signals pointing at different fixes — an index speeds up one query that is doing unnecessary work per execution; eager loading (JOIN or a prefetch) reduces the NUMBER of round trips. Checking query count (via connection.queries, or an ORM's query-logging) before reaching for an index is what tells these two problems apart instead of guessing.

Remember: Check in order: index on the WHERE/JOIN/ORDER BY columns, a disguised N+1 (query count, not just total time), SELECT * / missing LIMIT, a non-sargable WHERE clause — only then read an actual EXPLAIN ANALYZE plan.

See also: why indexes trade storage and write cost for read speed · selectivity and cardinality · the n plus 1 pattern

SQL vs NoSQL

coreintermediate

The real question is not relational versus document, it is whether your data has one dominant access pattern or many. A document store keeps everything one screen needs in a single object, which makes that read fast and every other read awkward. A relational store keeps each fact once and assembles it per query, which makes any question answerable and no single question free.

Think of it as

Normalization decides where the assembly happens. Relational assembles at read time, so a new question is a new query. Document assembles at write time, so a new question may be a migration. Pick by counting how many different questions the data has to answer, and how often the shape of those questions changes.

What we're doing: Model the same orders as normalized rows and as embedded documents, then count what each shape costs for three different jobs.

shapes.pypython
from collections import defaultdict

PRODUCTS = ["widget", "gasket", "bolt"]

# Relational shape: two tables, joined on order_id.
orders_tbl = [{"id": i, "customer": f"cust{i % 50}"} for i in range(1, 201)]
lines_tbl = [
    {"order_id": i, "product": PRODUCTS[n], "amount": 10 + ((i + n) % 7)}
    for i in range(1, 201)
    for n in range(3)
]

# Document shape: exactly the same facts, embedded in the order.
docs = [
    {
        "id": o["id"],
        "customer": o["customer"],
        "lines": [line for line in lines_tbl if line["order_id"] == o["id"]],
    }
    for o in orders_tbl
]


def one_order_relational(order_id):
    """Index lookup on orders, then index lookup on lines: two reads."""
    matched = [line for line in lines_tbl if line["order_id"] == order_id]
    return len(matched), 2


def one_order_document(order_id):
    """The order and its lines are one stored object: one read."""
    doc = next(d for d in docs if d["id"] == order_id)
    return len(doc["lines"]), 1


def revenue_relational():
    totals = defaultdict(int)
    for line in lines_tbl:          # one table, one pass
        totals[line["product"]] += line["amount"]
    return dict(totals), len(lines_tbl)


def revenue_document():
    totals = defaultdict(int)
    touched = 0
    for doc in docs:                # every document, then every embedded line
        touched += 1
        for line in doc["lines"]:
            touched += 1
            totals[line["product"]] += line["amount"]
    return dict(totals), touched


lines, reads = one_order_relational(42)
print(f"one order   relational: {lines} lines, {reads} reads")
lines, reads = one_order_document(42)
print(f"one order   document  : {lines} lines, {reads} reads")

rel, rel_touched = revenue_relational()
doc_totals, doc_touched = revenue_document()
print(f"revenue     relational: {rel_touched} items touched -> {rel}")
print(f"revenue     document  : {doc_touched} items touched -> {doc_totals}")

widget_copies = sum(
    1 for d in docs for line in d["lines"] if line["product"] == "widget"
)
print(f"rename widget  relational: 1 row updated")
print(f"rename widget  document  : {widget_copies} embedded copies updated")
5
Two tables: an order has no idea what its lines are, and the relationship lives in the `order_id` column.
18
One object: the order carries its lines. This single line is the whole difference, and every trade-off below follows from it.
26
The join is the cost of normalization — two lookups where the document shape needs one.
46
And this is the cost of denormalization: a question that cuts across orders has to open every order to answer it.
65
The duplicated value is the third cost — one row against 200 embedded copies for the same rename, and any copy the update misses is now wrong.
Output
one order   relational: 3 lines, 2 reads
one order   document  : 3 lines, 1 reads
revenue     relational: 600 items touched -> {'widget': 2598, 'gasket': 2602, 'bolt': 2606}
revenue     document  : 800 items touched -> {'widget': 2598, 'gasket': 2602, 'bolt': 2606}
rename widget  relational: 1 row updated
rename widget  document  : 200 embedded copies updated

Why this works: Each shape wins the job it was designed for and loses the other two, and the numbers say by how much. The document wins the order-detail read outright — one read against two, and at scale that is one network round trip against two plus a join. It loses the cross-cutting aggregate, touching 800 items against 600, because the grouping it needs cuts across the boundary it was organised by. And it loses the rename badly: 200 copies against one row, which is not a performance difference but a correctness risk, since any copy the update misses is now wrong. Choosing between them is choosing which of these three you do most.

Choosing a document store because the schema is not settled yet

Wrong

python
# "We do not know the model yet, so we will stay flexible and
#  add fields as we go."
db.orders.insert_one({"id": 1, "total": 120})
db.orders.insert_one({"id": 2, "total": 130, "currency": "EUR"})
db.orders.insert_one({"id": 3, "amount": 140})    # different key entirely

Better

python
# The schema exists either way — the only question is who enforces
# it. Write it down, validate on the way in, and version it:
ORDER_V2 = {"id": int, "total_minor": int, "currency": str}

# Then pick the store by the access patterns, not by how much
# ceremony each one asks for on day one.

What you see: Eighteen months in, the collection holds four generations of document shape. Every read path carries `doc.get("total") or doc.get("amount")`, no analytics query can trust a field is present, and a backfill has to reason about which shape each document is.

Why: Not declaring a schema does not remove it; it moves it into whichever code paths happen to write, where it is never written down and never enforced. Flexibility on the first day is bought with a permanent tax on every read afterwards, and it is the wrong reason to pick a data model — the right reason is the access patterns the numbers above measure.

Remember: Count the questions, not the features. One dominant access pattern that reads an aggregate whole favours documents; many different questions over the same facts favour relational. Duplicated data is the real cost of embedding — 200 copies against one row for the same rename — and "schemaless" only moves the schema into your application.

See also: access patterns not entities · embed or reference decision · embedding tradeoffs · what an index is and when it hurts · when to use redis

When would you use Redis?

standardadvanced

Redis earns its place when a use case needs speed the primary database cannot give at the same cost — a cache for expensive/repeated reads, a place for ephemeral data that does not need durability (a session, a rate-limit counter), or a data structure the primary database has no native equivalent for (a sorted set for a leaderboard, a stream for a lightweight queue). It is the wrong choice as the primary, durable system of record for data that must never be lost.

Think of it as

Redis is an in-memory specialist, not a general-purpose database competing with PostgreSQL — the question is never "Redis or Postgres" in the abstract, it is "does THIS specific piece of data want speed over durability, or a data structure Postgres does not offer natively." A shopping cart's session token wants speed and can tolerate loss (the user can log in again). An order's financial record wants durability above all — losing it is a real, expensive incident. Redis is right for the first, wrong for the second, and that distinction is the entire decision.

python
def should_use_redis(needs_durability, needs_relational_queries):
    if needs_durability or needs_relational_queries:
        return False
    return True   # ephemeral, or a native Redis structure, or a cache in front of the real DB

What we're doing: Apply the durability/relational-queries decision rule to four real use cases, and confirm it recommends Redis only for the ones matching Redis's actual strengths.

when_to_use_redis.pypython
def should_use_redis(description, needs_durability, needs_relational_queries):
    if needs_durability or needs_relational_queries:
        decision = "no -- use the primary database"
    else:
        decision = "yes -- Redis fits"
    return f"{description}: {decision}"


use_cases = [
    ("Session token, 30-minute TTL", False, False),
    ("Real-time leaderboard (sorted set)", False, False),
    ("Order financial records", True, False),
    ("Ad-hoc sales report joining 4 tables", False, True),
]
for description, needs_durability, needs_relational_queries in use_cases:
    print(should_use_redis(description, needs_durability, needs_relational_queries))
2
Durability need is checked FIRST — no amount of speed benefit overrides "this data must never be silently lost."
9
A session token needs neither durability (re-login is fine) nor relational queries — Redis fits cleanly.
11
Order financial records need durability — this recommends the primary database regardless of how fast Redis could serve reads.
Output
Session token, 30-minute TTL: yes -- Redis fits
Real-time leaderboard (sorted set): yes -- Redis fits
Order financial records: no -- use the primary database
Ad-hoc sales report joining 4 tables: no -- use the primary database

Why this works: The two "yes" cases share nothing about their DATA SHAPE (a token string versus a sorted set) — what they share is tolerance for loss and no need for relational queries. The two "no" cases are rejected for two DIFFERENT reasons (durability for the financial record, relational joins for the report), which is why the decision function checks both conditions independently rather than folding them into one vague "is this a good Redis use case" judgment.

Using Redis as the sole store for data that actually needs durability, because it is already in the stack for caching

Wrong

python
# "We already use Redis for caching, let's just store the shopping
# cart total there too, no need to also write it to Postgres"
redis_client.set(f"cart_total:{user_id}", total)   # ONLY place this value lives

Better

python
# Postgres is the source of truth; Redis (optionally) caches a read of it
db.execute("UPDATE carts SET total = %s WHERE user_id = %s", (total, user_id))
redis_client.set(f"cart_total:{user_id}", total, ex=300)   # cache, not the only copy

What you see: A Redis restart, eviction under memory pressure, or a maxmemory policy silently drops the cart total with no error anywhere — the data simply is not there anymore, and there was never a durable copy to recover it from.

Why: Redis persistence options exist (RDB snapshots, AOF) but are not the default guarantee an application should lean on for data with real financial or business consequences if lost — a relational database with WAL, replication, and point-in-time recovery is built specifically for that guarantee. Using Redis as convenient shared storage because "it is already there" quietly trades away durability nobody explicitly decided to give up.

Remember: Redis fits caching, ephemeral data (tolerates loss), and its own native data structures (sorted sets, streams) — not the sole durable system of record for data that must never be lost, and not complex relational queries.

See also: why caching and in memory cache · redis cache · ttl and expiration · distributed locks · document vs relational

Advertisement