Python • Problem-Solving Patterns

Common Python Edge Cases — Avoid common Python edge cases (integer division, mutable defaults, None handling)

Master the sneaky Python pitfalls that trip up even experienced programmers and learn defensive coding patterns.

Code braces with terminal and markup symbols representing Python programming.
Inside this lesson 10 sections 5 questions 8 figures

SECTION 1

Historical Context & Motivation

Every programming language carries a set of design decisions that can surprise newcomers. Python, despite its reputation for readability and simplicity, contains several edge cases — unexpected behaviors that arise in specific situations. These quirks are not bugs; they are consequences of deliberate choices made by Python's creator, Guido van Rossum, and the core development team over decades of evolution. Understanding these edge cases is essential because they cause real bugs in real programs, from homework assignments to professional software.

Many of these pitfalls stem from Python's transition from version 2 to version 3, its approach to memory management with mutable objects, and its use of None as a special sentinel value. Let's trace the key moments that shaped these behaviors.

1991 • Python 1.0 Released

Guido van Rossum releases Python with C-style integer division, where dividing two integers always produces an integer — a behavior inherited from languages like C and Java.

2000 • Python 2.0 & Mutable Defaults

Python 2.0 introduces list comprehensions and other features. The mutable default argument behavior — where default lists and dicts are shared across calls — is already present and starts generating widespread confusion in online forums.

2001 • PEP 238 Proposes True Division

Recognizing that integer division causes countless bugs, PEP 238 proposes changing the / operator to return a float. The // operator is introduced for explicit floor division.

2008 • Python 3.0 Fixes Division

Python 3 makes true division the default: 7 / 2 now returns 3.5. However, the mutable default and None handling behaviors remain, continuing to challenge programmers.

2020s • Modern Best Practices

Type hints, linters like pylint and mypy, and style guides now explicitly warn against mutable defaults and improper None checks. Defensive coding becomes standard practice.

The central question this lesson addresses is: What are the most common Python edge cases, and how can you write code that avoids them? By the end of this lesson, you'll recognize these traps before they bite you and know the defensive patterns that professional developers use daily.

SECTION 2

Core Principles & Definitions

Before diving into specific edge cases, you need to understand three foundational concepts that explain why these pitfalls exist. Each one connects to how Python manages data behind the scenes.

1

Integer Division vs. True Division

The / operator performs true division (returns a float), while // performs floor division (rounds down to the nearest integer). Mixing these up — especially with negative numbers — creates subtle bugs.
2

Mutable Default Arguments

When you use a mutable object like a list [] or dictionary {} as a default parameter in a function, Python creates that object once at definition time and reuses it across every call.
3

None Handling & Identity Checks

None is Python's null value — a singleton object meaning 'no value.' Checking for None requires is None (identity check), not == None (equality check), because equality can be overridden.
4

Mutable vs. Immutable Objects

Immutable objects (int, str, tuple) cannot be changed after creation. Mutable objects (list, dict, set) can be modified in place. This distinction is the root cause of the mutable default problem.
✦

KEY TAKEAWAY

Think of a mutable default argument like a shared whiteboard in a classroom. The first student writes on it, and when the next student comes in, the board still has the first student's writing. Each function call sees the same whiteboard — the same list object — not a fresh one. The fix is to use None as the default and create a new object inside the function each time.

SECTION 3

Visual Explanation — How Mutable Defaults Surprise You

The diagram below illustrates what happens when you define a function with a mutable default list and call it multiple times. On the left, you see the broken pattern where the default list accumulates items across calls. On the right, you see the correct pattern using None as the sentinel.

❌ BROKEN PATTERN✅ CORRECT PATTERNdef add(item, lst=[]): lst.append(item) return lstdef add(item, lst=None): if lst is None: lst = [] lst.append(item) return lstCall 1: add('a')['a']← shared list objCall 2: add('b')['a', 'b']← SAME list!Call 3: add('c')['a', 'b', 'c']← Keeps growing!Call 1: add('a')['a']← fresh listCall 2: add('b')['b']← fresh listCall 3: add('c')['c']← fresh listProblem:The default [] is created ONCE when thefunction is defined, not on each call.Fix:Use None, then create a new list inside.
Left: the broken pattern where a default list [] accumulates data across function calls. Right: the correct pattern using None as a sentinel and creating a fresh list inside the function body.

