PYTHON / STRINGS
Indexing and slicing strings
Address any character or substring in a Python string with index and slice expressions, including negative indices and steps.
What you will learn
- Read s[a:b] as boundary a to boundary b, so it holds exactly b - a characters
- Use negative indices to count from the right, where -1 is the last character
- Omit start or stop for 'from the beginning' and 'to the end' without len() math
- Know that slices clamp out-of-range bounds while plain indexing raises IndexError
Understanding Indexing and slicing strings
A Python string is an ordered sequence of characters, and every position in it has two names: a count from the left starting at 0, and a count from the right starting at -1. So in "Portugal", the letter P is both s[0] and s[-8], and l is both s[7] and s[-1]. Indexing with s[i] hands back a one-character string, because Python has no separate character type; s[0] is a str of length 1, which is why s[0].upper() works straight away.
Slicing uses s[start:stop] and it helps to stop thinking of the numbers as characters and think of them as the boundaries between characters. Boundary 0 is before the first letter, boundary 4 sits between t and u, so s[0:4] scoops up whatever lies between them: "Port". That single mental shift explains the two rules people memorise separately: the stop index is excluded, and the length of the result is stop - start. It also explains why s[:4] + s[4:] rebuilds the original string exactly, with no character lost or repeated.
A third value gives a step: s[::2] walks forward two boundaries at a time, and a negative step walks backwards, which is why s[::-1] is the idiomatic reversal. Slicing is forgiving about bounds because a slice can legitimately describe an empty range, so s[6:99] just stops at the end of the string and s[7:2] is simply "". Indexing has no such freedom, since s[99] must produce one specific character and there is none, so it raises IndexError. Every slice builds a brand new string object rather than a view into the old one, so slicing a huge string in a loop copies data each time.
s = "Portugal"
print(s[0], s[3], s[-1])
print(s[0:4])
print(s[:4], s[4:])
print(s[::2])
print(s[::-1])
print(len(s[2:5]), 5 - 2)
print(s[6:99])
try:
s[99]
except IndexError as e:
print("IndexError:", e)Slice numbers name the boundaries between characters, which is why the stop index is excluded and a slice holds stop - start characters.
Worked examples
Trimming from the right
Negative bounds let you strip a known suffix without computing any lengths.
name = "report_2024.csv"
print(name[-4:])
print(name[:-4])
print(name[-1], name[len(name) - 1])
print(repr(name[:-0]))Example explained
Line 1name[-4:] starts four boundaries from the right and runs to the end, giving the extension.
Line 2name[:-4] stops four boundaries from the right, so it is everything the previous slice left out.
Line 3name[-1] and name[len(name) - 1] address the same position; the negative form avoids the arithmetic.
Line 4name[:-0] is a trap: -0 is just 0, so this is name[:0] and yields the empty string.
Steps and empty results
Shows how the step controls direction and how mismatched bounds produce an empty string instead of an error.
digits = "0123456789"
print(digits[2:8:3])
print(digits[8:2:-1])
print(digits[-3:])
print(repr(digits[3:3]), repr(digits[7:2]))Example explained
Line 1digits[2:8:3] visits positions 2 and 5, then would land on 8, which is the excluded stop.
Line 2digits[8:2:-1] walks down from 8 to 3; the stop at 2 is excluded here too, so "2" is missing.
Line 3digits[3:3] has start equal to stop, so the range between the boundaries is empty.
Line 4digits[7:2] asks to move forward from 7 to 2, which is impossible with the default step of 1, so the result is empty rather than reversed.
Reading a fixed-width record
Parses a record whose fields occupy known column ranges using nothing but slices.
row = "AL3500220240115"
code = row[0:2]
amount = row[2:7]
date = row[7:]
print(code, amount, date)
print(f"{date[:4]}-{date[4:6]}-{date[6:]}")Example explained
Line 1row[0:2] takes two characters because 2 - 0 is 2; the field widths are the slice lengths.
Line 2row[2:7] begins exactly where the previous slice stopped, so no character is skipped or shared.
Line 3row[7:] omits the stop, so it absorbs the rest of the record however long it is.
Line 4date[4:6] pulls the month out of the already-sliced substring, since a slice is an ordinary string you can slice again.
Iterating positions instead of characters
Compares direct iteration with index-based access when you need neighbouring characters.
word = "letter"
for i in range(1, len(word)):
if word[i] == word[i - 1]:
print(i, word[i - 1:i + 1])
print(word[1:] == "etter", word[:len(word)] is word)Example explained
Line 1range(1, len(word)) keeps i - 1 valid, which is why the loop starts at 1 rather than 0.
Line 2word[i - 1:i + 1] spans two boundaries around the match, so it is a two-character slice.
Line 3word[1:] compares equal to a literal because a slice produces a normal string value.
Line 4word[:len(word)] is word is True in CPython because a full slice of a str can return the same object; do not rely on it, compare with == instead.
Important notes
Indexing counts code points, not visual characters, so a string containing an emoji with a modifier or a combining accent can take more than one index per glyph.
Each slice copies characters into a new string, so repeatedly slicing a large string inside a loop costs time proportional to the slice sizes.
Common mistakes
Treating the stop index as inclusive, so s[0:4] and s[4:8] are believed to overlap on one character; the resulting off-by-one either drops or duplicates a letter when you split a string in two.
Writing s[:-0] or s[-0] to mean 'up to the end' or 'the last character'; -0 equals 0, so you silently get the empty string or the first character.
Expecting s[5:1] to reverse a section; with the default step of 1 the range is empty, and the bug is silent because slicing never raises for backwards bounds.
Try it yourself
Change, predict, then run
Start from stamp = "2026-09-01T06:13" and use only indexing and slicing to print the year, the day, the time part after the T, and the whole stamp reversed.
Open the Python workspaceCheck your understanding
t is a 5-character string. What happens when you evaluate t[2:99] and then t[99]?
- t[2:99] returns t[2:] with no error, while t[99] raises IndexError
- Both raise IndexError, because 99 is past the end of the string
- t[2:99] raises IndexError, while t[99] returns the empty string
- Both return the empty string, since there is nothing at position 99
Show answer
A slice describes a range of positions and an empty or short range is a valid answer, so Python clamps 99 down to the end of the string. Indexing has to produce exactly one character, and no character exists at position 99, so it fails. The 'both raise IndexError' option is tempting because the number 99 is out of range in both expressions, but only indexing requires that a character actually be there.