"Pythonic" isn't about syntax tricks. It's about a few load-bearing assumptions Python culture made differently from C-family languages. The two biggest: EAFP over LBYL (try, catch the exception; don't pre-validate every condition), and duck typing (if it walks like a duck and quacks like a duck, treat it as one).
Trust the protocol, not the type
Java says "this method takes a List." Python says "this method takes anything that supports iteration." The first is restrictive — you can't pass a generator, a tuple, a custom iterable. The second is flexible — anything that implements __iter__ works. Python culture leans hard on this; functions accept duck-typed inputs and let the caller pick the shape that fits.
The practical consequence — fewer isinstance checks
Pythonic code rarely checks isinstance(x, list) before iterating. It just iterates. If x doesn't support iteration, the caller passed the wrong thing — let the natural error rise. Adding isinstance checks "to be safe" rejects valid inputs (other iterables) and adds noise.
Where strict types DO win
Type hints (typing track) give you static checking without runtime isinstance overhead. The hint says "I expect an Iterable"; mypy verifies it; the runtime stays duck-typed. This is the modern compromise: structural protocols (typing.Protocol) for contract, no isinstance walls.
Principle: Trust the caller knew what they were doing. Document the contract (with type hints), then call methods. Errors from invalid inputs become callsite bugs to fix once, not validation gates that punish every legitimate caller.
Code
EAFP — the Python way·python
# LBYL — defensive, verbose, has a race condition
def get_field_lbyl(obj, name):
if hasattr(obj, name):
attr = getattr(obj, name)
if callable(attr):
return attr()
return attr
return None
# EAFP — try, fall back
def get_field_eafp(obj, name):
try:
attr = getattr(obj, name)
return attr() if callable(attr) else attr
except AttributeError:
return None
# Both work; EAFP is shorter and matches Python culture
Duck typing — accept any iterable·python
# UNPYTHONIC — narrow type check
def sum_strict(xs):
if not isinstance(xs, list):
raise TypeError("need a list")
total = 0
for x in xs:
total += x
return total
# PYTHONIC — accept any iterable
def sum_pythonic(xs):
total = 0
for x in xs: # works for list, tuple, generator, set, ...
total += x
return total
print(sum_pythonic([1, 2, 3]))
print(sum_pythonic((1, 2, 3)))
print(sum_pythonic(x*x for x in range(4)))
print(sum_pythonic({1, 2, 3}))
Type hints + duck typing — modern compromise·python
from typing import Iterable
def sum_typed(xs: Iterable[int]) -> int:
"""Type hint says 'iterable of int'; runtime stays duck-typed."""
return sum(xs)
# Type checker (mypy) verifies the caller passes an iterable.
# Runtime doesn't isinstance-check — just iterates.
# Best of both worlds: static contract, runtime flexibility.
When isinstance IS appropriate·python
# At system boundaries — validating untrusted data:
def parse_user_input(data):
if not isinstance(data, dict):
raise TypeError("expected dict from API")
# ... process data
# When you genuinely need to dispatch on type:
def format_value(x):
if isinstance(x, (int, float)):
return f"{x:.2f}"
if isinstance(x, str):
return f'"{x}"'
return repr(x)
# (singledispatch is even cleaner here — see oop-advanced track)
# These are valid uses. The anti-pattern is isinstance defensiveness
# inside a function whose contract is already clear from type hints.
Rewrite this LBYL function in EAFP style, then add type hints with Iterable[int]: def first_positive(items): if not isinstance(items, list): return None; for x in items: if isinstance(x, int) and x > 0: return x; return None. Show that the EAFP version works with a tuple, a generator, and a set, while the LBYL version rejects all of them.
Progress
Progress is local-only — sign in to sync across devices.