Notice how on the left side, the list keeps growing with each call. This is because Python evaluates default arguments exactly once — when the def statement runs. The list object is then stored as part of the function's metadata and reused. On the right side, the None default is immutable, so a brand-new list is created on each call inside the function body.

SECTION 4

How Each Edge Case Works Under the Hood

Edge Case 1: Integer Division & Floor Division

In Python 3, the / operator always returns a float, even when both operands are integers. The // operator performs floor division, which rounds the result toward negative infinity — not toward zero. This distinction matters especially with negative numbers.

TRUE DIVISION

7/2→3.57 / 2 → 3.5

The / operator always produces a float result, even for integer operands.

FLOOR DIVISION (POSITIVE)

7//2→37 // 2 → 3

Floor division rounds down. For positive numbers, this behaves like truncation.

FLOOR DIVISION (NEGATIVE) — THE TRAP

−7//2→−4(not−3!)−7 // 2 → −4 (not −3!)

Floor division rounds toward negative infinity. Since −3.5 rounds down to −4, you get −4 instead of the −3 you might expect from simple truncation.

Edge Case 2: Mutable Default Arguments

When Python encounters a def statement, it evaluates the default argument expressions immediately and stores the resulting objects. For immutable defaults like integers or strings, this is fine because they cannot change. But for mutable defaults like lists, dictionaries, or sets, the same object is shared across every call that does not explicitly provide that argument. Any modifications accumulate in the shared object.

Edge Case 3: None Handling

None is a singleton in Python — there is exactly one None object in memory. You should always check for it using is None or is not None rather than == None. The is operator checks identity (same object in memory), while == checks equality (which a class can override via the __eq__ method). A poorly written class could make x == None return True even when x is not actually None.

⚠

Watch Out: Falsy Values

In Python, several values are falsy — they evaluate to False in boolean context. These include None, 0, "" (empty string), [] (empty list), and {} (empty dict). Writing if not x: when you mean if x is None: can accidentally catch 0 or empty strings, leading to incorrect behavior.

SECTION 5

Detailed Breakdown — Division Behavior & Falsy Traps

The following diagram maps out the full decision tree for division operations in Python 3 and highlights the negative-number trap that catches many students. Below the diagram is a comprehensive table of falsy values and the correct way to check for each one.

Python Division Decision Treea ÷ buse /use //True DivisionFloor Division7 / 2 → 3.5−7 / 2 → −3.5Always returns float ✓positivenegative7 // 2 → 3Rounds down = truncate ✓−7 // 2 → −4Rounds toward −∞ ⚠️⚠️ THE TRAP−3.5 rounds DOWN to −4not toward zero (−3).Fix: use int(−7 / 2) → −3for truncation toward zero.
A decision tree showing how Python 3 handles the / and // operators. The red box highlights the negative floor division trap: −7 // 2 yields −4, not −3.
ValueTruthinessCheck for None?Correct Check
NoneFalsyYesx is None
0FalsyNo — it is a valid intx == 0
""FalsyNo — it is a valid strx == ""
[]FalsyNo — it is a valid listlen(x) == 0
FalseFalsyNo — it is a valid boolx is False

Python falsy values and the correct way to check for each

SECTION 6

Worked Example — Fixing a Buggy Function

Suppose you're writing a grade tracker that stores student scores. The following buggy function uses all three edge cases we've studied. Let's identify and fix each bug step by step.

