Introduction
Python is forgiving by design. That’s why it makes a great first language — and also why intermediate Python developers can write code that looks fine, passes review, and ships to production while silently doing the wrong thing. The bugs that result are some of the hardest to track down because the code looks correct.
Seven anti-patterns show up over and over in real codebases. Each one has a benign-looking surface, a non-obvious cost, and a small fix that’s strictly better. Learn these and your code reads more idiomatically, runs faster, and avoids a category of bugs that’s genuinely painful to debug.
📚 Table of contents
- 1. Ignoring time complexity of fancy syntax
- 2. Using
==when you should useis - 3. Skipping list comprehensions and generators
- 4. Manually managing files instead of using context managers
- 5.
print()instead oflogging - 6. Ignoring the standard library
- 7. Mutable default arguments — the most famous trap
- Common mistakes when fixing these
- FAQs
1. Ignoring time complexity of fancy syntax
Python’s syntax hides cost. Operations that look O(1) are actually O(n). Three classic offenders:
# Bad: building a list with + creates new list every iteration -> O(n^2)
result = []
for i in range(n):
result = result + [i]
# Bad: string concatenation in a loop is O(n^2)
text = ""
for word in words:
text = text + word
# Bad: membership check on a list is O(n) each time
if item not in big_list:
big_list.append(item)
The fixes:
result = [i for i in range(n)] # O(n)
text = "".join(words) # O(n)
seen = set(big_list)
if item not in seen: # O(1) lookup
big_list.append(item)
seen.add(item)
Rule of thumb: every time you wrap a loop with “rebuild this collection,” check whether you’ve accidentally promoted O(n) into O(n²).
2. Using == when you should use is
== checks equality by calling __eq__. is checks
identity. They overlap most of the time and diverge in the cases that matter:
# Bad
if value == None: ...
if flag == True: ...
if items == []: ...
# Good
if value is None: ...
if flag: ... # or `if flag is True:` if you specifically need True (not truthy)
if not items: ...
Why it matters: a custom class can override __eq__ to return True for
things that aren’t actually None. is None bypasses
__eq__ entirely and asks the only correct question: “is this literally the
None singleton?”
Linters like ruff and pylint flag == None for exactly this reason. The PEP 8 guidance
has been settled for years: comparisons to singletons (None, True,
False) should always use is.
3. Skipping list comprehensions and generators
List comprehensions aren’t just shorter — they’re typically faster. CPython
optimizes them to bytecode that skips the overhead of list.append in a Python-level
loop.
# Bad
squares = []
for n in nums:
squares.append(n * n)
evens = []
for n in nums:
if n % 2 == 0:
evens.append(n)
# Good
squares = [n * n for n in nums]
evens = [n for n in nums if n % 2 == 0]
# Dict and set comprehensions exist too
counts = {word: len(word) for word in words}
unique = {n for n in nums}
# Generator expression for streaming — doesn't build the list
total = sum(n * n for n in nums)
Don’t over-do it. A double-nested comprehension with three filters is unreadable; expand it. The rule of thumb: one nested loop is fine, two is the upper edge, three is too many.
4. Manually managing files instead of using context managers
The most common “works on my machine” bug in production: a file open that didn’t get closed because an exception fired before the close call.
# Bad — file may stay open on exception
f = open("data.txt")
content = f.read()
process(content) # raises? file never closed.
f.close()
# Good — context manager handles close on any exit path
with open("data.txt") as f:
content = f.read()
process(content)
# Multiple files in one with
with open("in.txt") as fi, open("out.txt", "w") as fo:
fo.write(fi.read())
Context managers extend beyond files: database connections, locks, temporary directories,
HTTP sessions. Any resource that needs setup/teardown is a candidate. Build your own with
__enter__/__exit__ or the @contextmanager decorator from
contextlib.
5. print() instead of logging
print() is fine for scripts and exploration. It’s wrong for anything that runs
in production. Reasons:
- No timestamp, no log level, no source file.
- Goes to stdout regardless of context — can’t route to file vs console.
- Can’t silence in production without code edits.
- No structured logging for downstream tools (Datadog, Sentry, Grafana Loki).
import logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
handlers=[
logging.FileHandler("app.log"),
logging.StreamHandler(),
],
)
log = logging.getLogger(__name__)
log.debug("verbose tracing")
log.info("user logged in: %s", user_id)
log.warning("rate limit close: %s/%s", current, ceiling)
log.exception("payment failed") # automatically includes traceback
Once it’s in production, you can filter by level, route by handler, and ship logs to your
observability stack. None of that is possible with print.
6. Ignoring the standard library
Python’s standard library is enormous. Most “how do I do X in Python” questions have an answer already shipped with the interpreter. Three high-frequency wins:
from collections import Counter
# Bad — hand-built frequency dict
freq = {}
for item in items:
freq[item] = freq.get(item, 0) + 1
# Good
freq = Counter(items)
freq.most_common(3) # top 3 with counts
# pathlib for file paths
from pathlib import Path
p = Path("data") / "users" / "log.txt"
if p.exists():
text = p.read_text()
ext = p.suffix # ".txt"
name = p.stem # "log"
# itertools for the loop you were about to write
from itertools import chain, groupby, accumulate
flat = list(chain.from_iterable(nested_lists))
Worth knowing well: collections (Counter, defaultdict,
deque, OrderedDict), itertools, functools,
pathlib, dataclasses, typing, contextlib,
concurrent.futures.
7. Mutable default arguments — the most famous trap
The single most surprising Python gotcha. Default arguments are evaluated once at function definition time, not on every call. If the default is mutable, every call that uses the default shares the same object.
# Bad — default list is shared across calls
def add_item_bad(item, items=[]):
items.append(item)
return items
print(add_item_bad("apple")) # ['apple']
print(add_item_bad("banana")) # ['apple', 'banana'] <-- surprise!
# Good — sentinel default; create a fresh list inside
def add_item(item, items=None):
if items is None:
items = []
items.append(item)
return items
Same trap applies to dicts and sets as defaults, and to __init__ methods that use
mutable defaults. Use None as the sentinel; initialize inside the body.
Modern tools catch this. ruff’s B006 rule flags it. Type checkers like pyright
can warn. There’s no excuse for shipping this in 2026 if your linter is on.
❌ Common mistakes when fixing these
- Cramming everything into one nested comprehension. Readability beats cleverness.
- Replacing
== Nonewithis Nonebut missing!= Nonenearby. Linters help. - Adding
print(…)for debugging and forgetting to remove it. Use a debugger or temporarylog.debug. - Using context managers without understanding what gets cleaned up. Read the
__exit__method or docs. - Switching to
loggingbut never configuring handlers properly — logs vanish into the void. - Memorising every
collectionsclass. Better to recognise the patterns and reach for the docs when needed.
💡 Pro tips
- Run
ruff checkon every codebase you touch — it catches most of these automatically. - Set up logging once in a helper module, import it everywhere. Don’t repeat
basicConfigper file. - When debugging a slow loop, profile with
cProfilefirst — the time-complexity trap is often where the time hides. - Pair every new context-manager use with a small test that verifies cleanup happens on exception.
- Read
itertoolsandfunctoolsdocs end-to-end once. Both are short. The patterns you absorb pay off forever. - Code review for these patterns specifically. They’re the seven things you can teach a new hire to look for in their first month.
Conclusion
None of these patterns are exotic. They’re the everyday stuff — the parts of Python you write every week. Fixing them is the difference between code that runs and code that runs correctly under production load. Run a linter on your existing codebase and you’ll find at least three of these patterns. Fixing them takes an afternoon.
The compound effect of writing idiomatic, performant, defensive Python every day adds up to the intangible “senior” feel that hiring managers describe. Idiomatic isn’t a judgement of taste. It’s the accumulated wisdom of millions of hours of running code.
Explore More on DevShelf
-
Defensive Python: Edge Cases and Validation
The complementary skill — validating inputs before they reach the functions where anti-patterns bite hardest.
-
Go Programming: Zero to Concurrency
How another language avoids these patterns by design — Go's explicit errors and no-mutability-by-default model compared to Python.