Problem Solved

Python · append vs extend

Home › Courses › Python › Problems › append vs extend
🐍 Concept · 3 min read

append vs extend vs +=.
One item. Or many.

Three ways to add to a list. They look similar. They do very different things. append adds one item — even if that item is a list, it becomes a nested element. extend adds each item of an iterable individually. += is equivalent to extend — it's the same operation with different syntax. This page covers the difference, the string trap, the None return, and when each is the right tool.

Short answer: append(x) adds x as one element. extend(iterable) adds each item of iterable. += iterable is the same as extend. All three mutate in place and return None. All three are efficient for building lists. Use append for one item, extend or += for a collection. Watch out for extend('abc') — it adds each character, not the string.

✅ The three in action

Same list, three operations.

operations.py
nums = [1, 2, 3]

# append — add ONE item
nums.append(4)
print(nums)  # [1, 2, 3, 4]

# append a list — adds the list as ONE element
nums = [1, 2, 3]
nums.append([4, 5])
print(nums)  # [1, 2, 3, [4, 5]]

# extend — add each item of an iterable
nums = [1, 2, 3]
nums.extend([4, 5])
print(nums)  # [1, 2, 3, 4, 5]

# extend with a tuple
nums = [1, 2, 3]
nums.extend((4, 5))
print(nums)  # [1, 2, 3, 4, 5]

# extend with a string — adds each character
nums = [1, 2, 3]
nums.extend('abc')
print(nums)  # [1, 2, 3, 'a', 'b', 'c']

# += is the same as extend
nums = [1, 2, 3]
nums += [4, 5]
print(nums)  # [1, 2, 3, 4, 5]

# None return
nums = [1]
result = nums.append(2)
print(result)  # None
print(nums)    # [1, 2]

# vs + — creates a NEW list
nums = [1, 2, 3]
new_nums = nums + [4]
print(nums)      # [1, 2, 3]  — unchanged
print(new_nums)  # [1, 2, 3, 4]
The difference:

  [1, 2, 3].append([4, 5])
  → [1, 2, 3, [4, 5]]
  → 4 items: 1, 2, 3, and the list [4, 5]
  → len is 4
  
  [1, 2, 3].extend([4, 5])
  → [1, 2, 3, 4, 5]
  → 5 items: 1, 2, 3, 4, 5
  → len is 5

The key insight:
  append adds ONE thing — even if that thing is
  a list, it's one thing.
  extend adds each thing — if you give it a list,
  each item in the list is added individually.

The mental model:
  append is like dropping a box into a shelf.
    The box might contain many items, but it's
    one box on the shelf.
  
  extend is like emptying a box onto the shelf.
    Each item from the box goes onto the shelf
    individually.

