Filter concepts by levelShowing all levels.

Python · Senior-Level Engineering Skills

Senior-Level Engineering Skills

Concepts
12

The practices that separate Python knowledge from being a 5-year engineer: reviewing an architecture before it is built, writing documentation and debugging a production incident with no debugger attached, recognizing and paying down technical debt (including the untracked, legacy kind), estimating and breaking down large work, migrating a system and its schema safely, deploying and rolling back without risking every user at once, mentoring, and communicating a trade-off in plain language with an actual recommendation.

Python overview

Engineering practice beyond syntax

Review, document, debug, and manage debt and change deliberately — the practices a senior engineer applies around the code, not just in it.

Architecture reviews

coreadvanced

An architecture review evaluates a proposed system-level design — usually written up as an ADR (Architecture Decision Record: context, decision, consequences) — before any code is written, so a costly structural mistake is caught on paper instead of after months of implementation.

Think of it as

A code review asks "is this diff correct and clear?" An architecture review asks a much bigger question one level up: "is this the right shape for the system, before a single line commits us to it?" It happens on a document (an ADR), not a diff, precisely because the whole point is to be cheap to change — reversing a paragraph is nothing compared to reversing three months of implementation built on the wrong foundation.

python
def render_adr(title, status, context, decision, consequences):
    return (
        f"# {title}\nStatus: {status}\n\n"
        f"## Context\n{context}\n\n"
        f"## Decision\n{decision}\n\n"
        f"## Consequences\n{consequences}"
    )

What we're doing: Model an architecture review as a list of reviewer concerns against an ADR, where any concern tagged "blocker:" prevents the decision from being approved.

architecture_review.pypython
class ArchitectureReview:
    def __init__(self, adr_id, decision):
        self.adr_id = adr_id
        self.decision = decision
        self.concerns = []

    def raise_concern(self, reviewer, text):
        self.concerns.append({"reviewer": reviewer, "text": text})

    def is_blocked(self):
        return any(c["text"].lower().startswith("blocker:") for c in self.concerns)


review = ArchitectureReview("ADR-014", "Use a message queue for order processing")
review.raise_concern("dana", "Consider retry/backoff for consumer failures")
review.raise_concern("priya", "blocker: no plan for message ordering guarantees")
print(f"{review.adr_id}: {len(review.concerns)} concerns, blocked={review.is_blocked()}")
11
Any reviewer can raise a concern — most are discussion, not a veto.
14
A concern prefixed "blocker:" is different in kind: is_blocked() checks specifically for that prefix, not just for any concern existing at all.
Output
ADR-014: 2 concerns, blocked=True

Why this works: is_blocked() only trips on the explicit "blocker:" prefix, not on concern count — a design with five minor suggestions and zero blockers is still approvable, while one blocking concern (missing ordering guarantees, here) holds up approval even alone. That distinction is what keeps a review from either rubber-stamping everything or grinding to a halt over every nitpick.

An ADR, and what makes a review real

Context

the problem and forces at play — why a decision is even needed

Decision

what was chosen, written down before implementation starts

Consequences

at least one real cost named — "None" means it was not actually reviewed

blocker: concern

any reviewer can raise one — it alone gates approval, regardless of concern count

  1. Context — the problem and forces at play — why a decision is even needed
  2. Decision — what was chosen, written down before implementation starts
  3. Consequences — at least one real cost named — "None" means it was not actually reviewed
  4. blocker: concern — any reviewer can raise one — it alone gates approval, regardless of concern count

Approving a design with no consequences listed

Wrong

python
adr = render_adr(
    "ADR-014: Use a message queue for order processing",
    "Accepted",
    context="Order processing blocks the request thread.",
    decision="Introduce a queue.",
    consequences="None — this is strictly better.",   # nothing was actually weighed
)

Better

python
adr = render_adr(
    "ADR-014: Use a message queue for order processing",
    "Accepted",
    context="Order processing blocks the request thread.",
    decision="Introduce a queue; the API enqueues and returns immediately.",
    consequences=(
        "Adds an operational dependency (the queue) to run and monitor. "
        "Order processing becomes eventually consistent, not immediate — "
        "callers can no longer assume the order exists right after the API call returns."
    ),
)

What you see: The team discovers the real cost (an operational dependency, an eventual-consistency change client code silently relied on being synchronous) only after it ships and something downstream breaks — nobody road-tested the tradeoff on paper because the ADR claimed there wasn't one.

Why: Every real architectural decision trades something for something — "no downsides" almost always means the downsides were not looked for, not that none exist. A review that accepts a Consequences section reading "None" has not actually reviewed the tradeoff; it has rubber-stamped the Decision section and skipped the part of the ADR that is supposed to prevent exactly this kind of surprise.

Remember: An architecture review happens on an ADR (Context, Decision, Consequences) before implementation, and it is only a real review if it can name at least one real cost and can raise a blocking concern that actually blocks.

See also: code review and readability · service layer

Technical documentation

standardadvanced

Technical documentation writes down a function's contract (what it returns, what it raises), or a project's setup steps, so a reader does not have to reconstruct that knowledge by reading every line of implementation or asking the original author.

Think of it as

Documentation is the author's context, captured before it leaves their head. Six months from now, the author has forgotten exactly why a function raises CardDeclinedError under one condition and not another — a docstring or a README section is the only version of that knowledge that survives the forgetting.

