DS DevShelfHub Projects · AI tools
Articles / Stop Writing Optimistic Code: Defensive Programming and Edge Cases in Python

AI Engineering

Stop Writing Optimistic Code: Defensive Programming in Python

By DevShelfHub

Why your innocent-looking functions blow up in production — and the small validation habits that prevent it. Walks through the calculate_average failure modes (empty input, wrong types, None), the robust version with validation at the door, why type hints don't enforce types at runtime, where to validate (system boundaries) and where not to (internal helpers), and the raise-and-stop vs filter-and-coerce strategies for handling bad inputs.

Stop Writing Optimistic Code: Defensive Programming in Python

Introduction

Programmers are optimists. We write code assuming the caller will pass exactly what we expect, that inputs will always look reasonable, and that the happy path is the only path. Then production happens, weird inputs arrive, and the code blows up in ways nobody predicted.

This guide is a focused tour of defensive coding — the handful of habits that make functions robust without making them unreadable. Empty inputs, wrong types, boundary values, and the small validation gates that catch issues at the door instead of in a stack trace at 3am.

📚 Table of contents

  • The optimistic version — what most people write first
  • The failures — ZeroDivisionError, TypeError, and silent bugs
  • The robust version — validation at the door
  • Dynamic typing means type hints are documentation, not guarantees
  • Where to validate and where not to
  • Patterns: filter-and-coerce vs raise-and-stop
  • Common mistakes and best practices
  • Frequently asked questions

😇 The optimistic version

Take the most innocent-looking function in any codebase — calculating an average:

def calculate_average(numbers):
    total = 0
    for n in numbers:
        total += n
    return total / len(numbers)

print(calculate_average([10, 20, 30]))   # 20.0

Looks fine. Reads fine. Passes the obvious test. Ships.

💥 The failures

Now try the inputs the optimist didn’t imagine:

calculate_average([])
# ZeroDivisionError: division by zero

calculate_average([1, 2, "hello"])
# TypeError: unsupported operand type(s) for +=: 'int' and 'str'

calculate_average(None)
# TypeError: 'NoneType' object is not iterable

These look silly until you remember a real production function runs hundreds of thousands of times, fed by upstream systems you don’t fully control: APIs, message queues, user submissions, third-party data exports. Sooner or later, every weird input does happen.

🛡️ The robust version

from typing import Iterable, Union

Number = Union[int, float]

def calculate_average(numbers: Iterable[Number]) -> float:
    if not isinstance(numbers, (list, tuple)):
        raise TypeError(f"numbers must be a list or tuple, got {type(numbers).__name__}")

    # keep only int/float values, drop garbage
    cleaned = [n for n in numbers if isinstance(n, (int, float))]

    if not cleaned:
        raise ValueError("numbers must contain at least one numeric value")

    return sum(cleaned) / len(cleaned)

Slightly longer, dramatically more robust. The function now handles:

  • Caller passes None → clear TypeError instead of a confusing iteration failure.
  • Caller passes a string by accident → same.
  • Caller passes a list of mixed garbage → non-numeric values dropped instead of crashing on +=.
  • Empty list → clear ValueError with a useful message.

The line cost is small. The debugging cost it saves is enormous.

⚠️ Type hints don’t enforce types

Python is dynamically typed. A type hint like list[int] is documentation for humans and a target for static analysers (mypy, pyright) — not a runtime guarantee. At runtime, callers can pass anything, and Python won’t object until you try to use it the wrong way.

So either:

  • Use mypy strictly in CI so wrong types fail before merge.
  • Validate at runtime for any function exposed across module / service boundaries.

Inside one tightly-coupled module, you can probably skip the runtime check. Across a public API surface, validate.

📍 Where to validate (and where not to)

  • Validate at system boundaries: HTTP request handlers, queue consumers, file readers, anywhere data crosses from “outside” to “inside.”
  • Validate at public API surfaces: functions other modules import.
  • Don’t validate inside helper functions that are only called by code you control — you’d just be checking the same thing twice.
  • Use Pydantic / dataclasses for shape validation when you have anything beyond a couple of fields. Hand-rolled isinstance calls don’t scale.

🎛️ Filter-and-coerce vs raise-and-stop

Two reasonable strategies, picked deliberately per function:

🚦 Raise and stop

If a bad input means the request is fundamentally broken, raise early with a clear message. Lets the caller catch and handle once, instead of debugging downstream symptoms.

🧹 Filter and coerce

If a bad input means “some of the data is suspect but the operation should still complete,” drop the bad parts and proceed. Log what you dropped so it doesn’t disappear silently.

Don’t default to one. Pick per use-case. A payment-processing function should raise on any weirdness; a daily analytics aggregator can skip bad rows and log them.

✨ Best practices & common mistakes

✅ Do

  • Validate at system boundaries by default.
  • Write clear error messages that include the bad value and expected type.
  • Test empty inputs, single-element inputs, and wrong-type inputs explicitly.
  • Reach for Pydantic / dataclasses on anything more than trivial.

❌ Don’t

  • Catch Exception and silently return None — debugging hell.
  • Rely on type hints alone for runtime safety.
  • Defensive-code internal helpers the caller already validated.
  • Throw away the bad input data without logging it — you can’t fix what you can’t see.

Explore More on DevShelf

Stop Writing Optimistic Code: Defensive Programming and Edge Cases in Python FAQ

Doesn’t validation hurt readability?

A few well-placed guards at the top of a function are easy to read. Sprinkling isinstance through the body is the readability problem.

Should I validate inside a tight loop?

No — validate the collection once before the loop, not every iteration. Otherwise you pay validation cost on every element for no extra safety.

When does Pydantic beat hand-rolled checks?

Anything beyond a couple of fields. Pydantic handles nested validation, type coercion, defaults, and clear error messages for free.

Does this apply to languages with strict static types?

Less, but not zero. Static types catch most type bugs at compile time, but you still need runtime checks for invariants like “non-empty,” “valid range,” and “allowed enum.”

What about defensive programming in TypeScript?

Same principles. Use Zod or Valibot at boundaries; lean on the type system internally. Don't double-validate.