All articles

KajoData / python en

Python lists: indexing, slicing and shallow-copy traps

I trace list operations through concrete positions and results, including the difference between a new outer list and independent nested data.

Kajo RudzińskiPublished Updated 12 min read

A lifting frame selects three adjacent blocks from the middle of an ordered row.

A Python list keeps elements in a particular order. I can select one position, a consecutive group or every other element. This basic skill turns up when reading files, dividing work into batches and preparing small analytical examples. I would rather understand what a few brackets do than memorize increasingly elaborate combinations copied from unrelated answers online.

I will explain indexing and slicing using explicit synthetic data. The examples were checked with Python 3.14.4 and need no additional libraries. Each block defines its own input, so you can run blocks independently. The subject here is the ordinary built-in list. Similar syntax in pandas or NumPy can have different indexing and copying rules, so do not assume every object with square brackets behaves the same way.

One position: counting starts at zero

Imagine six file identifiers in their reading order. The first has index zero, the second one and the sixth five. An index describes a position in the current list. It is not a business identifier or the number stored in the element itself. Keep that distinction in mind when an element is a string such as "007" or a dictionary containing an id field.

# example: positions
items = ["A", "B", "C", "D", "E", "F"]
print(items[0], items[2], items[-1], items[-2])
print(len(items), items[len(items) - 1])

The results are A C F E and 6 F. Negative indices count from the end: -1 selects the last element and -2 the one before it. The older example described selecting the last element while its code still used [2]. That expression selects the third element regardless of the caption. Testing with distinct, recognizable values makes this kind of mismatch easy to spot.

The positive index of the last element is the length minus one. The length itself is already outside the list. An empty list has neither a first nor a last element. If empty input is allowed, decide whether to return a missing result, skip a step or report a violated assumption. Adding [-1] does not provide automatic empty-list handling.

A slice selects a range with an excluded stop

items[start:stop] creates a list from the starting position up to, but excluding, the stop position. With a positive step, items[1:4] selects indices one, two and three. I read that as “start at one and stop before four”. The boundary of one section can then be the beginning of the next, without repeatedly subtracting one when dividing the data.

# example: slices
items = ["A", "B", "C", "D", "E", "F"]
print(items[1:4])
print(items[:3], items[3:])
print(items[:3] + items[3:] == items)
print(items[2:3], items[2])

The first result is ['B', 'C', 'D']. The next two sections are ['A', 'B', 'C'] and ['D', 'E', 'F']; concatenating them reconstructs the input, so the comparison returns True. The final line shows a useful distinction: ['C'] is a one-element list, while C is a single string. Code expecting a list may need a slice even when only one position is selected.

With a positive step, an omitted start begins at the start of the list, and an omitted stop allows selection through its end. The colon is part of slice syntax. items[3] and items[3:] produce different results and serve different purposes. The sequence reference describes the formal rules. I suggest beginning with a short list of letters and writing down the selected positions by hand.

A step skips positions or changes direction

The complete form is start:stop:step. The default step is one. A step of two visits every other position starting at the chosen start. A negative step moves backwards. Its sign affects the interpretation of omitted bounds, which is why “an omitted start always means zero” is not a useful rule for reverse slices.

# example: steps
items = ["A", "B", "C", "D", "E", "F"]
print(items[::2], items[1::2])
print(items[::-1])
print(items[4:1:-1])
print(items[4:-1:-1], items[4::-1])

The first results are ['A', 'C', 'E'] and ['B', 'D', 'F']. The [::-1] slice produces ['F', 'E', 'D', 'C', 'B', 'A']. The [4:1:-1] slice visits positions four, three and two, producing ['E', 'D', 'C']. Position one remains excluded even though the direction is backwards.

The final line returns [] and ['E', 'D', 'C', 'B', 'A']. An explicit -1 means the last element’s position, rather than an imaginary position before the beginning. Starting at four and moving backwards cannot approach that stop in the required direction. When I want to continue through the beginning, I leave the stop empty. This is one reason I prefer a readable slice to a clever-looking cluster of negative numbers.

An empty slice can be a valid result

A single out-of-range index raises IndexError. Slicing is more forgiving: bounds are adjusted to the sequence length. Asking for the first hundred elements of a three-element list returns those three elements. It does not mean you received the requested hundred records. If that count is a requirement, validate the result’s length separately.

# example: boundaries
items = ["A", "B", "C"]
print(items[:100], items[100:], items[2:1])
print([][:3])
try:
    print(items[3])
except IndexError:
    print("index out of range")
try:
    print(items[::0])
except ValueError:
    print("zero step")

