String indexing and slicing are the two operations you will perform on text more often than anything else, and both have rules that are worth learning once rather than rediscovering under pressure. Positions start at zero, negative positions count from the end, the stop value of a slice is always excluded, and slices that fall outside the string are quietly clipped instead of raising an error. There is also one Unicode surprise at the end that breaks more production code than any of the others.
How does string indexing work?
A string behaves like a sequence of characters, and each character has a position. The first is 0, the second is 1, and so on. Negative positions count backwards from the end, so -1 is the final character and -2 the one before it. There is no separate "last element" property, because text[-1] is the idiomatic way to say it.
word = 'Tutorsbot'
print('word ->', word)
print('word[0] ->', word[0])
print('word[1] ->', word[1])
print('word[-1] ->', word[-1])
print('word[-2] ->', word[-2])
print('len(word) ->', len(word))
try:
print(word[99])
except IndexError as error:
print('word[99] ->', error)
print('ord and chr ->', ord('A'), chr(65))
Indexing past the end raises IndexError, which is Python refusing to invent a character. The ord() and chr() pair converts between a character and the integer code point that represents it, which is occasionally the only way to inspect a character that is misbehaving.
What does a slice actually do?
Slicing uses square brackets and up to three colon-separated parts: text[start:stop:step]. The start is included, the stop is excluded, and the step controls the direction and the spacing. Anything you leave out takes a sensible default: the start defaults to the beginning, the stop to the end, and the step to 1.
word = 'Tutorsbot'
print('word[0:5] ->', word[0:5])
print('word[5:] ->', word[5:])
print('word[:3] ->', word[:3])
print('word[::2] ->', word[::2])
print('word[::-1] ->', word[::-1])
print('word[-3:] ->', word[-3:])
print('word[3:3] ->', repr(word[3:3]))
print('word[:100] ->', word[:100])
Two behaviours stand out. An empty slice gives you an empty string rather than an error, so word[3:3] is ''. And a slice whose bounds run past the end of the string is clipped to whatever exists, so word[:100] is the whole string. Slicing is forgiving in a way that indexing is not, which is why text[start:start + length] never raises even when the length is a guess.
The three-part form earns its keep in two situations. A step of 2 takes every other character, and a step of -1 reverses the string, which is the standard way to write text[::-1].
A word about cost. A slice copies the characters it selects, so slicing a very long string is not free, and slicing it in a loop copies it many times over. If you need one character, index it. If you need a chunk, slice it once and keep the result rather than recomputing the slice inside the loop.
The classic off-by-one that trips people up is pulling a file extension off the end. name.rfind('.') finds the last dot, and the extension is everything after it, but only when the dot exists and is not the first character. Getting that right takes two checks rather than one, which is why the pathlib module exists.
Why can you not assign to a string position?
Because strings are immutable: once a string exists, its characters cannot be changed. Every slice produces a brand new string, and trying to write into an existing one is an error.
original = 'hello'
piece = original[1:4]
print('original ->', original)
print('piece ->', piece)
print('same object? ->', piece is original)
try:
original[0] = 'H'
except TypeError as error:
print('assignment ->', error)
This is not a limitation so much as a guarantee. Because no string can change underneath you, a string can safely be shared between functions, cached in a dictionary, or used as a key. The cost is that "editing" a string means creating a new one, which is why the idiomatic approach is to build a new value and rebind the name. The next-but-one lesson looks at why the language is built this way.
How do you handle Unicode and emoji correctly?
A Python string is a sequence of Unicode code points, not a sequence of what a human sees as characters. For plain text the difference never shows up. For emoji it is dramatic, because a single visible character can be several code points joined together.
# Built from code points so the source stays plain ASCII.
family = '\U0001F468\u200D\U0001F469\u200D\U0001F467'
print('family emoji length ->', len(family))
print('code points ->', [hex(ord(character)) for character in family])
flag = '\U0001F1EC\U0001F1E7'
print('flag length ->', len(flag))
accented = 'café'
print('accented chars ->', len(accented))
print('accented bytes ->', len(accented.encode('utf-8')))
thumbs = '\U0001F44D\U0001F3FD'
print('thumbs up length ->', len(thumbs))
print('first code point ->', hex(ord(thumbs[0])))
The family emoji is five code points long, the flag is two, and a thumbs-up with a skin tone modifier is two. Slicing one of those strings can cut a visible character in half, which is how a "user's name" ends up with a stray half emoji in it.
For plain-language work, indexing and slicing are exactly right. When you are handling user-typed names, titles or reactions where the input may contain emoji or combining characters, count grapheme clusters instead, and reach for a library that does the hard work rather than trying to detect them yourself.
Your turn: build a slicing toolkit
- Write
head(text, size)to return the firstsizecharacters, andtail(text, size)to return the last ones. Handlesizeof zero without raising an error. - Write
reverse(text)andevery_other(text)using only slicing. Check thatreverse('abc')gives'cba'andevery_other('abcdef')gives'ace'. - Write
middle(text)that returns the text with the first and last characters removed, and confirm it returns an empty string for a one-character input rather than an error. - Add
safe_char(text, index)that returns the character at a position, or a fallback string when the position is out of range. Call it with a positive index, a negative index and a wildly out-of-range index. - Finally, write
truncate(text, limit)that shortens text to a limit and appends an ellipsis when it had to cut, without ever splitting a multi-code-point emoji in half. Test it with a name containing an emoji.
Slicing cheat sheet
- Positions start at zero and negative positions count back from the end.
- Indexing out of range raises
IndexError; slicing out of range is clipped. - A slice is
text[start:stop:step]with an inclusive start and an exclusive stop. - Omitted parts default to the beginning, the end and a step of 1.
text[::-1]reverses a string andtext[::2]takes every other character.- Slices are new strings, characters cannot be assigned, and
len()counts code points rather than visible characters.