python
def function_doc(name, params, returns, raises):
    lines = [f"{name}({', '.join(params)})"]
    lines.append(f"    Returns: {returns}")
    if raises:
        lines.append(f"    Raises: {', '.join(raises)}")
    return "\n".join(lines)

What we're doing: Build a small README out of composable sections, and confirm the generated document is well-formed markdown a new engineer could actually follow.

technical_docs.pypython
def build_readme_section(heading, level, body):
    return f"{'#' * level} {heading}\n\n{body.strip()}\n"


sections = [
    build_readme_section("Installation", 2, "Run `pip install billing-service` to install."),
    build_readme_section("Quickstart", 2, "```python\nfrom billing import charge_card\ncharge_card('cus_1', 500)\n```"),
]
doc = "\n".join(sections)
print(doc)
print(f"total length: {len(doc)} chars, {doc.count('##')} h2 sections")
1
Each section is self-contained: heading level, a heading, and a body — the same shape every section in a real README follows.
6
Installation is the very first section — a reader cannot follow a Quickstart before they can install the package at all.
7
Quickstart shows a minimal, runnable example — not a description of what the package can do, an actual snippet a reader can paste and run.
Output
## Installation

Run `pip install billing-service` to install.

## Quickstart

```python
from billing import charge_card
charge_card('cus_1', 500)
```

total length: 151 chars, 2 h2 sections

Why this works: Installation appears before Quickstart in the generated document, in the order sections were composed — matching the order a new engineer actually needs the information: you cannot run the Quickstart example before the package is installed. Documentation structure that does not match reading order is documentation a reader has to reorder in their own head.