The results show the complete list, two empty slices, another empty list and the two described errors. A zero step is invalid. Equal bounds or a direction incompatible with the step can produce an empty list. Empty output may be legitimate or reveal a wrong assumption. I interpret it in the task’s context instead of automatically wrapping every slice in exception handling.

One particularly useful trap to remember is items[-n:]. For positive n, it selects the tail. For n=0, negative zero is simply zero, so it returns the whole list. If a function should return no elements when asked for the last zero elements, handle that case explicitly. Testing only a typical three-record example makes the mistake easy to miss.

A new list does not mean new nested objects

Slicing a built-in list creates a new list containing references to the same element objects. With strings and numbers, the consequences are often unobtrusive because those objects are not modified in place. With nested dictionaries and lists, the distinction becomes concrete. Understand it before assuming that a slice is an independent working copy of your data.

# example: shallow-copy
rows = [{"id": "A", "tags": ["raw"]}, {"id": "B", "tags": []}]
selected = rows[:1]
print(selected is rows, selected[0] is rows[0])
selected[0]["tags"].append("checked")
print(rows[0]["tags"])
selected.append({"id": "C", "tags": []})
print(len(rows), len(selected))

False True tells us the outer lists differ while their first dictionary is shared. Appending a tag is visible through rows too: ['raw', 'checked']. Appending another record to selected changes only that outer list. Both lists now contain two elements, but they contain different records. Matching lengths alone do not establish matching contents.

The copy documentation explains shallow and deep copying. deepcopy can fit a requirement for independent nested data, but I do not apply it automatically. Sometimes sharing is intentional; sometimes creating a new dictionary with just the fields being changed is enough. I first decide what effect the operation should have and which parts should remain connected.

Two separate trays contain compartments linked by a shared bridge, representing shared nested elements.

Slice assignment changes the existing list

On the left side of an assignment, a slice has a different role from reading. items[1:3] = [...] replaces that section of the existing list. For an ordinary continuous slice, the number of replacement elements can differ from the number removed. The list can shrink or grow, and every name referring to it sees the change.

# example: slice-assignment
items = ["A", "B", "C", "D"]
alias = items
items[1:3] = ["X"]
print(items, alias is items)
items[1:1] = ["Y", "Z"]
print(items)
del items[1:3]
print(items)

First you get ['A', 'X', 'D'] and True. Inserting into an empty slice produces ['A', 'Y', 'Z', 'X', 'D'], while deleting positions one and two returns the contents to ['A', 'X', 'D']. Insertion does not require constructing a new outer list first. That convenience also means the mutation may be visible in several parts of a program.

For assignment to an extended slice with a step other than one, the number of replacement values must match the number of selected positions. You cannot change length freely in that operation. If the purpose is merely to prepare a subset for display, reading a slice usually suffices. Mutating the input should be a clearly intended part of the function’s behavior.

Position selection does not replace a condition

Slicing selects positions. It does not examine which tasks are ready, which measurements exceed a threshold or which dates belong to a month. For selection based on contents, I use a condition, for example in a list comprehension. You can combine filtering with slicing, but changing their order changes the question answered by the result.

# example: filter-order
rows = [
    {"id": "A", "ready": False},
    {"id": "B", "ready": True},
    {"id": "C", "ready": True},
    {"id": "D", "ready": True},
]
first_two_ready = [row for row in rows if row["ready"]][:2]
ready_in_first_two = [row for row in rows[:2] if row["ready"]]
print([row["id"] for row in first_two_ready])
print([row["id"] for row in ready_in_first_two])

The first result is ['B', 'C'], and the second is ['B']. Filtering first asks for the first two ready tasks. Slicing first asks for ready tasks among the first two input records. Both results are correct for their respective questions. When reviewing code, I therefore read the complete sequence of operations instead of approving an isolated slice expression.

Likewise, “the last five” means the final five positions in the current order. If the list is not sorted by date, those are not necessarily the newest five events. File data may arrive in identifier order or write order. Establish ordering before selecting a section, including a tie-breaker when reproducibility matters for the report.

Check the final partial batch

Dividing identifiers into small batches is a practical use of slicing. I can visit starting positions with range and take one slice from each start. The final batch may be shorter. I do not insert imaginary values to make every group look equal, and I do not discard the remainder merely because it fails to fill a complete batch.

# example: batches
items = ["A", "B", "C", "D", "E", "F", "G"]
size = 3
if size <= 0:
    raise ValueError("size must be positive")
batches = [items[start:start + size] for start in range(0, len(items), size)]
print(batches)
print([item for batch in batches for item in batch] == items)

The output is [['A', 'B', 'C'], ['D', 'E', 'F'], ['G']] followed by True. Reconstructing the original list from all batches checks for omissions and ordering changes. Empty input produces an empty list of batches. If a consumer requires at least one group even for empty input, that is an additional requirement to state and implement separately.

