Python strings support slicing — extracting a substring using index ranges — with a flexible [start:stop:step] syntax that also works on lists and tuples.
text = "Hello, World!"
print(text[0]) # "H" — single character (0-indexed)
print(text[-1]) # "!" — negative indices count from the end
print(text[0:5]) # "Hello" — from index 0 up to (not including) 5
print(text[7:]) # "World!" — from index 7 to the end
print(text[:5]) # "Hello" — from the start up to index 5
print(text[::-1]) # "!dlroW ,olleH" — step -1 reverses the string
print(text[::2]) # "Hlo ol!" — every second character
# text[0] = "J" # ❌ TypeError — strings are immutable, can't assign to an index
text = "J" + text[1:] # ✅ build a new string instead
Slicing's stop index is always exclusive — text[0:5] grabs indices 0 through 4, not including index 5, which trips up nearly every beginner at least once. Omitting start or stop defaults to "the beginning" or "the end" respectively, and a negative step walks backward, which is how text[::-1] becomes the idiomatic one-liner for reversing a string.
Strings are immutable — once created, a string object's contents can never be changed in place. Every operation that appears to "modify" a string (.upper(), .replace(), slicing, concatenation) actually creates and returns a brand-new string object, leaving the original unchanged; attempting text[0] = "J" raises a TypeError rather than silently doing nothing. This immutability is also why strings can be used as dictionary keys and set elements, since those require values that never change after being added.