Where the confusion comes from:
  In JavaScript, array.push(arr) appends the
  array as one element (like Python's append).
  In Python, some people expect extend-like
  behavior from append because they're used
  to "adding items."

  The naming is precise:
    append → "add to the end" (as one element)
    extend → "extend the list with each item"

When to use each:

✅ append — when adding ONE item:
   - Adding a single value: nums.append(4)
   - Adding a computed value: results.append(compute())
   - Building a list one by one in a loop:
     for x in data: results.append(transform(x))
   - When the item is a container (list, dict,
     tuple) and you want it as one element:
     rows.append(row)  # row is one row

✅ extend — when adding MANY items:
   - Concatenating two lists: list1.extend(list2)
   - Adding a range: list.extend(range(5))
   - Adding a sequence of items: list.extend(items)
   - Adding characters: chars.extend("hello")

✅ += — same as extend, different syntax:
   - list1 += list2
   - Readability: `+=` is shorter, `extend` is
     clearer about intent
   - Performance: identical (both in-place)
   - Prefer extend for clarity, += for brevity

❌ + — creates a NEW list (not the same thing):
   - new_list = list1 + list2  # new list
   - list1 + list2 doesn't modify list1
   - Use for: getting a new list from two
   - Don't use for: building a list in a loop
     (creates N intermediate lists, O(n²))

The common bug:

  # Expected: add each item
  results = []
  for x in data:
      results.append(x * 2)   # ✅ correct
  
  # Wrong:
  results = []
  for x in data:
      results += [x * 2]       # ✅ also works
  
  # But:
  results = []
  for x in data:
      results = results + [x * 2]   # ❌ O(n²)!
  
  The last one creates a new list every iteration.
  For 10,000 items, that's 50 million operations.
  For 10 items, it's fine. For 10,000, it's slow.
  
  Fix: use append or +=.
  
  append is O(1) amortized (list over-allocates).
  += is also O(1) amortized (calls extend).
  + is O(n) per iteration (creates new list).

✅ The None return

Why append returns None.

none-return.py
# ❌ The classic bug — expecting a return value
nums = [1, 2, 3]
result = nums.append(4)
print(result)  # None — not [1, 2, 3, 4]!

# Why: append mutates in place and returns None.
# This is by design — Python's convention.

# ✅ Correct — call append separately
nums = [1, 2, 3]
nums.append(4)
print(nums)  # [1, 2, 3, 4]

# ✅ Or use + for a new list
nums = [1, 2, 3]
result = nums + [4]
print(result)  # [1, 2, 3, 4]

# Same for extend
nums = [1, 2, 3]
result = nums.extend([4, 5])
print(result)  # None

# Same for +=
nums = [1, 2, 3]
# result = nums += [4]  # SyntaxError — += is a statement

# Why the None return?

# 1. Consistency
#    All in-place methods return None:
#      list.append, list.extend, list.sort,
#      list.reverse, list.insert, list.remove,
#      dict.update, dict.clear, set.add
#    The None means "I modified the object,
#    I didn't create a new one."

# 2. Prevent the bug
#    If append returned the list, you might write:
#      result = original.append(x)
#    expecting a new list, but you'd get the
#    same list — modified. The None forces you
#    to think about mutation vs new list.

# 3. Idiomatic Python
#    In-place mutation is a common pattern in
#    Python. Returning None signals "look at
#    the original."

# The antipattern:
#   result = []
#   result.append(x).append(y)  # AttributeError: NoneType
#   # None has no append
#
# Fix: chain on the list itself, not the return:
#   result = []
#   result.append(x)
#   result.append(y)
# Or:
#   result.extend([x, y])

# What DOES return?
#   list.copy() → new list
#   list + [x] → new list
#   list[:] → new list
#   sorted(list) → new list
#   list.copy() → new list
#
# What DOESN'T?
#   list.append() → None
#   list.extend() → None
#   list.sort() → None
#   list.reverse() → None
Why None is a feature, not a bug:

The design pattern:

  In-place mutators return None:
    → append, extend, insert, remove, pop,
      clear, sort, reverse
    → dict.update, dict.clear, dict.pop
    → set.add, set.remove, set.update
  
  New-object creators return the new object:
    → list.copy(), list + [x], list[:]
    → sorted(list), reversed(list) (iterator)
    → copy.copy(obj), copy.deepcopy(obj)
    → {**d1, **d2}, d1 | d2 (Python 3.9+)

The convention:
  None from a method means "look at the receiver,
  not the return value."
  A return value means "look at the return value,
  the receiver is unchanged."

This is how Python prevents the classic bug:

  # In JavaScript:
  const arr = [1, 2, 3];
  const result = arr.push(4);  // returns length (4)
  // arr is [1, 2, 3, 4]
  // result is 4

  # In Python:
  nums = [1, 2, 3]
  result = nums.append(4)  # returns None
  # nums is [1, 2, 3, 4]
  # result is None — you can't accidentally
  # use the return as if it were the list

The None return forces a decision:

  ❌ result = nums.append(4)
     # result is None. Probably a bug.
     # What did you mean?
  
  ✅ nums.append(4)
     # Modifies nums. No confusion.
  
  ✅ result = nums + [4]
     # Creates a new list. nums is unchanged.

The choice is explicit.
No hidden semantics.

Compare to dict.update:

  d = {'a': 1}
  result = d.update({'b': 2})  # None
  # d is {'a': 1, 'b': 2}
  # result is None
  
  # Or use | (Python 3.9+):
  d = {'a': 1}
  result = d | {'b': 2}  # {'a': 1, 'b': 2}
  # d is {'a': 1}, result is new dict

Same pattern: in-place → None, new → return.

Chaining:

  Since in-place methods return None, you can't
  chain them:

  ❌ nums.append(1).append(2)   # AttributeError
  
  ✅ nums.append(1)
     nums.append(2)
  
  ✅ nums.extend([1, 2])
  
  For chaining, use fluent APIs:
    Some libraries return self from mutators
    (e.g., pandas DataFrames, some ORMs).
    Python's built-in list doesn't — and that's
    intentional. The standard library favors
    clarity over chaining.

The test:

  If you're unsure whether a method returns
  a new object or None, check:
    → Does it mutate the receiver?  → returns None
    → Does it create a new object?  → returns it

  In-place: append, extend, sort, reverse,
            update, add, clear, remove, insert
  Return:   copy, sorted, reversed, slice,
            +, |, |=

The one exception: += on lists.

  nums += [4, 5] returns None (it's a statement,
  not an expression). But it mutates.
  
  This matches the pattern: mutation → None.
  You can't write `result = nums += [4]`
  because += is a statement in Python.

The bigger picture:

  Python's standard library follows a "clarity
  over cleverness" principle. The None return
  forces you to be explicit about whether you
  want mutation or a new object.
  
  Some developers find this annoying.
  Some find it protective.
  
  The alternative (returning self, like
  pandas) enables fluent chains but makes it
  easier to write `x = y.append(z)` and expect
  a new list.
  
  Python chose the safer option.

✅ Building lists efficiently

append vs + in loops.

performance.py
# ✅ Building a list — append is O(1) amortized
results = []
for x in range(10000):
    results.append(x * x)

# ✅ extend is also O(k) for k items
results = []
for chunk in chunks:
    results.extend(chunk)

# ✅ List comprehension — idiomatic, fastest
results = [x * x for x in range(10000)]

# ❌ + in a loop is O(n²)
results = []
for x in range(10000):
    results = results + [x * x]
    # Each iteration creates a new list
    # Total: 1 + 2 + 3 + ... + 10000 = 50M operations

# Why += is fine (unlike +)
results = []
for x in range(10000):
    results += [x * x]
    # += mutates in place (same as extend)
    # O(1) amortized per iteration

# The comparison:

# | Method         | Per iteration | Total for n  |
# |----------------|---------------|--------------|
# | append         | O(1) amortized| O(n)         |
# | extend(chunk)  | O(k)          | O(n)         |
# | += [item]      | O(1) amortized| O(n)         |
# | = + [item]     | O(n)          | O(n²)        |
# | comprehension  | —             | O(n) fastest |

# When to use which:

# ✅ List comprehension — when you have a transform
results = [f(x) for x in data]

# ✅ Generator + extend — when you have a generator
results.extend(gen)

# ✅ Append — when you need conditional logic
results = []
for x in data:
    if should_include(x):
        results.append(transform(x))

# ✅ Extend — when you're accumulating collections
all_items = []
for group in groups:
    all_items.extend(group)

# ❌ results = results + [x] in a loop — never

# The comprehension is faster because it's
# optimized in C. For 10,000 items:
#   append in loop:  ~1ms
#   comprehension:   ~0.5ms
# The difference is small, but comprehensions
# also read more clearly.

# When comprehensions don't fit:

# 1. Multiple conditions:
results = []
for x in data:
    if x > 0:
        if x % 2 == 0:
            results.append(x * 2)

# 2. Side effects:
results = []
for x in data:
    results.append(x)
    log(f'Processed {x}')

# 3. Early exit:
results = []
for x in data:
    if x < 0:
        break
    results.append(x)
Why append is O(1) amortized:

  CPython's list uses over-allocation.
  When the list grows beyond capacity:
    → Allocates more space (typically ~1.125x)
    → Copies existing items
    → Capacity doubles-ish
  
  Most appends fit in the pre-allocated space
  → O(1) per append
  → Occasionally a resize copies → O(n)
  → Amortized over many appends → O(1)

  For 10,000 appends:
    ~13 resizes (2^13 > 10,000)
    Total resize work: ~20,000 element copies
    Average per append: ~2 element copies
    → Still O(1) amortized

Why + is O(n):

  list1 + list2 creates a new list.
  → Allocates for len(list1) + len(list2)
  → Copies all items from list1
  → Copies all items from list2
  → O(n1 + n2) per call
  
  In a loop of n iterations:
    Iteration 1: + 1 item → copy 1 item
    Iteration 2: + 1 item → copy 2 items
    Iteration 3: + 1 item → copy 3 items
    ...
    Iteration n: + 1 item → copy n items
    Total: 1 + 2 + 3 + ... + n = n(n+1)/2 = O(n²)

  For n = 10,000: ~50,000,000 element copies.
  At ~1ns per copy: ~50ms.
  For n = 100,000: ~5,000,000,000 copies.
  ~5 seconds.
  
  The O(n²) becomes unusable at scale.

The fix is one character:

  ❌ result = result + [x]  → O(n) per iteration
  ✅ result += [x]          → O(1) per iteration
  ✅ result.append(x)       → O(1) per iteration

  The += is in-place; the + creates a new list.
  Same difference as list += vs list = list +.

Why comprehension is faster than append:

  [f(x) for x in data]
  
  → Compiles to a specialized bytecode
  → The compiler knows the result will be a list
  → Pre-allocates based on the iterable's length
    (if the iterable has __len__)
  → No method call overhead per append
  
  vs.
  
  result = []
  for x in data:
      result.append(f(x))
  
  → LOAD_METHOD, CALL_METHOD for each append
  → Dynamic type check on the result (is it a list?)
  → The list starts empty, grows via resizing
  
  For small n, the difference is microseconds.
  For large n, comprehension is ~2x faster.
  Both are O(n) — the difference is constants.

When to use what:

  For 90% of list-building:
    → Use a comprehension
    → Clearest, fastest, most idiomatic

  When the logic is complex:
    → Use append in a loop
    → Multiple conditions, side effects, breaks

  When accumulating collections:
    → Use extend
    → Flattening, merging, chunking

  Never:
    → result = result + [x] in a loop
    → This is the O(n²) bug that shows up at scale

The one case for +:

  Creating a new list from two lists:
    combined = list1 + list2
  
  ✅ Clear intent
  ✅ O(n) once
  ✅ Both inputs unchanged

  Even here, you could use:
    combined = list(list1)
    combined.extend(list2)
  
  Both work. The + is shorter. For one-time
  combination, use +. For building in a loop,
  use append/extend.

The rule:
  + for one-time concatenation.
  append/extend for accumulating in a loop.
  Comprehension for transforming data.
  Choose based on context, not on avoiding one.

Don't use extend on a string unless you want characters. list.extend('hello') adds 'h', 'e', 'l', 'l', 'o' — five items, not one. Same for any string argument. If you want to add a string as a single element, use append: list.append('hello'). The same trap applies to tuples, sets, and any iterable — extend iterates them. This is one of the most common bugs when someone means "add this string to the list" and accidentally uses extend. The fix is one word: use append.

⚠️ Common mistakes

The append/extend mistakes that cost real time.

1. result = list.append(x)

Returns None. Fix: list.append(x); result = list.

2. extend when you mean append

Adds each item individually. Fix: append for one item.

3. append when you mean extend

Adds the list as one nested element. Fix: extend for a collection.

4. extend('string')

Adds each character. Fix: append('string') for the whole string.

5. list = list + [x] in a loop

O(n²) — creates a new list each iteration. Fix: append or +=.

6. Chaining .append()

Returns None, so chaining fails. Fix: separate statements.

7. += on a tuple

Creates a new tuple (tuples aren't mutable). Fix: use a list for in-place ops.

8. extend on a dict

Iterates keys, not items. Fix: extend(d.items()) or append(d).

❓ Frequently asked

What is the difference between append and extend?

append adds its argument as a single element to the end of the list. If the argument is a list, that list becomes one element (nested). extend adds each element of its argument individually. Example: [1, 2].append([3, 4]) → [1, 2, [3, 4]]. [1, 2].extend([3, 4]) → [1, 2, 3, 4]. Rule: append for one item, extend for a collection.

What does += do on a list?

list += other is equivalent to list.extend(other) — it adds each element of the iterable to the end. The difference from extend is subtle: += requires the right side to be an iterable, while extend accepts any iterable too. Both mutate in place and return None. The common confusion: list += list is extend, not append.

Does append return a new list?

No — append, extend, and += all mutate the original list in place and return None. This is a common mistake: result = my_list.append(x) gives result = None, not the appended list. The fix is to call append separately: my_list.append(x); result = my_list. Or use list concatenation to create a new list: result = my_list + [x].

Can I use append to add multiple items at once?

Not directly — append takes exactly one argument. To add multiple items, use extend: my_list.extend([a, b, c]). Or use a loop: for x in [a, b, c]: my_list.append(x). Or use +=: my_list += [a, b, c]. The only case where append adds multiple items is when you append a list — but that adds the list as one nested element, not its items individually.

What happens if I use extend with a string?

extend iterates the string character by character. So [1, 2].extend('abc') gives [1, 2, 'a', 'b', 'c'] — three items added. To add the string as one element, use append: [1, 2].append('abc') gives [1, 2, 'abc'].

What about list + [x] vs append?

list + [x] creates a NEW list — it does not modify the original. append mutates in place. Use + when you want a new list (functional style). Use append when you want to modify the existing list. For a single append, either works. For a loop of appends, use append — the copy-on-each-iteration from + is O(n²).

🔗 Continue learning

🗺️ Python learning path
The full roadmap from install to first project — every concept in order

Next in this series:

✂️ Python slicing explained
a[start:stop:step] — indices point between elements, and that fixes 90% of off-by-one bugs
← All Python problems
Problem solved · Python