Skip to content

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:

Python
single = 'Python'
double = "Python"
print(single)
print(double)
print(single == double)
Output
Python
Python
True

Having 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:

Python
message = "It's a sunny day"
quote = 'She said "hello" and walked away.'
print(message)
print(quote)
Output
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):

Python
print("Line one\nLine two")
print("She said \"hi\" back")
print('It\'s fine')
print("C:\\Users\\ada")
Output
Line one
Line two
She said "hi" back
It's fine
C:\Users\ada

Notice 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:

Python
poem = """Roses are red,
violets are blue,
this string spans lines
and keeps them too."""
print(poem)
Output
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:

Python
word = "cat"
print(word[0])
try:
    word[0] = "b"
except TypeError as error:
    print("Python refused:", error)
Output
c
Python refused: 'str' object does not support item assignment

Compare 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:

Python
greeting = "hello"
louder = greeting.upper()
print(greeting)
print(louder)
Output
hello
HELLO

greeting 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:

Python
greeting = "hello"
greeting = greeting.upper()
print(greeting)
Output
HELLO

The 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:

Python
word = "cat"
new_word = "b" + word[1:]
print(word)
print(new_word)
Output
cat
bat

Indexing 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:

Python
language = "Python"
print(language[0])
print(language[3])
print(len(language))
Output
P
h
6

Since "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:

Python
language = "Python"
print(language[-1])
print(language[-2])
Output
n
o

To 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":

Python
language = "Python"
print(language[0:3])
print(language[:3])
print(language[3:])
print(language[-3:])
Output
Pyt
Pyt
hon
hon

A 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]:

Python
language = "Python"
print(language[::2])
print(language[::-1])
Output
Pto
nohtyP

That 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:

Python
for letter in "cat":
    print(letter)
Output
c
a
t

The 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:

Python
shout = "quiet please"
print(shout.upper())
print(shout.title())
print("HELLO".lower() == "hello")
Output
QUIET PLEASE
Quiet Please
True

Trimming 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:

Python
raw = "   ada@example.com\n"
print(repr(raw.strip()))
print(repr("xxhelloxx".strip("x")))
print(repr("  spaced  ".lstrip()))
Output
'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:

Python
sentence = "the quick brown fox"
print(sentence.split())
csv_row = "ada,lovelace,1815"
print(csv_row.split(","))
print("a-b-c".split("-", 1))
Output
['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":

Python
words = ["never", "gonna", "give"]
print(" ".join(words))
print("-".join(words))
print("".join(["c", "a", "t"]))
Output
never gonna give
never-gonna-give
cat

Replacing. .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:

Python
line = "I like cats. Cats are great."
print(line.replace("cats", "dogs"))
print(line.replace(".", "!"))
print("aaaa".replace("a", "b", 2))
Output
I like dogs. Cats are great.
I like cats! Cats are great!
bbaa

The 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:

Python
filename = "report_2026.csv"
print(filename.startswith("report"))
print(filename.endswith(".csv"))
print(filename.endswith((".txt", ".csv")))
Output
True
True
True

Searching. .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:

Python
text = "banana bread"
print(text.find("bread"))
print(text.find("cake"))
print(text.index("bread"))
Output
7
-1
7

Here is the difference in action, with the error caught so the program can keep running:

Python
text = "banana bread"
try:
    position = text.index("cake")
except ValueError as error:
    print("index() raised:", error)
print(text.find("cake"))
Output
index() raised: substring not found
-1

Pick .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:

Python
print("Python3".isalnum())
print("hello world".isalnum())
print("2026".isdigit())
print("20.26".isdigit())
print("abc".isalpha())
print("abc123".isalpha())
Output
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.

Python
name = "Ada"
age = 36
print(f"{name} is {age} years old.")
print(f"Next year she turns {age + 1}.")
Output
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:

Python
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:,}")
Output
Total: $7.50
Ratio: 0.667
Percent: 66.7%
Big number: 1,234,567

Note 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":

Python
for item, qty in [("apples", 3), ("kiwi", 12)]:
    print(f"{item:<10}{qty:>4}")
Output
apples       3
kiwi        12

One 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.

Python
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}")
Output
NAME            INITIALS  USERNAME
Ada Lovelace    A.L.      ada
Grace Hopper    G.H.      grace

Walking 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:

Python
name = "ada"
name.upper()
print(name)
Output
ada

The 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):

Python
name = "ada"
name = name.upper()
print(name)
Output
ADA

Building 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:

Python
words = ["red", "green", "blue"]
line = ""
for word in words:
    line += word + ", "
print(repr(line))
Output
'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:

Python
words = ["red", "green", "blue"]
print(repr(", ".join(words)))
Output
'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:

Python
text = "hello world"
position = text.find("z")
print(text[position])
Output
d

There 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:

Python
text = "hello world"
position = text.find("z")
if position == -1:
    print("not found")
else:
    print(text[position])
Output
not found

If 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:

Python
print("scores.csv".strip(".csv"))
Output
ore

Python 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:

Python
print("scores.csv".removesuffix(".csv"))
print("scores.txt".removesuffix(".csv"))
Output
scores
scores.txt

Mixing a string with a number. A string that looks like a number is still text, and + will not combine the two types:

Python
age = "17"
try:
    print(age + 1)
except TypeError as error:
    print("TypeError:", error)
print(int(age) + 1)
Output
TypeError: can only concatenate str (not "int") to str
18

Convert 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.

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.