Python9 min readCode verified

Strings: Slicing, Immutability, and the Method That Did Nothing

A string method ran, worked perfectly, produced the right answer — and threw it away. Once you see why, half the string bugs in a first course explain themselves. Every result on this page was executed, not remembered.

Indexing, forwards and backwards#

A string is a sequence of characters, and [] reaches one of them. Counting starts at zero:

s = "Python rocks"
print(s[0])
print(s[7])
print(s[-1])
print(len(s))
P
r
s
12

A negative index counts from the end — s[-1] is the last character, s[-2] the one before it. It is shorthand for s[len(s) - 1], and it saves you from writing that every time.

There is no character type in Python. s[0] is a string of length one, which is why you can compare it to "P" and why len(s[0]) is 1.

Go past the end and Python stops you:

s = "apple"
print(s[5])
Traceback (most recent call last):
  File "idx.py", line 2, in <module>
    print(s[5])
          ~^^^
IndexError: string index out of range

"apple" has five characters at indexes 0 through 4. The last valid index is always len(s) - 1.

Slicing#

fruit = "banana"
print(fruit[0:3])
print(fruit[:3])
print(fruit[3:])
print(fruit[::-1])
ban
ban
ana
ananab

[start:stop] takes from start up to but not including stop — the same exclusive-stop rule as range. Leave out the start and it means "from the beginning"; leave out the stop and it means "to the end". A step of -1 walks backwards, which is the shortest way to reverse a string.

Because the halves meet exactly, fruit[:3] + fruit[3:] rebuilds the original with nothing missing and nothing doubled.

Strings cannot be changed#

greeting = "Hello"
greeting[0] = "J"
Traceback (most recent call last):
  File "imm.py", line 2, in <module>
    greeting[0] = "J"
    ~~~~~~~~^^^
TypeError: 'str' object does not support item assignment

Strings are immutable. You can read any character; you cannot overwrite one. Build a new string instead, using a slice for the part you are keeping:

greeting = "Hello"
greeting = "J" + greeting[1:]
print(greeting)
Jello

Nothing was modified there either — a new string was made and the name greeting was pointed at it. That distinction is worked through in full in Why changing one list changed the other, where it decides whether an alias sees your change.

The method that did nothing#

Immutability has one consequence that catches nearly everyone:

s = "hello"
s.upper()
print(s)
hello

s.upper() ran. It computed "HELLO". Then the line ended, nothing was holding the result, and it was discarded. A string method can never modify the string it was called on — there is no such operation. It can only return a new one.

So the result has to be caught:

s = "  Hello World  "
print(s.strip())
print(s.upper())
print(s.strip().replace("World", "Python"))
Hello World
  HELLO WORLD  
Hello Python

Look at the middle line: .upper() capitalised the letters and left the surrounding spaces exactly where they were, because that is all it was asked to do. The third line chains — .strip() hands its new string straight to .replace(), which returns another new one.

The mirror image of a list bug

String methods return and never mutate, so forgetting to assign loses the work. List methods like .sort() mutate and return None, so assigning throws the list away. The two most common one-line mistakes in a first course are exact opposites of each other.

The methods worth memorising#

MethodReturns
.upper() .lower()a new string with the case changed — the standard way to make Y/N input reliable
.strip()a new string with leading and trailing whitespace removed
.replace(old, new)a new string with every occurrence swapped
.count(sub)how many times sub appears
.find(sub)the index of the first occurrence, or −1 if absent
.split(sep)a list of pieces — splits on whitespace when given no argument
sep.join(list)a string built by gluing a list together with sep between items
.isalpha() .isdigit() .isalnum()True or False — useful for validating input
s = "banana"
print(s.count("a"))
print(s.find("na"))
print(s.find("z"))
3
2
-1

.find() returning −1 rather than raising is worth remembering, because −1 is also a valid index. if s.find("z"): is true when the character is absent, which is the opposite of what you meant. Test if s.find("z") != -1:, or just use if "z" in s:.

print("abc".isalpha(), "abc1".isalpha())
print("123".isdigit(), "12.3".isdigit())
print("abc1".isalnum())
True False
True False
True

"12.3".isdigit() is False — the dot is not a digit. It is a check for a whole number, not for anything numeric.

song = "The rain in Spain"
print(song.split())
print("a,b,,c".split(","))
print("-".join(["a", "b", "c"]))
['The', 'rain', 'in', 'Spain']
['a', 'b', '', 'c']
a-b-c

Splitting on a specific separator keeps empty pieces — two commas in a row produce an empty string between them. Splitting on nothing collapses runs of whitespace instead.

in, and comparing strings#

print("p" in "apple")
print("ppl" in "apple")
print("z" not in "apple")
True
True
True

