DS DevShelfHub Projects · AI tools
Articles / 7 Python Anti-Patterns That Quietly Kill Your Code (And How to Fix Each)

AI Engineering

7 Python Anti-Patterns That Quietly Kill Your Code

By DevShelfHub

Seven Python anti-patterns that look fine in review and cause real bugs in production — accidentally O(n²) loops with + and string concat, == None instead of is None, hand-rolling what list comprehensions do faster, manual file open/close, print() instead of logging, ignoring collections / pathlib / itertools, and the famous mutable default argument trap. Each one with a before/after and the linter rule that catches it automatically.

7 Python Anti-Patterns That Quietly Kill Your Code

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 use is
  • 3. Skipping list comprehensions and generators
  • 4. Manually managing files instead of using context managers
  • 5. print() instead of logging
  • 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:

Python
# 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:

Python
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:

Python
# 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.

Python
# 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.

Python
# 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).
Python
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:

Python
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.

Python
# 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 == None with is None but missing != None nearby. Linters help.
  • Adding print(…) for debugging and forgetting to remove it. Use a debugger or temporary log.debug.
  • Using context managers without understanding what gets cleaned up. Read the __exit__ method or docs.
  • Switching to logging but never configuring handlers properly — logs vanish into the void.
  • Memorising every collections class. Better to recognise the patterns and reach for the docs when needed.

💡 Pro tips

  • Run ruff check on every codebase you touch — it catches most of these automatically.
  • Set up logging once in a helper module, import it everywhere. Don’t repeat basicConfig per file.
  • When debugging a slow loop, profile with cProfile first — 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 itertools and functools docs 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

7 Python Anti-Patterns That Quietly Kill Your Code (And How to Fix Each) FAQ

Is print() really that bad?

For scripts and notebooks, no — it's fine. For anything that runs in production, including any service that runs more than a few seconds at a time, yes. The cost of switching to logging is one helper module.

Are list comprehensions always faster than loops?

Usually yes for simple transformations. For complex bodies with multiple branches or expensive operations the difference shrinks. Profile before optimising; default to comprehensions for simple cases.

Why is the mutable default argument trap so famous?

Because it's the most counterintuitive piece of Python behaviour. Every other language treats defaults as “evaluated per call.” Python deliberately doesn't. The asymmetry catches everyone exactly once, and they remember it forever afterward.

Should I write my own context manager?

Yes for resources that need cleanup. Database transactions, locks, temporary files, network sessions. The @contextmanager decorator from contextlib makes it a five-line affair for simple cases.

What other anti-patterns should I learn?

The next tier: exception swallowing (except Exception: pass), wildcard imports, using eval/exec on user input, returning different types from the same function, and depending on dict insertion order in code that needs to work across Python versions older than 3.7.

Are these the same as code smells?

Closely related. Anti-patterns are habits that look fine but are wrong. Code smells are structural hints that something deeper is off. Fixing anti-patterns is a refactor; fixing code smells is often a redesign.