Debugging a Grade Tracker Function

  1. 1

    Step 1 — Read the Buggy Code

    Here is the original function: def add_grade(name, score, grades=[]): avg = score // 1 # intended to keep integer part if not name: # intended to check if name is None name = 'Unknown' grades.append((name, avg)) return grades This function has three bugs related to our edge cases.
  2. 2

    Step 2 — Identify Bug #1: Mutable Default

    The parameter grades=[] creates a single list that persists across calls. If we call add_grade('Alice', 92) and then add_grade('Bob', 85), Bob's result will also contain Alice's grade.
    Fix: Change to grades=None and add if grades is None: grades = [] inside the function.
  3. 3

    Step 3 — Identify Bug #2: Floor Division with Negatives

    The line avg = score // 1 works fine for positive scores like 92.7 (giving 92). But if a score is negative (say, a penalty of −3.5), -3.5 // 1 gives -4.0 instead of -3 because floor division rounds toward negative infinity.
    Fix: Use avg = int(score) which truncates toward zero, giving −3 for −3.5.
  4. 4

    Step 4 — Identify Bug #3: Incorrect None Check

    The condition if not name: catches more than just None. It also catches the empty string "". If a student has a blank name from a form field, that might be a different case than receiving no name at all. Using if not name: conflates these two situations.
    Fix: Use if name is None: to specifically check for None, or handle empty strings separately.
  5. 5

    Step 5 — Write the Fixed Function

    Here is the corrected version: def add_grade(name, score, grades=None): if grades is None: grades = [] avg = int(score) if name is None: name = 'Unknown' grades.append((name, avg)) return grades
    All three edge cases are now handled correctly. The function creates a fresh list per call, truncates toward zero, and checks identity for None.

SECTION 7

Comparing Correct vs. Incorrect Patterns

The table below summarizes the dangerous patterns alongside their safe alternatives. Use this as a quick reference sheet when writing or reviewing Python code.

Edge Case❌ Dangerous Pattern✅ Safe Pattern
Mutable defaultdef f(x, lst=[]):def f(x, lst=None): then if lst is None: lst = []
Negative floor div-7 // 2 → -4int(-7 / 2) → -3 (truncation toward zero)
None checkif x == None: or if not x:if x is None:
Check for empty listif x is None: (confuses None with [])if not x: when you know x is always a list, or if len(x) == 0:
Integer vs. float divisionAssuming 7/2 gives 3Know that 7/2 gives 3.5; use // for integer result

Quick reference: dangerous patterns vs. safe alternatives

✦

KEY TAKEAWAY

Think of these edge cases like road signs. You don't need to memorize every stretch of highway, but you do need to recognize the warning signs: =[] in a function signature, // with negative numbers, and == None instead of is None. Once you learn to spot these signs, you can avoid the potholes entirely.

SECTION 8

Connection to Advanced Concepts

The edge cases covered in this lesson are entry points into deeper Python concepts. As you advance, you'll encounter more sophisticated versions of these same patterns. Understanding the basics now sets you up for success with advanced tools like type checking, testing frameworks, and functional programming paradigms.

This LessonAdvanced Concept
Mutable default argumentsObject identity & aliasing — understanding how Python variables are references to objects, not boxes holding values. This extends to deep copy vs. shallow copy.
None sentinel patternOptional types & type hints — using Optional[List[int]] in type annotations and static analysis with mypy to catch None errors before runtime.
Floor division behaviorModular arithmetic & the divmod() function — Python's floor division pairs with the % modulo operator, and understanding both is crucial for algorithms involving remainders, hash functions, and cyclic patterns.
Falsy value awarenessTruthiness protocols & __bool__ — custom classes can define their own truthiness by implementing the __bool__ or __len__ dunder methods.

How this lesson's edge cases connect to advanced Python topics

As you continue learning Python, consider exploring type hints (introduced in Python 3.5) which let you annotate function parameters with their expected types. Tools like mypy can then catch many of these edge cases at analysis time, before your code even runs. This is a powerful step toward writing more reliable programs.

SECTION 9

Practice Problems

Question 1: CONCEPTUAL

Explain in your own words why using def collect(item, bag=[]): as a function signature can lead to unexpected behavior. What would happen if you called collect('apple') three times without providing the bag argument?
Show answer
The default list [] is created once when the function is defined, not each time it's called. On the first call, bag becomes ['apple']. On the second call, that same list grows to ['apple', 'apple']. By the third call, it's ['apple', 'apple', 'apple']. The fix is to set the default to None and create a new list inside the function with if bag is None: bag = [].