This example stores every batch in memory. For a large stream, consider a generator or processing each batch directly in a loop. An ordinary list slice creates a new outer list; it is not a free view over arbitrarily large input. I cannot give one universal size threshold, because element sizes, available memory and subsequent operations all affect the practical limit.

Reversing and sorting answer different questions

[::-1] reverses the current order. It does not sort numbers from largest to smallest or text alphabetically. Reversing three, one, two gives two, one, three. Sorting descending gives three, two, one. Before either operation, establish the desired output order and whether the input order carries information worth preserving.

Lists also have a reverse method that changes the list in place and returns None. Assigning that return value to the list’s name loses access to the list through that name. The method performed its action but did not return a new collection. A slice returns a new list, while reversed returns an iterator. Choose the form that fits the next step.

# example: reverse-and-sort
values = [3, 1, 2]
print(values[::-1], sorted(values, reverse=True))
result = values.reverse()
print(values, result)
print(list(reversed(values)))

The results are [2, 1, 3] and [3, 2, 1], then [2, 1, 3] None, and finally [3, 1, 2]. Check method return values as well as visible side effects. In a notebook, mutating an input can change later cells’ behavior even when their code stays untouched. Restarting and running from the beginning helps expose these dependencies on previous state.

Decide whether the subset should be a snapshot

Imagine selecting the first three records of a synthetic task queue for display. Their statuses may change during later processing. A subset containing shared dictionaries will show those changes. If it should describe the state at selection time, you need a deliberately constructed snapshot. Either behavior may be appropriate, provided it follows the function’s stated contract.

For simple data, copying selected fields into new dictionaries may suffice and also limits the output to fields the consumer needs. But copying a dictionary does not independently copy a nested list of tags. I sketch the layers: name, outer list, record, tag list. At each layer, I decide whether sharing is required or independence is expected.

A shallow copy can therefore fit a live queue while being insufficient for a history record. Deep copying also needs thought, especially with external resources or objects defining their own copying behavior. Our examples use small lists, dictionaries and strings, so their limits are explicit. When moving to NumPy or pandas, check the actual object’s type and the operation’s contract, then run a mutation example. Familiar brackets alone cannot prove identical behavior.

How I check slicing code

I start with distinct elements and write the expected positions manually. Repeated values can conceal an incorrect index. Selecting the wrong one of three consecutive sevens still prints seven. Simple labels make a boundary shifted by one position visible, which is much more useful than a large realistic dataset during this first check.

Next, I try empty input, one element, a bound equal to the length and a bound beyond it. For a step, I test both directions and the prohibited zero. If the code modifies elements, I include a dictionary or nested list. That exposes sharing which a sample made only of numbers or strings would hide.

I also avoid removing elements from the same list during ordinary iteration unless there is a carefully controlled reason. Removing an element shifts later positions and can make the loop skip records. For filtering, constructing a new list is usually easier to understand. A measured memory limitation can justify a more complicated approach; compact syntax by itself does not.

Exercise: the last items without the zero trap

Write a function returning the last n elements under an explicit contract: n is a nonnegative integer, zero returns an empty list, and a value greater than the input length returns the whole input. The function must return a new list without modifying the input. Predict the result for six letters before running it.

# example: last-items
def last_items(items, n):
    if isinstance(n, bool) or not isinstance(n, int) or n < 0:
        raise ValueError("n must be a nonnegative integer")
    return items[-n:] if n else []

items = ["A", "B", "C", "D", "E", "F"]
print(last_items(items, 2), last_items(items, 0))
print(last_items(items, 20), last_items([], 2))

The results are ['E', 'F'], [], the complete input list and []. I also reject True, although Python’s Boolean type is a subclass of its integer type. This interface requires an actual count, so I make the decision explicit. Check -1, 1.5 and True separately, then verify that the original list is unchanged.

Once you can explain those results, continue with counting through dictionaries and Counter or apply slices while reading files. The useful skill is predicting which exact elements you will receive and what will change. An expression full of colons still needs a clear purpose and a checked result.

Key takeaways

Is the stop index included in a slice?

No. The stop boundary is excluded. With a positive step, items[1:4] selects indices 1, 2 and 3.

Why does items[-1] work while items[len(items)] fails?

For a nonempty list, -1 identifies the last element. Positive indices end at len(items)-1, so len(items) is outside the list.

Does items[:] also copy nested dictionaries?

It creates a new outer list with references to the same elements. Nested dictionaries remain shared; copying them independently requires a separate decision.

Kajo Rudziński

Newsletter KajoData

Let’s keep in touch.

I write about data analysis, learning and changing careers. My newsletter and the sign-up form are in Polish. If that works for you, I’d be glad to see you there.