Python Code Review

5 Python PR Bugs Your
Reviewer Missed

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.

Bug #01

Async/Await Concurrency Issues

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.

✗ Sequential (slow)
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
      }
✓ Concurrent (fast)
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
      }
Why reviewers miss it

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.


Bug #02

Django ORM N+1 Queries Hiding in List Comprehensions

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.

✗ N+1 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
      ]
✓ 1 query with prefetch
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
      ]
Why reviewers miss it

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.


Bug #03

Type Coercion Bugs in Python 3.10+ Pattern Matching

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.

✗ Silent type mismatch
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.
✓ Explicit type handling
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)
Why reviewers miss it

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.

Get the Python Code Review Cheatsheet (PDF)

12 patterns that break in production — with the exact fixes. Free.


Bug #04

Resource Leaks in Async Context Managers

Async generators that don't complete their iteration leave resources open. The pattern shows up most often in streaming API calls and database cursors.

✗ Resource leak
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
✓ Explicit cleanup
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()
Why reviewers miss it

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.


Bug #05

Silent Exception Swallowing in Try/Except

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.

✗ Swallowed failure
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.
✓ Explicit handling
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
Why reviewers miss it

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.


CodeSight catches these on every PR — automatically.

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

Enjoyed this? Get weekly Python code review tips.

No spam. Unsubscribe any time.