Remember: Good documentation states a contract (what a function returns and raises) or gets a new engineer running (a README's install + quickstart) — it earns its keep by adding information the code and signature do not already make obvious.

See also: code review and readability · backward compatibility

Debugging production incidents

coreadvanced

Debugging a production incident means narrowing a failure to its cause with no debugger attached — a correlation ID that ties every log line from one request together, and a structured log at each boundary (before/after a DB call, an external API call) turns "it broke somewhere" into "it broke between step 3 and step 4."

Think of it as

A local bug is debugged by attaching a debugger and stepping through — you have the process, you can pause it. A production incident is debugged like a crime scene days later: no debugger, no chance to reproduce interactively, only whatever evidence (logs) was captured at the time. A correlation ID is the case file number that lets you pull every piece of evidence for one specific request, in order, instead of an undifferentiated pile of everyone's logs mixed together.

python
import logging, uuid

logger = logging.getLogger(__name__)

def handle_request(payload):
    correlation_id = str(uuid.uuid4())
    logger.info("request_start", extra={"correlation_id": correlation_id})
    # ... every downstream log call also includes correlation_id ...

What we're doing: Tag every log line from one request with a shared correlation ID, and show how filtering by that ID isolates exactly the steps of one failing request out of several interleaved ones.

incident_debugging.pypython
import uuid


def process_order(order_id, correlation_id, log):
    log.append({"correlation_id": correlation_id, "step": "start", "order_id": order_id})
    log.append({"correlation_id": correlation_id, "step": "charge_card", "order_id": order_id})
    if order_id == "order-2":
        log.append({"correlation_id": correlation_id, "step": "charge_card_failed", "order_id": order_id})
        return False
    log.append({"correlation_id": correlation_id, "step": "charge_card_ok", "order_id": order_id})
    return True


log = []
requests = [("order-1", str(uuid.uuid4())), ("order-2", str(uuid.uuid4()))]
for order_id, cid in requests:
    process_order(order_id, cid, log)

failing_cid = requests[1][1]
isolated = [entry for entry in log if entry["correlation_id"] == failing_cid]
for entry in isolated:
    print(entry["step"])
4
Every log call inside process_order includes the same correlation_id it was called with — nothing about it changes per log line except the step.
20
Filtering the full, interleaved log down to one correlation_id reconstructs exactly one request's timeline — "start", "charge_card", then the failure — out of two requests' worth of mixed entries.
Output
start
charge_card
charge_card_failed

Why this works: The full log list contains entries from both order-1 and order-2 interleaved together — filtering by failing_cid discards order-1's entries entirely and leaves exactly order-2's three steps in order, immediately showing the failure happened at the charge_card step, not before it and not after. Without the shared correlation_id there would be no way to tell which log lines belonged to the failing request at all once real production traffic interleaves thousands of concurrent requests.

Logging without a correlation ID, then trying to debug from timestamps alone

Wrong

python
def process_order(order_id, log):
    log.append({"step": "start", "order_id": order_id})
    log.append({"step": "charge_card", "order_id": order_id})
    # no correlation_id -- under concurrent requests, log lines from
    # DIFFERENT orders interleave with no way to regroup them by request

Better

python
def process_order(order_id, correlation_id, log):
    log.append({"correlation_id": correlation_id, "step": "start", "order_id": order_id})
    log.append({"correlation_id": correlation_id, "step": "charge_card", "order_id": order_id})
    # every entry can be regrouped by correlation_id regardless of interleaving

What you see: During a real incident with concurrent traffic, log lines for the failing request are scattered among thousands of other requests' lines with the same rough timestamp — there is no field to filter on that reliably isolates just the one request that failed.

Why: order_id alone is not enough when the same order can appear across retries, and a timestamp range catches every concurrent request in that window, not just the failing one. A correlation_id generated once per request and threaded through every downstream call and log line is the only reliable key for reconstructing one request's full path after the fact.

Narrowing a failure with no debugger attached

Tag at the edge

correlation_id generated once, threaded through every downstream call

Log at each boundary

before/after a DB call, before/after an external API call

Filter by correlation_id

reconstructs one request's timeline out of millions of interleaved lines

Reproduce with the real payload

the actual failing input, not a synthetic guess

  1. Tag at the edge — correlation_id generated once, threaded through every downstream call
  2. Log at each boundary — before/after a DB call, before/after an external API call
  3. Filter by correlation_id — reconstructs one request's timeline out of millions of interleaved lines
  4. Reproduce with the real payload — the actual failing input, not a synthetic guess

Remember: A correlation ID threaded through every downstream call turns an unreadable pile of interleaved logs into one request's timeline; narrow fast with real production inputs rather than reasoning from the code in isolation.

See also: incident response and root cause analysis · correlation and request ids

Estimating engineering work

standardadvanced

A three-point estimate takes an optimistic, most-likely, and pessimistic guess and combines them into one expected value — (optimistic + 4*most_likely + pessimistic) / 6 — which is far more honest about uncertainty than a single number pulled from thin air.

Think of it as

A single-number estimate ("3 days") hides a question nobody answered: 3 days if everything goes right, or 3 days on average including the unknown unknowns? A three-point estimate forces that question into the open — optimistic, most likely, and pessimistic are three different answers to "how long," and the weighted average treats the most-likely case as the anchor while still respecting that the pessimistic case is a real possibility, not a rounding error.

python
def three_point_estimate(optimistic, most_likely, pessimistic):
    expected = (optimistic + 4 * most_likely + pessimistic) / 6
    return round(expected, 1)

What we're doing: Estimate two tasks with three-point estimation, sum them into a total, and validate that the three inputs are given in a sane order.

estimation.pypython
def three_point_estimate(optimistic, most_likely, pessimistic):
    if not optimistic <= most_likely <= pessimistic:
        raise ValueError("expected optimistic <= most_likely <= pessimistic")
    return round((optimistic + 4 * most_likely + pessimistic) / 6, 1)


tasks = [
    ("add pagination", 1, 2, 3),
    ("migrate auth to OAuth", 3, 8, 21),
]
total = 0
for name, o, m, p in tasks:
    days = three_point_estimate(o, m, p)
    total += days
    print(f"{name}: {days} days (PERT)")
print(f"total: {round(total, 1)} days")
try:
    three_point_estimate(5, 2, 10)
except ValueError as e:
    print(f"ValueError: {e}")
2
The estimate itself is checked for sanity — a pessimistic value smaller than the optimistic one means the three numbers were entered in the wrong order.
9
"migrate auth to OAuth" has a much wider optimistic-to-pessimistic spread (3 to 21) than "add pagination" (1 to 3) — that spread is the estimator flagging real uncertainty in a task that touches an external system.
Output
add pagination: 2.0 days (PERT)
migrate auth to OAuth: 9.3 days (PERT)
total: 11.3 days
ValueError: expected optimistic <= most_likely <= pessimistic

Why this works: The OAuth migration's PERT estimate (9.3 days) sits much closer to its most-likely guess (8) than a naive average of all three (10.7) would — the 4x weight on most_likely is exactly what keeps one extreme pessimistic guess from dominating the number, while the wide 3-to-21 spread itself is still visible as a signal that this task carries more risk than "add pagination" does.

Remember: A three-point estimate — (optimistic + 4*most_likely + pessimistic) / 6 — is more honest than a single guess because it makes the estimator's uncertainty visible instead of hiding it inside one number.

See also: breaking large tasks into smaller pieces

Breaking large tasks into smaller pieces

standardadvanced

A large task splits into subtasks that are either additive (new code that does not change existing behavior) or built behind a feature flag — both are safe to merge and ship on their own, long before the whole epic is "done."

Think of it as

A large task done as one giant merge is a single point of failure: one review, one risky deploy, weeks of work that either all lands or all needs unwinding together. Splitting it into independently shippable pieces turns that single point of failure into a series of small, low-risk steps — each one already in production and already battle-tested by the time the final piece (the risky one, usually a cutover) ships.

python
def is_independently_shippable(task):
    return task.get("behind_flag", False) or task.get("additive", False)

What we're doing: Split a large migration epic into subtasks, and separate the ones safe to ship immediately from the one riskier step that must go last.

task_breakdown.pypython
epic = {
    "name": "Migrate billing to Stripe",
    "subtasks": [
        {"name": "add Stripe client behind a feature flag", "behind_flag": True, "days": 2},
        {"name": "write dual-write shim (old + Stripe)", "additive": True, "days": 3},
        {"name": "backfill historical customers", "additive": True, "days": 1},
        {"name": "flip flag to 100% Stripe, remove old path", "behind_flag": False, "additive": False, "days": 1},
    ],
}

def is_independently_shippable(task):
    return task.get("behind_flag", False) or task.get("additive", False)

shippable_first = [t for t in epic["subtasks"] if is_independently_shippable(t)]
final_step = [t for t in epic["subtasks"] if not is_independently_shippable(t)]
print(f"can ship independently: {[t['name'] for t in shippable_first]}")
print(f"must go last: {[t['name'] for t in final_step]}")
print(f"total estimated days: {sum(t['days'] for t in epic['subtasks'])}")
4
The Stripe client ships behind a flag, off by default — merged and deployed with zero behavior change until someone flips it.
5
The dual-write shim is additive: both the old and new path run, so nothing that already works stops working.
7
The actual cutover — removing the old path — is the one genuinely risky step, and it is deliberately the last subtask, not the first.
Output
can ship independently: ['add Stripe client behind a feature flag', 'write dual-write shim (old + Stripe)', 'backfill historical customers']
must go last: ['flip flag to 100% Stripe, remove old path']
total estimated days: 7

Why this works: Three of the four subtasks are shippable well before the epic finishes — each merges, deploys, and gets tested in production independently, with zero risk to existing billing behavior. Only the final cutover carries real risk, and by the time it ships, everything it depends on (the Stripe client, the dual-write shim, the backfill) has already been running safely for however long those earlier subtasks have been live.

Remember: Split a large task into subtasks that are additive or flag-gated — each ships and gets reviewed on its own — and save the one genuinely risky step (the cutover) for last.

See also: estimating engineering work · migration strategies

Identifying technical debt

standardadvanced

Identifying technical debt starts with what is already marked (a FIXME/HACK-style comment naming a known shortcut), then goes further to spot the unmarked kind — code nobody flagged, that just quietly got harder to work in over time.

Think of it as

Tracked technical debt is a debt with an IOU attached — someone wrote down what was skipped and why. Untracked debt has no IOU: a workaround that shipped without a comment, a module three people are now afraid to touch, a pattern copy-pasted five times because refactoring it once felt riskier than duplicating it a sixth. Identifying debt means finding both — the marked kind by searching for the marker, the unmarked kind by asking "what code do we route around instead of fixing?"

python
import re

def has_debt_marker(line):
    return bool(re.search(r"#\s*(TODO|FIXME|HACK|XXX)", line))

What we're doing: Scan a small module's source for tracked debt markers, reporting the line number and the marker text for each one found.

debt_scan.pypython
import re

def has_debt_marker(line):
    return bool(re.search(r"#\s*(TODO|FIXME|HACK|XXX)", line))


source_lines = [
    "def parse_config(path):",
    "    # HACK: assumes UTF-8, will break on legacy Windows-1252 files",
    "    return open(path, encoding='utf-8').read()",
    "",
    "def total(items):",
    "    # TODO(2026-06-01): O(n^2), fine at current scale, revisit past 10k items",
    "    return sum(i for i in items if i not in items[:items.index(i)])",
]

def find_debt_markers(lines):
    markers = []
    for i, line in enumerate(lines, start=1):
        if has_debt_marker(line):
            markers.append((i, line.strip()))
    return markers

for lineno, text in find_debt_markers(source_lines):
    print(f"L{lineno}: {text}")
print(f"found {len(find_debt_markers(source_lines))} debt markers")
8
"HACK" flags a real known limitation (the encoding assumption) — a reader hitting a Windows-1252 file now knows exactly why, instead of debugging it from scratch.
12
The dated TODO tells a future reader WHEN to worry about the O(n^2) cost — "fine at current scale" is a judgment call that becomes wrong at some point, and the date is the signal to re-check it.
Output
L2: # HACK: assumes UTF-8, will break on legacy Windows-1252 files
L6: # TODO(2026-06-01): O(n^2), fine at current scale, revisit past 10k items
found 2 debt markers

Why this works: find_debt_markers surfaces exactly the debt someone already flagged — real, useful, but only the tracked half of the picture. The O(n^2) total() function has a comment explaining the tradeoff; a second, unmarked O(n^2) function elsewhere in the same codebase would not show up in this scan at all, which is exactly why identifying debt cannot stop at grepping for TODO.

Treating "no TODO comments" as evidence a codebase has no debt

Wrong

python
markers = find_debt_markers(read_all_source_lines("billing/"))
if not markers:
    print("no technical debt found")   # confuses "not marked" with "not present"

Better

python
markers = find_debt_markers(read_all_source_lines("billing/"))
print(f"{len(markers)} tracked debt markers found — this is a floor, not the total")
# also check: bug density per module, "nobody wants to touch this" in standup,
# and duplicated logic that was copy-pasted instead of extracted

What you see: A module everyone privately avoids — heavily duplicated, brittle, feared by the whole team — reports zero technical debt on every grep-for-TODO audit, because nobody who wrote the workarounds left a comment admitting it.

Why: A TODO/FIXME grep only finds debt someone already noticed AND bothered to write down — it says nothing about debt nobody flagged, which is usually the more dangerous kind precisely because there is no paper trail pointing at it. Treating an empty grep result as "no debt" mistakes the absence of a marker for the absence of the problem the marker would have described.

Remember: Grepping for FIXME/HACK-style markers finds the tracked debt someone already flagged — that is a floor, not the total; the untracked kind (a feared module, duplicated logic, a rising bug rate) needs a different kind of looking.

See also: refactoring and technical debt

Refactoring legacy systems

coreadvanced

Refactoring a legacy system safely starts with a characterization test — a test that pins down what the code ACTUALLY does right now, bugs and all, rather than what it should do — so a later change has something real to check against, even when no test suite existed before.

Think of it as

Ordinary refactoring assumes tests already exist to prove behavior held. Legacy code breaks that assumption — there is nothing to run before the change. A characterization test is built by calling the existing code with real inputs, recording whatever it actually returns (even a known bug), and asserting exactly that. It is not testing correctness, only current behavior — the safety net a normal refactor would have taken for granted.

python
def characterize(func, *args, **kwargs):
    """Call the existing function and pin down what it ACTUALLY returns —
    used once to write down current behavior, not to judge correctness."""
    return func(*args, **kwargs)

What we're doing: Write a characterization test against an undocumented legacy function, pinning down its real (buggy) current output, then refactor its internals while the characterization test stays green.

legacy_refactor.pypython
def legacy_discount(price, quantity):
    # undocumented, no existing tests -- nobody remembers if this rounding is intentional
    total = price * quantity
    if quantity >= 10:
        total = total * 0.9
    return round(total, 1)   # rounds to ONE decimal place -- looks like a bug, nobody fixed it


# Step 1: characterization test -- pin down what it ACTUALLY does, bug and all
observed = legacy_discount(19.99, 10)
print("characterized:", observed)
assert observed == 179.9   # documents the real (probably buggy) current output


# Step 2: refactor internal structure only -- characterization test must stay green
def legacy_discount_refactored(price, quantity):
    total = price * quantity
    if quantity >= 10:
        total *= 0.9
    return round(total, 1)   # rounding bug preserved on purpose -- fixing it is a SEPARATE step


refactored = legacy_discount_refactored(19.99, 10)
print("after refactor:", refactored)
print("characterization still holds:", refactored == observed)
9
The characterization test does not assert the "correct" rounded value — it asserts 179.9, exactly what the existing, unrefactored function actually returns today, one-decimal rounding included.
20
The refactored version keeps round(total, 1) unchanged — fixing the apparent rounding bug is explicitly NOT part of this refactor, exactly the same discipline as not mixing new behavior into a refactor commit.
22
The characterization test still passes after the refactor — proof the internal restructuring changed nothing observable, which is the entire guarantee a refactor is supposed to make.
Output
characterized: 179.9
after refactor: 179.9
characterization still holds: True

Why this works: legacy_discount had no prior test suite, so there was nothing to prove behavior held before refactoring it — the characterization test manufactures exactly that proof by recording the real current output first. Once it exists, refactoring the legacy function is no different from refactoring any tested function: change structure, rerun the test, confirm it is still green.

Fixing the bug the characterization test reveals, at the same time as the refactor

Wrong

python
def legacy_discount_refactored(price, quantity):
    total = price * quantity
    if quantity >= 10:
        total *= 0.9
    return round(total, 2)   # "fixed" the rounding while refactoring -- now the OLD test fails

Better

python
# Refactor commit: structure only, round(total, 1) UNCHANGED, characterization stays green
# SEPARATE commit: round(total, 2) -- a deliberate, reviewed bug fix with its own test update

What you see: The characterization test written specifically to prove the refactor was safe now fails — not because the refactor broke anything, but because a second, unrelated change got bundled into the same commit, making it impossible to tell from the test result alone whether the structural change was actually safe.

Why: A characterization test's entire value is proving "this specific change did not alter behavior" — changing behavior (even to fix a real bug) inside the same commit destroys that proof for the refactor itself. The bug fix may well be correct and worth doing, but it needs its own commit, its own review, and its own updated test, exactly the same discipline ordinary refactoring already requires.

Making a safe refactor possible with zero prior tests

Find a seam

extract hidden global deps first, if the code has no place to insert a fake

Write a characterization test

assert exactly what it returns TODAY — bugs included, not "correct" behavior

Refactor structure only

the characterization test must stay green throughout

Fix any bug separately

its own commit, its own review, its own updated test

  1. Find a seam — extract hidden global deps first, if the code has no place to insert a fake
  2. Write a characterization test — assert exactly what it returns TODAY — bugs included, not "correct" behavior
  3. Refactor structure only — the characterization test must stay green throughout
  4. Fix any bug separately — its own commit, its own review, its own updated test

Remember: When no test suite exists yet, write a characterization test that pins down current behavior (bugs included) BEFORE refactoring — then refactor exactly as if that test had always existed, and fix any bug it reveals as a separate, deliberate step.

See also: refactoring and technical debt · identifying technical debt · avoiding hidden global dependencies

Migration strategies

standardadvanced

A migration strategy like the strangler fig pattern routes each request to the new system if that piece has already been migrated, or to the legacy system otherwise — letting a large migration ship in small, continuously-shippable increments instead of one risky big-bang cutover.

Think of it as

A strangler fig grows around a host tree, gradually taking over its structure until the original tree is no longer needed. A system migration named after it does the same: new functionality grows up alongside the legacy system, one piece is migrated and routed to at a time, and the legacy system is only fully retired once every piece has already been strangled — never in one all-at-once rewrite.

python
def strangler_route(request_path, migrated_paths):
    return "new_service" if request_path in migrated_paths else "legacy_service"

What we're doing: Route requests through a strangler-fig layer, and compute the current migration progress as a fraction of routes already migrated.

strangler_migration.pypython
MIGRATED = {"/api/v2/invoices", "/api/v2/customers"}

def strangler_route(request_path, migrated_paths=MIGRATED):
    """Strangler fig: route already-migrated paths to the new service,
    everything else still goes to the legacy monolith."""
    return "new_service" if request_path in migrated_paths else "legacy_service"


routes_to_check = ["/api/v2/invoices", "/api/v2/orders", "/api/v2/customers"]
for path in routes_to_check:
    print(f"{path} -> {strangler_route(path)}")

migrated_fraction = len(MIGRATED) / (len(MIGRATED) + 1)
print(f"migration progress: {migrated_fraction:.0%}")
1
MIGRATED is the only thing that changes as the migration proceeds — the routing logic itself never needs to change, only which paths are in this set.
12
"/api/v2/orders" is the one not-yet-migrated path in this example — migration progress is computed directly from how many paths are in MIGRATED versus the total.
Output
/api/v2/invoices -> new_service
/api/v2/orders -> legacy_service
/api/v2/customers -> new_service
migration progress: 67%

Why this works: Two of the three routes already resolve to new_service while the third still falls through to legacy_service — both systems are live and correctly serving traffic at the same time, which is the entire point of the strangler pattern: the migration is already partially shipped and already delivering value, not waiting on a single future cutover date to deliver anything.

Remember: The strangler fig pattern routes migrated paths to the new system and everything else to the legacy one — migration ships incrementally, and the legacy system retires only after the last piece has moved.

See also: breaking large tasks into smaller pieces · database migrations · data migrations

Database migrations

coreadvanced

A database migration is a versioned script (Alembic-style) with an upgrade() that applies a schema change and a downgrade() that reverses it — each revision links to the one before it, so the schema's full history is a chain of small, reversible steps, not a single hand-edited state.

Think of it as

A migration is a schema-level version control commit — upgrade() is the diff going forward, downgrade() is the diff going backward, and revision/down_revision chain migrations into an ordered history the same way a git commit chains to its parent. A schema changed by hand in production, with no migration recorded, is a commit that never got made — nobody can replay it, diff it, or safely reverse it.

python
def upgrade():
    return "ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP NULL"

def downgrade():
    return "ALTER TABLE users DROP COLUMN last_login_at"

What we're doing: Model an Alembic-style migration as a class with a revision chain, and apply it in both directions.

schema_migration.pypython
class Migration0007AddLastLogin:
    revision = "0007"
    down_revision = "0006"

    @staticmethod
    def upgrade():
        return ["ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP NULL"]

    @staticmethod
    def downgrade():
        return ["ALTER TABLE users DROP COLUMN last_login_at"]


def apply_migration(migration, direction="upgrade"):
    statements = getattr(migration, direction)()
    for stmt in statements:
        print(f"[{direction}] {stmt}")
    return statements

applied = apply_migration(Migration0007AddLastLogin, "upgrade")
print(f"revision {Migration0007AddLastLogin.revision} applied, {len(applied)} statement(s)")
apply_migration(Migration0007AddLastLogin, "downgrade")
2
down_revision="0006" chains this migration to the one before it — a migration tool uses this to apply migrations in the right order.
6
upgrade() adds the column NULLABLE — an existing row has no value for a brand-new column, so it must be allowed to be empty (or backfilled separately).
12
apply_migration is direction-agnostic: the same function drives both upgrade() and downgrade() by name, matching how a real migration tool applies and reverts a revision.
Output
[upgrade] ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP NULL
revision 0007 applied, 1 statement(s)
[downgrade] ALTER TABLE users DROP COLUMN last_login_at

Why this works: The same migration object drives both directions through one apply_migration function — upgrade() and downgrade() are genuine inverses of each other (ADD COLUMN / DROP COLUMN on the exact same column), which is what makes this migration actually safe to roll back, not just theoretically reversible.

A revision chain — each step links to the one before
  1. 0006

    down_revision of 0007

    the migration this one builds on

  2. 0007

    upgrade(): ADD COLUMN last_login_at

    nullable — existing rows have no value yet

  3. rollback

    downgrade(): DROP COLUMN last_login_at

    the genuine inverse of upgrade()

  4. 0008

    next revision, down_revision = 0007

    chains forward — the database tracks which are applied

  1. 0006: down_revision of 0007 — the migration this one builds on
  2. 0007: upgrade(): ADD COLUMN last_login_at — nullable — existing rows have no value yet
  3. rollback: downgrade(): DROP COLUMN last_login_at — the genuine inverse of upgrade()
  4. 0008: next revision, down_revision = 0007 — chains forward — the database tracks which are applied

Writing a migration with no real downgrade, or one that loses data silently

Wrong

python
class Migration0008DropLegacyStatus:
    revision = "0008"
    down_revision = "0007"

    @staticmethod
    def upgrade():
        return ["ALTER TABLE orders DROP COLUMN legacy_status"]

    @staticmethod
    def downgrade():
        return ["ALTER TABLE orders ADD COLUMN legacy_status VARCHAR(20)"]
        # the COLUMN comes back -- but every value it held is gone forever

Better

python
class Migration0008DropLegacyStatus:
    revision = "0008"
    down_revision = "0007"

    @staticmethod
    def upgrade():
        return [
            "CREATE TABLE orders_legacy_status_backup AS "
            "SELECT id, legacy_status FROM orders",   # preserve the data first
            "ALTER TABLE orders DROP COLUMN legacy_status",
        ]

    @staticmethod
    def downgrade():
        return [
            "ALTER TABLE orders ADD COLUMN legacy_status VARCHAR(20)",
            "UPDATE orders o SET legacy_status = "
            "(SELECT legacy_status FROM orders_legacy_status_backup b WHERE b.id = o.id)",
        ]

What you see: A rollback runs cleanly, no error anywhere, and the schema looks identical to before — but every value the dropped column held is gone, discovered only when someone asks for data that used to be there and gets NULL back.

Why: downgrade() adding an empty column back is not the actual inverse of upgrade() dropping a populated one — the schema shape matches, but the data does not, which is precisely the gap that makes a migration "revert cleanly with no error" a false sense of safety. A genuinely safe downgrade for a destructive upgrade has to preserve what it is about to destroy, not just recreate the column's shape afterward.

Remember: A migration's revision chains to down_revision, and downgrade() must genuinely reverse upgrade() — a destructive change (DROP COLUMN) needs its data backed up first, or "rolling back" only restores the shape, not what was actually lost.

See also: migration strategies · data migrations · rollbacks

Safe deployments

coreadvanced

A canary deployment sends a new version a small slice of traffic first, and only widens that slice while the error rate stays healthy — a bad deploy affects a fraction of users for a short window, not everyone, and never gets the chance to reach 100% traffic.

Think of it as

A canary deployment is exactly the coal-mine canary it is named for: a small, expendable exposure that reveals danger before it reaches everyone. Sending a new version 5% of traffic first means a bug shows up as "5% of users had a bad five minutes," not "100% of users had a bad deploy" — the difference between a safe deployment practice and a full outage is often nothing more than how much traffic was exposed before someone (or something automated) noticed.

python
def route_traffic_percent(canary_healthy, canary_percent):
    return min(canary_percent + 10, 100) if canary_healthy else 0

What we're doing: Run a canary through three rollout steps, widening traffic while error rate stays under threshold and reverting instantly to 0% on a breach.

canary_rollout.pypython
def canary_error_rate_ok(errors, total, threshold=0.02):
    return total == 0 or (errors / total) <= threshold


def next_canary_step(current_percent, errors, total):
    """Progressive rollout: only widen the canary while its error rate
    stays under threshold; any breach sends traffic back to 0%."""
    if not canary_error_rate_ok(errors, total):
        return 0
    if current_percent >= 100:
        return 100
    return min(current_percent + 25, 100)


steps = [
    (0, 1, 100),
    (25, 2, 500),
    (50, 40, 500),
]
percent = 0
for _, errors, total in steps:
    percent = next_canary_step(percent, errors, total)
    print(f"errors={errors}/{total} -> canary at {percent}%")
8
A breach at ANY step returns 0 immediately — the function does not "hold at the current percentage and wait to see" once the threshold is crossed.
11
A healthy step only widens by 25 percentage points at a time — the rollout deliberately never jumps straight from a small canary to full traffic.
Output
errors=1/100 -> canary at 25%
errors=2/500 -> canary at 50%
errors=40/500 -> canary at 0%

Why this works: The first two steps stay well under the 2% threshold (1% and 0.4% error rates) and the canary widens step by step — but the third step's 8% error rate (40/500) immediately drops traffic back to 0%, before the new version ever reaches the remaining 50% of users still on the old, known-good version. Those users never see the bad deploy at all.

Checking error rate once, then widening straight to 100% traffic

Wrong

python
# check once at 5%, then skip straight to full traffic if it looks fine
if canary_error_rate_ok(errors=1, total=100):
    route_traffic_percent = 100   # no intermediate steps, no more checks

Better

python
# widen gradually, re-checking the error rate at every step
percent = 0
for errors, total in observed_error_counts_over_time:
    percent = next_canary_step(percent, errors, total)
    if percent == 0:
        break   # revert immediately, do not keep widening past a breach

What you see: A bug that only shows up under real production load (a race condition, a cache stampede) passes the single 5%-traffic check cleanly, then breaks every user the instant traffic jumps to 100% — the canary caught nothing because it was only checked once, at the easiest, lowest-load step.

Why: A single check at low traffic proves the new version survives low traffic — it proves nothing about what happens under 20x more load. Widening gradually with a fresh check at every step is what actually catches load-dependent failures, while a one-shot check followed by a jump to 100% gets the appearance of a canary process with none of its actual safety.

A canary rollout, one step at a time

Deploy at 0%

new version is live but receives no traffic yet

Widen to 25%

error rate checked against threshold before advancing further

Widen again, or revert

healthy -> +25% more; breached -> straight back to 0%

Reach 100%

only after every intermediate step stayed healthy

  1. Deploy at 0% — new version is live but receives no traffic yet
  2. Widen to 25% — error rate checked against threshold before advancing further
  3. Widen again, or revert — healthy -> +25% more; breached -> straight back to 0%
  4. Reach 100% — only after every intermediate step stayed healthy

Remember: Widen a canary gradually, re-checking error rate at every step, and revert to 0% traffic immediately on any breach — a single check followed by a jump to full traffic is not a canary deployment, just a delayed all-at-once one.

See also: rollbacks · slos slis and error budgets

Mentoring junior developers

standardadvanced

Mentoring through code review means a comment explains WHY something is a problem, not just what to change — "blocker: default args are evaluated once, so a mutable default is shared across calls" teaches something reusable, while "fix this" only fixes one line.

Think of it as

A review comment that only says what to change is a one-time fix — it corrects this line and teaches nothing transferable. A review comment that explains why is a small lesson: the junior developer who understands that Python evaluates a default argument once, at def time, will never write that bug again, in this function or any other. Mentoring through review is choosing to spend a few extra words turning every correction into a lesson instead of just a patch.

python
def review_comment(kind, text):
    prefix = {"blocking": "blocker:", "suggestion": "nit:", "question": "question:"}[kind]
    return f"{prefix} {text}"

What we're doing: Build review comments that include the underlying "why" alongside the correction, and count how many are actually blocking versus optional.

mentoring_review.pypython
def review_comment(kind, text, teach=None):
    prefix = {"blocking": "blocker:", "suggestion": "nit:", "question": "question:"}[kind]
    comment = f"{prefix} {text}"
    if teach:
        comment += f"\n    why: {teach}"
    return comment


comments = [
    review_comment(
        "blocking",
        "this mutates the default argument list",
        teach="default args are evaluated once at def time, so a mutable default is shared across every call",
    ),
    review_comment("suggestion", "extract this 20-line block into calculate_discount()"),
]
for c in comments:
    print(c)
print(f"{len(comments)} comments, {sum(1 for c in comments if c.startswith('blocker'))} blocking")
4
teach is optional — not every comment needs a lesson attached, but the blocking one about a real language gotcha absolutely does.
11
This is a genuine bug (mutable default argument), correctly marked "blocking" — it must be fixed before merge, not just discussed.
15
The "nit:" suggestion models better structure without blocking the junior developer's progress — a lesson offered, not a gate.
Output
blocker: this mutates the default argument list
    why: default args are evaluated once at def time, so a mutable default is shared across every call
nit: extract this 20-line block into calculate_discount()
2 comments, 1 blocking

Why this works: The blocking comment carries a "why" line explaining the actual Python rule (mutable default arguments are evaluated once, at definition time) — a junior developer reading it learns something that generalizes to every future function they write, not just how to fix this one. The nit-level suggestion, by contrast, needs no why: extracting a named helper is self-evidently clearer, and marking it "nit:" signals it is a suggestion, not a requirement.

Remember: A mentoring review comment explains WHY, not just what to fix — that is what turns a correction into a lesson the junior developer can apply again on their own, and reserve "blocker:" for things that actually need to block.

See also: code review and readability · communicating technical trade offs

Communicating technical trade-offs

standardadvanced

Communicating a trade-off means stating each real option as gain versus cost in plain language — "p99 latency 500ms to 40ms" versus "a new dependency to operate" — and ending with an explicit recommendation, not a list of options with no conclusion.

Think of it as

A trade-off explained only in implementation detail ("we'd add a Redis instance with an LRU eviction policy and a five-minute TTL") is unreadable to anyone who was not already in the room for the design discussion. Communicating a trade-off means translating that detail into gain and cost a stakeholder can actually weigh — and then, because the engineer is the one with the technical context, making an actual recommendation instead of leaving the decision to someone less equipped to make it.

python
def summarize_tradeoff(option, gain, cost):
    return f"{option}: gain={gain}; cost={cost}"

What we're doing: Compare two real options for reducing read latency as gain-versus-cost pairs, and surface an explicit recommendation from the comparison.

tradeoff_summary.pypython
def compare_options(options):
    """options: list of dicts with name/gain/cost/recommended -- renders a
    plain-language comparison a non-engineer stakeholder can read."""
    lines = []
    for opt in options:
        marker = "-> " if opt.get("recommended") else "   "
        lines.append(f"{marker}{opt['name']}: gain={opt['gain']}; cost={opt['cost']}")
    return "\n".join(lines)


options = [
    {"name": "Add Redis cache", "gain": "p99 500ms -> 40ms", "cost": "new dependency to operate", "recommended": True},
    {"name": "Scale DB read replicas", "gain": "p99 500ms -> 150ms", "cost": "3x monthly DB spend", "recommended": False},
]
print(compare_options(options))
recommended = next(o["name"] for o in options if o["recommended"])
print(f"recommendation: {recommended}")
6
The recommended option gets a visible marker (-> ) rather than being buried in a plain list — a stakeholder reading quickly should not have to infer which option the engineer actually favors.
12
Both options state gain AND cost — neither is presented as free, and the second option's real cost (3x DB spend) is named just as plainly as its benefit.
Output
-> Add Redis cache: gain=p99 500ms -> 40ms; cost=new dependency to operate
   Scale DB read replicas: gain=p99 500ms -> 150ms; cost=3x monthly DB spend
recommendation: Add Redis cache

Why this works: Both options show a real cost, not just a benefit — a stakeholder reading this can see that the recommended option (Redis) wins on latency but is not free of tradeoffs either, and can weigh "new dependency to operate" against "3x monthly DB spend" for themselves, while still walking away with a clear, explicit recommendation rather than an unresolved menu of choices.

Remember: State each real option as gain versus cost in plain language, never hide the cost of the option being pitched, and end with an explicit recommendation — the engineer with the technical context should not leave that decision to whoever has less of it.

See also: architecture reviews · mentoring junior developers

Advertisement