in tests for a substring of any length, not just a single character.

print("apple" < "banana")
print("Apple" < "apple")
print("Zebra" < "apple")
True
True
True

Comparison is dictionary order, with one twist: every capital letter sorts before every lowercase letter, so "Zebra" comes before "apple". If case should not matter, compare .lower() on both sides.

Walking through a string#

word = "apple"
for ch in word:
    print(ch, end="-")
print()
for idx in range(len(word)):
    print(idx, word[idx])
a-p-p-l-e-
0 a
1 p
2 p
3 l
4 e

The first loop goes by item and hands you each character. The second goes by index and hands you each position, which you then use to look the character up. Use the first unless you actually need the number.

The accumulator pattern works on strings the same way it works on numbers — start empty, add as you go:

word = "programming"
vowels = ""
for ch in word:
    if ch in "aeiou":
        vowels = vowels + ch
print(vowels)
print(len(vowels))
oai
3

.format() and f-strings#

Gluing strings and numbers together with + does not work:

age = 20
print("I am " + age)
Traceback (most recent call last):
  File "cat.py", line 2, in <module>
    print("I am " + age)
          ~~~~~~~~^~~~~
TypeError: can only concatenate str (not "int") to str
age = 20
print("I am " + str(age))
print(f"I am {age}")
I am 20
I am 20

.format() fills numbered or empty braces from its arguments:

print("Hello, {}!".format("Sam"))
print("{} is {} years old".format("Sam", 20))
print("{1} then {0}".format("second", "first"))
Hello, Sam!
Sam is 20 years old
first then second

An f-string does the same job with the values written where they appear, which is almost always easier to read:

name = "Sam"
age = 20
print(f"{name} is {age}")
pi = 22 / 7
print(f"{pi:.4f}")
n = 14
print(f"{n:b} {n:X}")
Sam is 20
3.1429
1110 E

After the colon comes a format specification: .4f means four decimal places, b binary, X uppercase hexadecimal. The same specifiers work in .format():

orig = 2.50
disc = 7
new = (1 - disc / 100) * orig
print("${:.2f} discounted by {}% is ${:.2f}".format(orig, disc, new))
$2.50 discounted by 7% is $2.32

Two things go wrong here often enough to name. First, dropping the f:

x = 1234.5678
print(f"{x:.2f}")
print(f"{x:.2}")
1234.57
1.2e+03

.2f is two decimal places. A bare .2 is two significant digits in general format, which turns a four-figure number into scientific notation. For money, always .2f.

Second, forgetting the f that makes it an f-string at all:

name = "Sam"
print("{name} is here")
{name} is here

No error, no substitution — just the braces, printed literally. If your output contains curly braces, this is why.

Where these go wrong#

  • Calling a string method without assigning it. s.upper() on its own line does nothing you can observe.
  • Trying to change a character in place. TypeError. Build a new string with slices.
  • Off-by-one on a slice. The stop is excluded, exactly like range.
  • Using the result of .find() as a truth value. It returns −1 when absent, which is truthy. Compare to −1, or use in.
  • "12.3".isdigit(). False — the dot disqualifies it.
  • Concatenating a number. "I am " + age raises. Use str() or an f-string.
  • {x:.2} for money. Two significant digits, not two decimals.
  • Forgetting the leading f. The braces print as text and nothing warns you.
  • Comparing mixed-case strings. Every capital sorts before every lowercase; normalise with .lower() first.

More Python guides

Free study guides for a first Python course — every listing executed before publication.

See the Python course →

Frequently asked questions#

Why does s.upper() not change my string?

Because strings are immutable, so every string method returns a NEW string and leaves the original alone. Calling s.upper() on its own line computes the answer and discards it. Assign the result: s = s.upper().

What does a negative index mean in Python?

It counts from the end. s[-1] is the last character, s[-2] the second to last. It is shorthand for s[len(s) - 1], and it works on lists too.

Why does fruit[0:3] give only three characters?

Because the stop value of a slice is excluded, exactly like range. fruit[0:3] takes indexes 0, 1 and 2. To include index 3 you would write fruit[0:4].

Why can't I change one character of a string?

Strings are immutable. greeting[0] = 'J' raises TypeError: 'str' object does not support item assignment. Build a new string instead: greeting = 'J' + greeting[1:].

What is the difference between .2 and .2f in a format specifier?

The f means fixed-point, so .2f gives exactly two digits after the decimal point. A bare .2 means two significant digits in general format, which turns 1234.5678 into 1.2e+03. For money you always want .2f.

Why does 'I am ' + age fail?

Because + between a string and a number is not defined: TypeError: can only concatenate str (not "int") to str. Convert with str(age), or use an f-string, which converts automatically.