Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Python Append to List: append, extend, and the Default-Argument Trap

Sean

Platform Writer

Jul 16, 2026
7 min read

items.append(x) adds a single element to the end of a list and returns None, modifying the list in place. That is the whole method, and it is one of the few Python operations that does mutate rather than return a new object - which is the opposite of how strings behave and the source of the first mistake everyone makes: writing items = items.append(x) and ending up with None. The second mistake is reaching for append when you meant extend, and the third - the one that ships to production and survives code review - is a mutable default argument that quietly shares one list across every call.

Python Append to List: append, extend, and the Default-Argument Trap

Adding to a list is about as basic as Python gets. It is also attached to the single most famous gotcha in the language, and the gotcha has nothing to do with lists being hard.

Table of contents

append, extend, insert, and +=

items = [1, 2, 3]

items.append(4)        # [1, 2, 3, 4]        - one element
items.append([5, 6])   # [1, 2, 3, 4, [5, 6]] - a nested list!

items = [1, 2, 3]
items.extend([4, 5])   # [1, 2, 3, 4, 5]     - each element separately

items.insert(0, 0)     # [0, 1, 2, 3, 4, 5]  - at an index. O(n).

items += [6, 7]        # same as extend
items = items + [8]    # different: builds a NEW list

The distinction that trips people:

  • append(x) - adds x as one element, whatever x is. Appending a list gives you a nested list.
  • extend(iterable) - adds each element of the iterable. Extending with a string adds each character, which is almost never what you meant.
  • insert(i, x) - adds at position i. It shifts everything after it, so it is O(n), not O(1).
  • += - equivalent to extend for lists. Mutates in place.
  • + - builds a new list. Does not mutate.

The extend with a string case is worth seeing once so you never do it twice:

items = []
items.extend("abc")
# ['a', 'b', 'c']   - a string is an iterable of characters

append returns None, and that is deliberate

items = [1, 2]
items = items.append(3)
print(items)
# None

This catches everyone once. append mutates the list and returns None, so assigning the result throws the list away and leaves you with None. The next line that touches items fails with an incomprehensible AttributeError: 'NoneType' object has no attribute ..., several lines from the actual mistake.

This is a deliberate Python convention: methods that mutate in place return None, so you cannot accidentally chain them and be confused about whether you have the original or a copy. list.sort() returns None; sorted() returns a new list. list.reverse() returns None; reversed() returns an iterator.

The rule that resolves it: if the method changes the object, it returns None. If it returns something, it did not change the original. Strings are the mirror image - they are immutable, so every string method returns a new string and none of them mutate.

items.append(3)   # correct - no assignment
print(items)      # [1, 2, 3]

The mutable default argument

The most famous gotcha in Python, and it is worth understanding rather than memorising.

def add_item(item, basket=[]):
    basket.append(item)
    return basket

add_item("apple")   # ['apple']
add_item("pear")    # ['apple', 'pear']   <- the same list!
add_item("plum")    # ['apple', 'pear', 'plum']

The default value is evaluated once, when the function is defined - not on each call. So there is exactly one list, created at import time, shared by every call that does not pass its own. Each call appends to the same object.

In a web service this is genuinely dangerous. A default list that accumulates across requests is a memory leak, and if it holds user data it is a data leak between requests. It looks like a per-call variable and behaves like a global.

The fix is the standard idiom:

def add_item(item, basket=None):
    if basket is None:
        basket = []
    basket.append(item)
    return basket

None is immutable, so there is nothing to share. A fresh list is created per call. Use this pattern for every mutable default - lists, dicts, sets. Immutable defaults like numbers, strings, and tuples are safe because nothing can mutate them.

Performance, briefly and honestly

append is amortised O(1). Python over-allocates the underlying array, so most appends are a pointer write and the occasional resize is spread across many operations. Building a list of a million items with append in a loop is fine and nobody should feel bad about it.

The ones that are not fine:

  • insert(0, x) - O(n), because every element shifts. In a loop that is O(n squared). If you are prepending, use collections.deque, which is O(1) at both ends.
  • list = list + [x] in a loop - builds a whole new list each iteration. O(n squared). Use append.
  • Repeated extend with a generator - fine, actually. This one is not a problem.

And a comprehension usually beats an append loop for both speed and readability:

# Fine.
squares = []
for n in numbers:
    squares.append(n * n)

# Better.
squares = [n * n for n in numbers]

The comprehension is faster because it avoids a method lookup and call per iteration. But the real argument is that it says what it means in one line.

Appending in a loop is where memory goes

One production note. A list you append to without bound is a list that grows without bound, and lists hold references, so nothing they contain is freed either.

# This accumulates every row in memory before writing anything.
results = []
for row in huge_query():
    results.append(transform(row))
write(results)

On a dataset that fits, fine. On one that does not, this is an OOM kill - the process disappears mid-run and the logs end mid-sentence. The fix is usually to stream rather than accumulate:

# Constant memory.
for row in huge_query():
    write_one(transform(row))

# Or a generator, if the consumer wants an iterable.
def transformed(rows):
    for row in rows:
        yield transform(row)

The instinct to build a list and return it is strong, and it is the right default for small collections. It is the wrong default for anything whose size is determined by your data rather than your code.

How this fits the rest of the stack

A list that grows for the life of a process is the most common way a Python service quietly outgrows its instance, and the symptom is an OOM restart rather than an error you can read. Sizing memory is a guess until you have watched it under real load. The RunxBuild hosting calculator puts the compute, memory, database, and bandwidth line items together so the size of the box is something you chose deliberately, and the RunxBuild dashboard shows what the process actually consumes once it is serving traffic.

Useful related references:

FAQ

What is the difference between append and extend in Python?

append(x) adds x as a single element, so appending a list produces a nested list. extend(iterable) adds each element of the iterable individually. The classic surprise is extend("abc"), which adds three separate characters because a string is an iterable of characters. Use append for one item, extend for many.

Why does append return None in Python?

Because it mutates the list in place, and Python’s convention is that in-place methods return None so you cannot mistake them for methods that return a new object. items = items.append(x) therefore sets items to None. Just call items.append(x) without assigning. The same convention applies to list.sort() and list.reverse().

Why is my Python default list shared between function calls?

Because default arguments are evaluated once when the function is defined, not on each call - so def f(x, items=[]) creates exactly one list at import time that every call shares. Use items=None and create the list inside the function body. In a web service, a shared mutable default is both a memory leak and a potential data leak between requests.

Is append or insert faster in Python?

append is amortised O(1) and insert(0, x) is O(n), because inserting at the front shifts every existing element. Prepending in a loop with insert is O(n squared) and will visibly hang on large lists. If you need efficient insertion at the front, use collections.deque, which is O(1) at both ends.

Should I use a list comprehension instead of append in a loop?

Usually yes, when you are building a list from an iterable. A comprehension is faster - it avoids the per-iteration method lookup and call - and it states the intent in one line. Keep the explicit append loop when the body has real logic, multiple statements, or side effects, because forcing that into a comprehension hurts readability for no gain.

#python append to list#python#lists#data structures#dev-infra