Kestrel Exams kestrel logo KESTREL EXAMSPRACTICE TO PASS
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.

Practice this topic in the free app →All Python guides

A word printed on a card#

Picture a word printed on a small card: hello. Not written in pencil. Printed.

You can read the whole word. You can read the third letter. You can hold two cards side by side and read them as one. You can go to the print shop, hand them the card, and ask for a new card that says HELLO.

What you cannot do is rub out the h and write a J. The card is printed. If you want a different word you get a different card, and the old one is still exactly as it was.

Every surprising thing about Python strings comes from that. The print shop always hands you a new card, so if you do not take the new one, nothing has changed. That is the bug at the top of this page, and it is the most common wasted line in a first Python course.

The line that did nothing#

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

What prints? Commit to it.

Almost everyone says HELLO. We called .upper(), so surely s is now uppercase.

hello

The middle line ran, did its work, and threw the answer away. Strings in Python are immutable — nothing can change one after it is made. So .upper() cannot modify s. It builds a new string and hands it back, and we did not catch it.

s = s.upper() is the fix. Every string method works this way, which is why a string method on a line by itself is always a bug.

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.

A slice is mostly punctuation, and punctuation is hard to hold in your head until you can say it. These are the four you will meet:

Written asSay it out loud as
fruit[0:3]“fruit, from 0, up to but not including 3”
fruit[:3]“fruit, from the beginning, up to but not including 3”
fruit[3:]“fruit, from 3, all the way to the end”
fruit[::-1]“fruit, the whole thing, backwards”

Say “up to but not including” every single time and the off-by-one error stops happening.

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() capitalized 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 memorizing#

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 — whitespace meaning spaces, tabs and the invisible newline at the end of typed input
.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 instead of 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

“Concatenate” is the word in that error message, and it just means join end to end. Python will join a string to a string. It will not join a string to a number, because it has no way to guess whether you wanted "I am 20" or arithmetic. Say which you meant:

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, which is just an instruction about how to print the value. It changes how the number looks, never what the number is.

Written asSay it out loud as
f"{pi}"“print pi however Python likes”
f"{pi:.4f}"“print pi as a fixed-point number with four digits after the point”
f"{n:b}"“print n in binary”
f"{n:X}"“print n in uppercase hexadecimal”

The f in .4f stands for fixed-point, and it is the character that money depends on. The same specifications 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.
One sentence to carry into the exam

Every string method hands you back a new string. If you did not catch it, nothing happened. A string method sitting alone on its own line is always a bug.

Test yourself in the free Kestrel Exams app

30 practice questions on indexing, slicing, string methods and f-strings, every code answer checked by running it. Free, offline, no account.

Practise this topic →

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.

Suggest a change

Something here not clear? A topic you wish we covered? Tell us. We read every message, and a request is the fastest way to get a guide written — several of these exist because somebody asked.