Python lesson 6 of 9
Python Strings
Learn how Python strings are created, why they are immutable, how indexing and slicing work, and the string methods and f-string formatting you will use most.
Published · Every example on this page was run before it was published.
Almost every program you write will handle text at some point — a name typed into a form, a line read from a file, a message printed to the screen. In Python, text lives in a value called a string, and strings come with their own rules and their own toolbox of built-in operations. This lesson walks through all of it, from the quotes you type to the formatting tricks that make output look professional.
What Is a String?
A string is a sequence of characters treated as a single value. A character is one symbol — a letter, a digit, a space, a punctuation mark, even an emoji. "Sequence" means the characters sit in a fixed order, one after another, and Python remembers that order exactly.
Picture a strip of movie film. Each frame holds one picture, the frames are glued together in order, and you can point at any frame by counting from the start of the strip. A Python string works the same way: each slot holds exactly one character, the slots are numbered starting from 0, and the whole strip is one object you can pass around under a single variable name.
You create a string by wrapping characters in quotes. Python accepts single quotes or double quotes, and they produce the exact same value — there is no "string type" difference between them:
single = 'Python'
double = "Python"
print(single)
print(double)
print(single == double)Python
Python
TrueHaving two kinds of quotes is genuinely useful, because it lets you put one kind of quote inside a string delimited by the other kind without any extra work:
message = "It's a sunny day"
quote = 'She said "hello" and walked away.'
print(message)
print(quote)It's a sunny day
She said "hello" and walked away.Escapes and Triple-Quoted Strings
Sometimes you need a character that you cannot simply type between quotes — a newline, or a quote mark of the same kind that is delimiting the string. For those cases Python uses an escape sequence: a backslash followed by a letter or symbol, which together stand for one special character.
The four you will meet most often are \n (start a new line), \t (insert a tab), \" or \' (a
literal quote mark that does not end the string), and \\ (a single literal backslash, needed because a
lone backslash would be read as the start of another escape):
print("Line one\nLine two")
print("She said \"hi\" back")
print('It\'s fine')
print("C:\\Users\\ada")Line one
Line two
She said "hi" back
It's fine
C:\Users\adaNotice that \n printed as an actual line break rather than as a backslash and an n. The escape is
interpreted when Python builds the string, so what is stored is one newline character, not two ordinary
ones.
When you want several lines of text without peppering the string with \n, use a triple-quoted
string — three quote marks (""" or ''') on each end. Everything between them is kept verbatim,
line breaks included:
poem = """Roses are red,
violets are blue,
this string spans lines
and keeps them too."""
print(poem)Roses are red,
violets are blue,
this string spans lines
and keeps them too.Be careful where you put the opening """. If you press Enter right after it, the string begins with a
newline and your output will start with a blank line. Triple-quoted strings are also commonly used to
write documentation at the top of a function, which you will see later as a docstring.
Strings Are Immutable
This is the single most important fact about Python strings: they are immutable, which means that once a string exists, its characters can never be changed. You can build new strings out of old ones all day long, but you cannot edit a string in place.
Reading a character is fine. Writing one is not:
word = "cat"
print(word[0])
try:
word[0] = "b"
except TypeError as error:
print("Python refused:", error)c
Python refused: 'str' object does not support item assignmentCompare this with a list, which is mutable — my_list[0] = "b" works perfectly. Strings deliberately
do not allow it.
The practical consequence shows up everywhere: every string method returns a brand-new string and
leaves the original alone. A method is a function attached to a value, called with a dot, like
greeting.upper(). Calling .upper() does not shout at the existing string; it manufactures a second
string and hands it back:
greeting = "hello"
louder = greeting.upper()
print(greeting)
print(louder)hello
HELLOgreeting is still lowercase because it was never touched. If you want the variable to hold the new
value, you must assign the result back to it — reassigning a variable is allowed even though editing a
string is not:
greeting = "hello"
greeting = greeting.upper()
print(greeting)HELLOThe same idea applies when you want to "change" one character. You do not edit the string; you assemble
a new one from the pieces you want, using + to glue strings together:
word = "cat"
new_word = "b" + word[1:]
print(word)
print(new_word)cat
batIndexing and Slicing
Because a string is an ordered sequence, you can reach into it by position. Square brackets after the
string give you the character at a given index, and counting starts at 0, so the first character
lives at index 0 and not at index 1. The built-in len() function reports how many characters a string
holds:
language = "Python"
print(language[0])
print(language[3])
print(len(language))P
h
6Since "Python" has 6 characters, its valid positive indices run from 0 through 5. Asking for
language[6] raises an IndexError — a runtime error meaning the position you asked for does not
exist.
Negative indices count backward from the end, so -1 is the last character and -2 is the one
before it. This saves you from writing len(language) - 1 every time you want the tail of a string:
language = "Python"
print(language[-1])
print(language[-2])n
oTo pull out more than one character at a time, use a slice, written text[start:stop]. A slice
includes the character at start and stops just before stop, so the character at stop is never
part of the result. Leaving out start means "from the beginning" and leaving out stop means "through
the end":
language = "Python"
print(language[0:3])
print(language[:3])
print(language[3:])
print(language[-3:])Pyt
Pyt
hon
honA slice takes an optional third number, the step, written text[start:stop:step]. A step of 2 takes
every second character. A step of -1 walks the string backward, which gives Python's famous one-line
string reversal, text[::-1]:
language = "Python"
print(language[::2])
print(language[::-1])Pto
nohtyPThat reversal trick is worth memorizing — it turns up constantly in palindrome problems, where you need to compare a string against its own reverse. Slicing always produces a new string and never disturbs the one you sliced, which follows directly from immutability.
You can also walk through a string one character at a time with a for loop, exactly as you would with
a list:
for letter in "cat":
print(letter)c
a
tThe String Methods You Will Use Most
Python ships dozens of string methods. These are the ones that earn their keep in everyday code.
Changing case. .upper() returns an all-uppercase copy, .lower() an all-lowercase copy, and
.title() a copy with the first letter of each word capitalized. Lowercasing both sides before
comparing is the standard way to compare text case-insensitively:
shout = "quiet please"
print(shout.upper())
print(shout.title())
print("HELLO".lower() == "hello")QUIET PLEASE
Quiet Please
TrueTrimming whitespace. .strip() removes whitespace — spaces, tabs, newlines — from both ends of a
string, leaving the middle untouched. .lstrip() and .rstrip() trim only the left or right side. If
you pass an argument, it is treated as a set of characters to remove from the ends, not as a word. The
repr() function below prints a string with its quotes visible so you can see exactly where it begins
and ends:
raw = " ada@example.com\n"
print(repr(raw.strip()))
print(repr("xxhelloxx".strip("x")))
print(repr(" spaced ".lstrip()))'ada@example.com'
'hello'
'spaced 'Stripping input is nearly always the first thing you do with text a user typed or a file gave you, because invisible trailing newlines are a classic source of comparisons that mysteriously fail.
Splitting and joining. .split() breaks a string into a list of smaller strings. Called with no
argument it splits on any run of whitespace; called with an argument it splits on that exact separator.
An optional second argument caps how many splits are made:
sentence = "the quick brown fox"
print(sentence.split())
csv_row = "ada,lovelace,1815"
print(csv_row.split(","))
print("a-b-c".split("-", 1))['the', 'quick', 'brown', 'fox']
['ada', 'lovelace', '1815']
['a', 'b-c'].join() is the exact reverse: it takes a list of strings and welds them into one string, placing the
string you called it on between each pair. The separator comes first and the list goes inside the
parentheses, which looks backwards at first but reads naturally once you get used to it — "join these
words with a space":
words = ["never", "gonna", "give"]
print(" ".join(words))
print("-".join(words))
print("".join(["c", "a", "t"]))never gonna give
never-gonna-give
catReplacing. .replace(old, new) returns a copy with every occurrence of old swapped for new. A
third argument limits how many replacements happen. Like every string method, it changes nothing about
the original:
line = "I like cats. Cats are great."
print(line.replace("cats", "dogs"))
print(line.replace(".", "!"))
print("aaaa".replace("a", "b", 2))I like dogs. Cats are great.
I like cats! Cats are great!
bbaaThe second Cats survived because .replace() is case-sensitive — "Cats" and "cats" are different
text as far as Python is concerned.
Checking the ends. .startswith() and .endswith() answer a yes-or-no question about the beginning
or end of a string and give you back True or False. Both accept a tuple of options if you want to
test several possibilities at once:
filename = "report_2026.csv"
print(filename.startswith("report"))
print(filename.endswith(".csv"))
print(filename.endswith((".txt", ".csv")))True
True
TrueSearching. .find() reports the index where a smaller string first appears inside a bigger one, and
returns -1 when it is not there at all. .index() does the identical search but raises a ValueError
instead of returning -1 when the search fails:
text = "banana bread"
print(text.find("bread"))
print(text.find("cake"))
print(text.index("bread"))7
-1
7Here is the difference in action, with the error caught so the program can keep running:
text = "banana bread"
try:
position = text.index("cake")
except ValueError as error:
print("index() raised:", error)
print(text.find("cake"))index() raised: substring not found
-1Pick .find() when a missing match is a normal, expected outcome you want to check for, and .index()
when a missing match means something has gone wrong and the program should stop. If you only need to
know whether the text appears and not where, the in operator is simpler than either:
print("bread" in text) prints True.
Inspecting the contents. Three methods report what kind of characters a string contains.
.isdigit() is True when every character is a digit, .isalpha() when every character is a letter,
and .isalnum() when every character is a letter or a digit. All three are False for an empty string,
and none of them tolerate spaces or punctuation:
print("Python3".isalnum())
print("hello world".isalnum())
print("2026".isdigit())
print("20.26".isdigit())
print("abc".isalpha())
print("abc123".isalpha())True
False
True
False
True
False"20.26".isdigit() is False because the dot is not a digit — a useful reminder that .isdigit()
checks for whole numbers only, not decimals.
Formatting with f-strings
Gluing values into a message with + is painful, because + refuses to mix a string with a number and
you end up calling str() everywhere. An f-string solves this: put the letter f immediately
before the opening quote, and then any expression you write inside curly braces is evaluated and dropped
into the text at that spot.
name = "Ada"
age = 36
print(f"{name} is {age} years old.")
print(f"Next year she turns {age + 1}.")Ada is 36 years old.
Next year she turns 37.The braces can hold any expression, not just a variable name — age + 1 was computed on the spot.
Numbers are converted to text automatically, so no str() call is needed.
You can also tell an f-string how to display a value by adding a colon and a format specification
inside the braces. .2f means "a decimal number with exactly two digits after the point", which is what
you want for money. .1% multiplies by 100 and appends a percent sign. A bare comma inserts thousands
separators:
price = 7.5
ratio = 2 / 3
print(f"Total: ${price:.2f}")
print(f"Ratio: {ratio:.3f}")
print(f"Percent: {ratio:.1%}")
print(f"Big number: {1234567:,}")Total: $7.50
Ratio: 0.667
Percent: 66.7%
Big number: 1,234,567Note that 7.5 printed as 7.50 — the format specification padded it out, which is exactly what a
price should look like. Rounding is applied for display only; the value in price is unchanged.
Format specifications also handle alignment, which is how you line up columns of output. :<10 means
"left-aligned, padded with spaces to 10 characters wide" and :>4 means "right-aligned in 4
characters":
for item, qty in [("apples", 3), ("kiwi", 12)]:
print(f"{item:<10}{qty:>4}")apples 3
kiwi 12One syntax caution: if the f-string is delimited by double quotes, use single quotes for any string written inside the braces, and vice versa.
A Worked Example
Here is a small program that cleans up messy signup data and prints a tidy table. It uses splitting, stripping, case methods, slicing, searching, joining, and f-string alignment together.
def clean(raw):
return raw.strip().lower()
def initials(name):
letters = []
for part in name.split():
letters.append(part[0].upper())
return ".".join(letters) + "."
rows = [" Ada LOVELACE , ada@example.com ", "grace hopper , GRACE@navy.org "]
print(f"{'NAME':<16}{'INITIALS':<10}{'USERNAME'}")
for row in rows:
name_part, email_part = row.split(",")
name = clean(name_part)
email = clean(email_part)
username = email[:email.find("@")]
print(f"{name.title():<16}{initials(name):<10}{username}")NAME INITIALS USERNAME
Ada Lovelace A.L. ada
Grace Hopper G.H. graceWalking through it line by line: clean() is a tiny helper that does two jobs at once by chaining
methods — .strip() removes the surrounding whitespace and returns a new string, and .lower() is then
called on that new string. Chaining works precisely because each method returns a fresh string rather
than modifying anything.
initials() starts with an empty list, loops over the words produced by name.split(), and appends the
first character of each word (part[0]) in uppercase. It collects the letters in a list rather than
building a string with +=, then calls ".".join(letters) to stitch them together with dots, and adds
one final dot on the end.
rows holds two deliberately messy records: inconsistent capitalization, stray spaces around the comma,
and a trailing space. The first print writes the header using three f-string fields — 'NAME' padded
to 16 characters and 'INITIALS' padded to 10 — so the header lines up with the rows beneath it.
Inside the loop, row.split(",") cuts each record at its comma and produces a list of exactly two
pieces, which are unpacked into name_part and email_part in one step. Both are passed through
clean(). Then email.find("@") returns the index where the at-sign sits, and the slice
email[:that index] keeps everything before it — that is the username, with the at-sign itself excluded
because a slice stops before its end position. Finally, one f-string prints the title-cased name padded
to 16 characters, the initials padded to 10, and the username, producing three aligned columns from data
that arrived in no particular shape at all.
Common Mistakes
Expecting a method to change the string in place. This is the number one string bug for beginners.
.upper(), .strip(), .replace() and friends all return a new string; calling one and ignoring the
return value accomplishes nothing:
name = "ada"
name.upper()
print(name)adaThe uppercase string really was created — it was just thrown away immediately, because nothing captured it. Assign the result back to the variable (or to a new one):
name = "ada"
name = name.upper()
print(name)ADABuilding a long string with + inside a loop. Because strings are immutable, line += word cannot
extend the existing string; it must allocate a new string and copy every character accumulated so far
into it. Repeat that for every item and the copying work grows with the square of the final length,
which gets slow on large inputs. It also invites the classic trailing-separator bug:
words = ["red", "green", "blue"]
line = ""
for word in words:
line += word + ", "
print(repr(line))'red, green, blue, 'There is a stray ", " hanging off the end, and the loop did more copying than necessary. Collect the
pieces in a list and let .join() build the string once, which puts the separator strictly between
items:
words = ["red", "green", "blue"]
print(repr(", ".join(words)))'red, green, blue'Forgetting that .find() returns -1 instead of failing loudly. This one is dangerous because the
program does not crash — it quietly does the wrong thing. Since -1 is a perfectly valid index meaning
"last character", using an unchecked .find() result as an index gives you nonsense:
text = "hello world"
position = text.find("z")
print(text[position])dThere is no z in the string, yet d was printed, because .find() returned -1 and text[-1] is
the final character. Always test the result against -1 before using it:
text = "hello world"
position = text.find("z")
if position == -1:
print("not found")
else:
print(text[position])not foundIf you would rather the program stop than continue with bad data, use .index() instead — it raises a
ValueError on a failed search, so the problem announces itself immediately.
Treating .strip() as a way to remove a suffix. When you pass an argument to .strip(), Python
treats it as a set of individual characters to shave off both ends, not as a word to delete. The
result is often startling:
print("scores.csv".strip(".csv"))orePython kept shaving characters off both ends for as long as they appeared anywhere in ".csv", so it
removed the leading s and c of "scores" and the trailing s as well, stopping only when it hit
letters that were not in that set. To drop an actual suffix, use .removesuffix(), which deletes
the exact ending you name and does nothing if it is not there:
print("scores.csv".removesuffix(".csv"))
print("scores.txt".removesuffix(".csv"))scores
scores.txtMixing a string with a number. A string that looks like a number is still text, and + will not
combine the two types:
age = "17"
try:
print(age + 1)
except TypeError as error:
print("TypeError:", error)
print(int(age) + 1)TypeError: can only concatenate str (not "int") to str
18Convert explicitly with int() or float() when you need arithmetic, and with str() — or better, an
f-string — when you need text.
Next Steps
The two practice problems linked with this lesson exercise exactly what you just read. Valid
Palindrome asks whether a string reads the same forward and backward, which is where .lower(),
.isalnum(), and the [::-1] reversal come together. Reverse Words in a String is a direct
workout for .split(), list reversal, and .join().
Before jumping in, open the Python playground and retype a few examples from this lesson by hand — especially the slicing ones, changing the numbers to see what comes out. Immutability and off-by-one slice boundaries are much easier to internalize by experiment than by reading, and ten minutes of poking at strings in the playground will save you a lot of debugging later.
Write it yourself
Reading about code and writing it are different skills. These exercises practise exactly what this lesson covered; they run in this tab and need no account.
- The ScoreboardA game scoreboard where every name, score and bar lands in the same column, built from one line of text with the values sitting inside it.
- The Cafe TicketA printed cafe receipt with the shop name at the top, three item rows in straight columns, a subtotal, a service charge the program works out itself, a total, and a thank-you line at the bottom.
When you want a harder one
Interview-style problems graded against hidden tests — a big step up from the exercises. Come back to these when the ideas in this lesson feel comfortable rather than new.