Most programs spend more time handling text than doing math. User names, addresses, log files, web pages, configuration files — everything arrives as strings. Python gives you powerful tools to inspect, slice, clean, and format that text. But strings have a hidden rule that trips up every beginner: they cannot be changed once created. You can build new strings from old ones, but you can never edit a character in place. Understanding that rule — and the library of methods that work around it — is the difference between code that looks right and code that actually works.
In this lesson you will learn to pull out substrings with indexing and slicing, transform text with methods, and compose values into readable messages with f-strings. These are the skills you will use in every Python program you write.
flowchart LR A["word[0]"] --> B["'P'"] C["word[-1]"] --> D["'n'"] E["word[0:3]"] --> F["'Pyt'"]
Every character in a string sits at an index, starting from zero. word[0] is the first character, word[1] the second, and so on. But the real power is slicing: word[start:stop] grabs a range of characters, from start up to — but not including — stop. That off-by-one behavior is deliberate and consistent across Python, but it takes practice to stop guessing. If you omit start, the slice begins at the beginning; omit stop, and it runs to the end. Both can be negative, so word[:-1] means everything except the last character.
There is a third number you can add: step. word[::2] reads every second character, starting from the beginning and running to the end. word[::-1] is the famous reversal trick — a step of -1 walks backwards. The full slice syntax is word[start:stop:step], and any of the three numbers can be left out.
word = "Python" print(word[0]) # first character print(word[-1]) # last character print(word[0:3]) # first three (stop is exclusive) print(word[2:]) # from index 2 to the end print(word[:2]) # first two characters print(word[::2]) # every second character print(word[::-1]) # reversed
Negative indices count from the right, which is far more useful than it first appears. word[-1] is the last character, word[-2] the second-to-last. This means you never need to know the length of a string to touch its tail. A common pattern is word[:n] for the first n characters and word[-n:] for the last n. The colon is what tells Python you want a slice, not a single character — word[2] is one character, but word[2:] is a substring starting there.
One subtlety: slicing past the end of a string does not raise an error. word[2:999] simply stops at the last character. This is safe and forgiving, which is why slicing is the preferred way to crop text when you are not sure of the exact length.
flowchart LR S["'Python'"] --> F["[::-1]"] F --> R["'nohtyP'"] S --> E["[::2]"] E --> O["'Pto'"] style F fill:#3776ab,color:#fff style E fill:#1e293b,color:#fff
Strings come with dozens of built-in methods — .upper(), .lower(), .strip(), .replace(), .find(), .startswith() — that return new strings rather than editing the original. This is the immutability rule in action. When you write name.upper(), Python builds a brand-new string with capital letters and hands it back; name itself is untouched. If you want to keep the result, you must assign it: name = name.upper(). Forgetting this is the source of a classic silent bug: the method runs, the result is discarded, and the original string looks unchanged.
Two methods worth distinguishing: .find() returns -1 when a substring is missing, while .index() raises a ValueError. Both return the position of the first match when the substring exists, but .find() is safer when you are not sure the text is there.
text = " hello world "
clean = text.strip()
print(clean)
print(clean.upper())
print(clean.replace("world", "Python"))
print(clean.startswith("hello"))
print(clean.find("Python")) # -1 because not found
print("original is still:", text)
Building text by gluing strings together with + gets awkward fast, especially when numbers are involved. An f-string lets you drop values straight into the text. Prefix the quotes with f, then wrap any expression in curly braces: f"Hello, {name}". Python evaluates the expression, converts it to text, and slots it in. You can even format numbers: f"{price:.2f}" rounds a float to two decimal places. This is cleaner, faster, and avoids the TypeError that + throws when you accidentally mix strings and numbers.
Because f-strings are expressions, you can call functions inside the braces: f"You have {len(items)} items". The result is readable, self-contained, and avoids the temporary variables that concatenation often forces you to create.
flowchart LR
F["f'Price: {price:.2f}'"] --> E["evaluate price:.2f"]
E --> R["'Price: 4.50'"]
style F fill:#3776ab,color:#fff
style E fill:#1e293b,color:#fff
item = "coffee"
price = 4.5
qty = 3
total = price * qty
print(f"Item: {item}")
print(f"Unit price: ${price:.2f}")
print(f"Qty: {qty}")
print(f"Total: ${total:.2f}")
print(f"Length: {len(item)} letters")
The braces in an f-string are not just placeholders — they are full expressions. f"{len(word)} letters" evaluates len(word) right there. For numbers, a colon inside the braces triggers formatting: :.2f for two decimals, :>10 for right-alignment in ten characters. This replaces older approaches like "%s" formatting and "{}.format()", both of which you will still see in older code but should avoid in new code. Reach for f-strings first; they are readable, fast, and the modern standard.
One caution: the expression inside braces is evaluated immediately. If it is expensive or has side effects, consider computing it beforehand. For simple values and formatting, though, f-strings are almost always the right choice.
Write shout(text) that returns text uppercased with a "!" on the end. (Example: shout("hi") → "HI!".)
def shout(text):
# uppercase text and add "!"
pass
.upper()returns the uppercased string.Join it with
"!"using+.
def shout(text):
return text.upper() + "!"
What does this print? Remember slices are exclusive at the end and negative indices count from the right.
word = "Python" print(word[0]) print(word[-1]) print(word[0:3]) print(word[2:]) print(word[::-1])
word[0] is 'P', word[-1] is 'n'.
word[0:3] stops before index 3, so 'Pyt'. word[2:] runs to the end: 'thon'.
[::-1] reverses the string.
Write initials(first, last) that returns the initials of a name, separated by a dot. For example, initials("Ada", "Lovelace") should return "A.L.".
def initials(first, last):
# return initials like "A.L."
pass
Use indexing to get the first character of each name: first[0] and last[0].
An f-string makes joining them easy: f"{first[0]}.{last[0]}.".
def initials(first, last):
return f"{first[0]}.{last[0]}."
This function is meant to return a trimmed, uppercased version of the text, but it leaves the original untouched. Fix it so the operations are actually applied.
def make_title(text):
text.strip()
text.upper()
return text
String methods return new strings; they do not modify the original.
Chain the methods and return the result: return text.strip().upper().
def make_title(text):
return text.strip().upper()
Write mask_card(card_number) that takes a 16-digit string and returns it masked like "****-****-****-1234", showing only the last four digits. Use slicing to extract the tail.
def mask_card(card_number):
# return masked card number
pass
Use negative slicing to get the last four characters: card_number[-4:].
Concatenate the prefix with the sliced tail.
def mask_card(card_number):
return "****-****-****-" + card_number[-4:]
Recap
- Strings are sequences of characters indexed from
0; negative indices count from the end. - A slice
[start:stop]is exclusive at the end; omit either bound to reach the edge. - A third number gives the step:
word[::2]for every second character,word[::-1]to reverse. - Strings are immutable — methods return new strings, never edit the old one. Save the result with assignment.
- Use f-strings to embed values in text:
f"Value: {x}". - Format numbers inside f-strings with
:.2f,:>10, and similar specifiers.
Next you will learn how to make your programs respond to the user by reading input.