Question 2: BASIC CALCULATION

Predict the output of each expression: a) 10 / 3 b) 10 // 3 c) -10 // 3 d) int(-10 / 3)
Show answer
a) 3.3333333333333335 — true division always returns a float. b) 3 — floor division rounds down, and for positive numbers this is straightforward truncation. c) -4 — floor division rounds toward negative infinity, so −3.33… becomes −4, not −3. d) -3 — int() truncates toward zero, giving the −3 that many people expect. The key lesson: use // for floor division and int(a/b) for truncation toward zero.

Question 3: INTERMEDIATE

Consider this code: def process(value): if not value: return 'Nothing provided' return f'Got: {value}' What does process(0) return? What does process('') return? What does process(None) return? Explain why the function may not behave as intended and suggest a fix.
Show answer
All three calls return 'Nothing provided' because 0, '', and None are all falsy in Python. The if not value: check cannot distinguish between 'no value was given' (None) and 'a valid but falsy value was given' (0 or empty string). If the intent is to catch only None, the fix is if value is None:. Then process(0) would correctly return 'Got: 0' and process('') would return 'Got: '.

Question 4: APPLIED

You're building a temperature logger. The function below records readings, but users report that readings from yesterday are showing up in today's log. Find and fix all bugs: def log_temp(temp_f, readings=[]): temp_c = (temp_f - 32) * 5 // 9 if temp_c == None: return readings readings.append(temp_c) return readings
Show answer
There are three bugs. First, readings=[] is a mutable default — it should be readings=None with if readings is None: readings = [] inside the function. Second, (temp_f - 32) * 5 // 9 uses floor division, which gives incorrect results for below-freezing temperatures: for 14°F, you get (14−32) × 5 // 9 = −90 // 9 = −10, which happens to be correct, but for 15°F, (15−32) × 5 // 9 = −85 // 9 = −10 instead of −9 (truncation). Use int((temp_f - 32) * 5 / 9) for truncation toward zero, or use float division for precision. Third, == None should be is None. Additionally, the None check on temp_c doesn't make sense here since the math always produces a number — this might indicate a design issue where the function should check if temp_f is None instead.

Question 5: CRITICAL THINKING

Python chose to make floor division round toward negative infinity (not toward zero as in C/Java). Research or reason about: Why might this choice be useful? Hint: think about the relationship between // and % (modulo). In Python, the equation a == (a // b) * b + (a % b) always holds true. How does rounding toward negative infinity make the modulo operator behave more consistently?
Show answer
Python's floor division ensures that the modulo result always has the same sign as the divisor. Since a == (a // b) * b + (a % b) must hold, and // rounds toward negative infinity, the remainder a % b is always in the range [0, b) for positive b. For example, -7 % 3 == 2 in Python (since −7 = (−3) × 3 + 2). In C/Java, where division truncates toward zero, -7 % 3 == -1. Python's behavior is more mathematically consistent for cyclic operations like clock arithmetic, array index wrapping, and hash table calculations. This is a deliberate design choice — Guido van Rossum has written about it, calling the mathematical consistency more important than matching C's behavior.

SUMMARY

Lesson Summary

Summary

This lesson covered three critical Python edge cases that every programmer should know. Integer division in Python 3 uses / for true division (always returns a float) and // for floor division, which rounds toward negative infinity — a trap with negative numbers. Mutable default arguments like def f(x, lst=[]): create a shared object across calls; the fix is the None sentinel pattern: set the default to None and create a fresh object inside the function.

None handling requires using is None (identity check) rather than == None (equality check), because equality can be overridden. Be cautious with if not x: — it catches all falsy values (0, empty string, empty list, None), not just None. Memorize the defensive patterns: use None defaults, is None checks, and int(a/b) for truncation toward zero. These small habits prevent big bugs.

Varsity Tutors • Python • Common Python Edge Cases