Mastering Indexing and Slicing in Python | by Giorgos Myrianthous | Towards Data Science

In Python, the elements of ordered sequences like strings or lists can be -individually- accessed through their indices. This can be achieved by providing the numerical index of the element we wish to extract from the sequence. Additionally, Python supports slicing that is a characteristic that lets us extract a subset of the original sequence object.
In this article, we are going to explore how both indexing and slicing work, and how they can be used in order to write cleaner and more Pythonic code.
Like most programming languages, Python offsets start at position 0 and end at position N-1, where N is defined to be the total length of the sequence. For instance, the total length of the string Hello is equal to 5 and each individual character can be accessed through indices 0 to 4 as shown in the diagram below:
Now you can programmatically access individual characters in the string, by providing the corresponding offset you wish to fetch, enclosed in square brackets:
>>> my_string = 'Hello'>>> print(my_string[0])'H'>>> print(my_string[2])'l'>>> print(my_string[3])'l'
It is also important to know that when you attempt to access an offset which is greater than the length of the sequence (minus 1), Python will throw an IndexError informing you that the requested offset is out of range:
>>> my_string[5]Traceback (most recent call last):  File "<input>", line 1, in <module>IndexError: string index out of range
It is also possible to access an element by providing a negative index that essentially corresponds to the index starting from the right of the sequence. The last item can be accessed through offset -1, the last but one through offset -2 and so on
Technically when a negative offset is used, Python adds that offset to the length of the sequence in order to infer the exact position. For example, say we want to extract character e from the string my_string = 'Hello’ using a negative offset. Now the expression my_string[-4] will essentially be translated into my_string[len(my_string) — 4] which is equivalent to my_string[5 — 4] and my_string[1]that finally gives us the desired output:
>>> my_string[-4]'e'
Slicing is one form of indexing that allows us to infer an entire (sub)section of the original sequence rather than just a single item. To perform a slicing over a sequence in Python, you need to provide two offsets separated by a colon although in some cases you can define just one of the two, or even none (more on these cases are discussed below).
The first offset denotes the starting point and is inclusive while the second offset denotes the ending point but in contrast to the starting offset, it is non-inclusive.
my_string[start:end]
Therefore, when performing slicing Python will return a new object including all the elements starting from the lower index up to the position which is one less the upper index. As an example, consider a use-case where we need to take the first two elements of the string:
>>> my_string[0:2]'He'
As I already mentioned, it is not mandatory to provide explicit offsets. When the starting offset is omitted, then its value will default to 0. On the other hand, when the ending offset is not provided, its default value will be equal to the length of the sequence. There are actually three different scenarios which are shown below:
my_string[0:]   # Omit ending offsetmy_string[:-1]  # Omit starting offsetmy_string[:]    # Omit both starting and ending offsets
The first notation is typically useful when we want to chop off leading text. Say we want to get all but the first character of our string. We can do so by using the following notation
>>> my_string = 'Hello'>>> my_string[1:]'ello'
As we already mentioned, when the ending offset is omitted the length of the sequence will be used instead:
>>> my_string[1:] == my_string[1:len(my_string)]True
Let’s assume that we now want all but the first character of our string. In this case, omitting the starting offset will do the trick:
>>> my_string = 'Hello'>>> my_string[:-1]'Hell'
Given that the lower bound is skipped, its value will default to 0:
>>> my_string[:-1] == my_string[0:-1]True
Slicing notation in Python allows us to omit both the starting and ending offsets.
>>> my_string = 'Hello'>>> my_string[:] == my_string[0:len(my_string)]True
Given that when lower and upper bounds are ignored will default to 0 and len(sequence) respectively you might be wondering whether this could be of any help or use. Well, this is a quick way to take a copy of the object as shown below
>>> my_string = 'Hello'>>> my_string_copy = my_string[:]
Note that when this slicing technique will generated a different object that will be allocated to a different memory location. This doesn’t make any sort of difference for immutable object types like strings, however it is quite important to be aware of this when dealing with mutable object types such as lists. For more details on this, you can refer to my article Dynamic Typing in Python that discusses how objects are created and (properly) copied.
Slicing expressions in Python come with a third index which is optional and when specified is used as a step. Obviously, when the step value is omitted it defaults to 1 which means that no element in the requested sequence sub-section will be skipped. The notation is shown below
[start:end:step]
As an example, consider that we have a string with the letters of the alphabet from which we want to extract every other item in it, between letters in positions 1 and 19:
>>> import string>>> my_string = string.ascii_lowercase # 'abcdefg...'>>> my_string[1:20:2]'bdfhjlnprt'
Such notation can be used to replace list comprehensions. For instance, let’s say we want to get all the elements of a list that have an even index. A list comprehension that achieves this would be
>>> my_list = [100, 400, 34, 179, 0, 89, 121]>>> [value for index, value in enumerate(my_list) if index % 2 == 0][100, 34, 0, 121]
In this occasion, slicing notation could make our code simpler and much more readable:
>>> my_list = [100, 400, 34, 179, 0, 89, 121]>>> my_list[::2][100, 34, 0, 121]
Like start and end offsets, the step index can be a negative number. Technically this is useful when we want to reverse the order of the elements in an ordered sequence
>>> my_string = 'Hello'>>> my_string[::-1]'olleH'
In other words, when a negative step index is applied the effect of starting and ending offsets is reversed. To make this clear let’s jump into another example where we actually define all three possible offsets.
>>> import string>>> my_string = string.ascii_lowercase # 'abcdefg...'>>> my_string[20:10:-1]'utsrqponml'
In the example above, we essentially create a new string from indices 11 to 20 in reversed order.
In this article, we explored how indexing and slicing work in Python. Both of these notations are used extensively in most Python applications so you need to make sure you understand how they work. Below we revisit the key points we’ve covered — feel free to use it as a cheatsheet, too.
The first item starts at offset 0
The last item ends at offset len(my_sequence) — 1
Negative indices indicate that the count will start backwards. Essentially it is being added to the length of the sequence. For example, my_string[-1] translates to my_string[len(my_string) — 1]
The starting index (lower bound) is inclusive
The ending index (upper bound) is non-inclusive
When the starting index it omitted, it defaults to 0
When the ending index is omitted, it defaults to the length of the sequence
When both starting and ending indices are omitted, a copy of the original object is created — my_string[:]
The third index denotes the step
When the step index is omitted, it defaults to 1 (i.e. no element is skipped)
A negative step index helps us create reversed sequences (e.g. my_string[::-1] )