Your code review process has blind spots. Async races, N+1 queries, swallowed exceptions — these patterns compile, pass tests, and land in production. Here are the five that slip through every team.
The most common async bug isn't a missing await — it's a sequential await where you should be running concurrently. The code reads correctly and the tests pass (if you have them), but you just made your endpoint 3x slower.
async def get_dashboard(user_id: int):
# Each awaits before next starts
user = await fetch_user(user_id)
repos = await fetch_repos(user_id)
stats = await fetch_stats(user_id)
return {
"user": user,
"repos": repos,
"stats": stats
}
async def get_dashboard(user_id: int):
# All three fire simultaneously
user, repos, stats = await asyncio.gather(
fetch_user(user_id),
fetch_repos(user_id),
fetch_stats(user_id)
)
return {
"user": user,
"repos": repos,
"stats": stats
}
Both versions look correct. The slow one has proper await syntax. The reviewer sees no bug, just style — and style calls feel like bikeshedding. An automated reviewer sees the dependency graph: user, repos, and stats have no dependencies on each other, so sequential awaiting is always wrong.
The worse variant is a race condition introduced by shared mutable state inside asyncio.gather. Tasks that write to the same dict or list without coordination will corrupt each other intermittently — no exception, wrong data, hard to reproduce.
The N+1 problem is well-known. What reviewers miss is when it wears a list comprehension disguise — it looks like a single operation, but it fires N database queries.
def get_team_summary(org_id):
repos = Repository.objects.filter(
org_id=org_id
)
# One query per repo — fires N times
return [
{
"name": repo.name,
"owner": repo.owner.username,
"pr_count": repo.pullrequest_set
.count()
}
for repo in repos
]
def get_team_summary(org_id):
repos = Repository.objects.filter(
org_id=org_id
).select_related(
"owner"
).prefetch_related(
"pullrequest_set"
)
return [
{
"name": repo.name,
"owner": repo.owner.username,
"pr_count": repo.pullrequest_set
.count()
}
for repo in repos
]
The list comprehension looks clean. The reviewer sees the iteration logic, not the implicit ORM calls hidden inside each property access. Attribute access in Python is silent — repo.owner looks like a local property but triggers a SELECT. Catching this requires knowing the ORM traversal semantics cold, not just reading the code.
In a 10-repo org, this fires ~21 queries. With 500 repos, it fires over 1,000. Django Debug Toolbar catches it during development. In production, you get timeouts — eventually.
Structural pattern matching (match/case) was added in Python 3.10. It looks like a switch statement, but it isn't. The type matching semantics trip up even senior engineers who switch from other languages.
def handle_event(event: dict):
match event.get("status"):
case 200:
return process_success(event)
case 404:
return handle_not_found(event)
case _:
return handle_unknown(event)
# API returns "200" (string) — falls
# through to handle_unknown() every time
# No error. Wrong behavior. Good luck.
def handle_event(event: dict):
status = event.get("status")
# Normalize before matching
try:
status = int(status)
except (TypeError, ValueError):
return handle_unknown(event)
match status:
case 200:
return process_success(event)
case 404:
return handle_not_found(event)
case _:
return handle_unknown(event)
The pattern match looks complete. All cases are covered, there is a catch-all. The bug requires knowing that match does strict equality on literal patterns — no coercion. A reviewer not familiar with the 3.10 semantics will see complete case coverage and move on. The failure is silent and conditional on input type.
This exact bug appears in webhook handlers where the upstream API returns stringified status codes. The wrong case always runs. No exception, no log, just wrong behavior — diagnosed days later when a customer reports that success emails never arrive.
Async generators that don't complete their iteration leave resources open. The pattern shows up most often in streaming API calls and database cursors.
async def stream_records(query: str):
async with db.cursor() as cursor:
await cursor.execute(query)
async for row in cursor:
yield process_row(row)
# If caller breaks early,
# cursor never closes
# Caller that bails:
async def get_first_ten(query):
results = []
async for item in stream_records(query):
results.append(item)
if len(results) == 10:
break # cursor leaked
return results
async def stream_records(query: str):
cursor = await db.cursor()
try:
await cursor.execute(query)
async for row in cursor:
yield process_row(row)
finally:
# Runs even if caller breaks early
await cursor.close()
# Or use aclose() on the generator
# explicitly at the call site:
async def get_first_ten(query):
gen = stream_records(query)
try:
results = []
async for item in gen:
results.append(item)
if len(results) == 10:
break
return results
finally:
await gen.aclose()
The async with in the bad example looks like it provides cleanup. It does — but only when the generator runs to completion. Early exits via break, raised exceptions, or timeouts bypass the context manager exit. Reviewers focus on the happy path. Resource leaks only surface under load or intermittent errors.
The symptom: connection pool exhaustion at 3am, 30 days after deploy. The postmortem always finds a generator that was never explicitly closed.
This is the most common and most destructive pattern on this list. A broad except clause that logs (or doesn't even log) and returns a default value converts a hard failure into invisible corruption.
def calculate_risk_score(user_id: int) -> float:
try:
user = fetch_user(user_id)
history = fetch_payment_history(user_id)
return compute_score(user, history)
except Exception:
# "Safe" default
return 0.5
# What actually happens:
# - DB is down? Returns 0.5
# - User not found? Returns 0.5
# - compute_score has a divide-by-zero?
# Returns 0.5
# Every user gets approved. No one knows.
def calculate_risk_score(user_id: int) -> float:
try:
user = fetch_user(user_id)
except UserNotFoundError:
raise # caller handles this
try:
history = fetch_payment_history(user_id)
except PaymentServiceUnavailable:
# Known transient — let caller retry
raise
try:
return compute_score(user, history)
except ZeroDivisionError as e:
# Unexpected — surface it
logger.error(
"score_compute_failed",
user_id=user_id,
error=str(e)
)
raise RuntimeError(
f"Score computation failed for {user_id}"
) from e
The broad except looks defensive. It looks like the developer was being careful. Reviewers see error handling and move on. The problem surfaces months later when a downstream service starts returning malformed data — every affected user silently gets the default value, business logic runs on garbage, and the bug report is "users seem to be getting approved who shouldn't be."
The fix isn't always to re-raise. Sometimes a default is correct. But the exception type must be specific, the fallback must be intentional, and every silent catch must emit an observable signal — a metric, a structured log, something. If the code can silently fail, it will silently fail.
Async dependency analysis, ORM traversal detection, exception handling review. On every Python PR, before it merges, in 30 seconds.
Install Free on GitHub 5 PRs/month free · No credit card · Uninstall in one click