Python Strings
A string in Python is an ordered sequence of characters used to represent text — names, sentences, file paths, JSON, HTML, anything made of letters, digits, or symbols. Strings are one of Python’s most-used data types, and nearly every program reads, builds, or transforms them. This lesson covers how strings actually work under the hood, every major way to create and manipulate them, and the mistakes that trip up even experienced developers.
Overview / How Strings Work
In Python, a string is an instance of the built-in str class. Internally, a string is a sequence of Unicode code points — Python 3 strings are Unicode by default, so they can hold emoji, accented letters, Chinese characters, and plain ASCII all in the same object. Because str is a sequence type, it supports the same operations as other sequences like lists and tuples: indexing, slicing, iteration, membership testing with in, and the len() function.
The single most important fact about strings is that they are immutable: once a string object is created, its contents can never change. Every method that looks like it “changes” a string — .upper(), .replace(), .strip() — actually builds and returns a brand-new string object, leaving the original untouched. This is why string methods must be reassigned to a variable (or used directly) to have any visible effect. Immutability also lets Python safely reuse and cache short, simple strings internally (a process called interning), and it makes strings safe to use as dictionary keys, since a key’s hash value is guaranteed to never change out from under you.
Because strings are sequences, each character has a positional index starting at 0 for the first character. Negative indices count from the end, with -1 referring to the last character. Slicing (string[start:stop:step]) extracts a sub-sequence and always returns a new string, never a reference into the original.
Syntax
Strings can be written with single quotes, double quotes, or triple quotes (for multi-line text):
single = 'Hello'
double = "Hello"
triple = """Multiple
lines of text"""
raw = r"C:\Users\name" # backslashes are literal
f_string = f"Result: {2 + 2}" # expressions are evaluated inline
| Form | Example | Purpose |
|---|---|---|
| Single/double quotes | 'text', "text" |
Standard one-line strings; use whichever avoids escaping the other type of quote |
| Triple quotes | """text""" |
Multi-line strings and docstrings |
| Raw string | r"\d+" |
Disables escape sequences — common for regex patterns and Windows paths |
| f-string | f"{name}" |
Embeds and formats expressions directly inside the string (Python 3.6+) |
Common escape sequences used inside normal (non-raw) strings include \n (newline), \t (tab), \\ (a literal backslash), and \"/\' (a literal quote character).
Examples
Example 1: Creating, indexing, and slicing
name = "Ada Lovelace"
greeting = "Hello, " + name + "!"
print(greeting)
print(name[0])
print(name[-1])
print(name[0:3])
print(name[:3])
print(name[4:])
print(len(name))
print(name.upper())
title = f"{name} was born in {1815}"
print(title)
Output:
Hello, Ada Lovelace!
A
e
Ada
Ada
Lovelace
12
ADA LOVELACE
Ada Lovelace was born in 1815
Here + concatenates strings into a new one. Indexing with name[0] grabs the first character, and name[-1] the last. Slices name[0:3] and name[:3] are equivalent — when start is 0 it can be omitted. name[4:] takes everything from index 4 to the end. len() counts characters, and .upper() returns a new, fully uppercase string without touching name.
Example 2: Cleaning and splitting text
sentence = " The quick brown fox jumps over the lazy dog "
cleaned = sentence.strip()
words = cleaned.split()
print(words)
print(len(words))
joined = "-".join(words)
print(joined)
replaced = cleaned.replace("quick", "slow")
print(replaced)
print(cleaned.lower())
print(cleaned.startswith("The"))
print(cleaned.find("fox"))
Output:
['The', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']
9
The-quick-brown-fox-jumps-over-the-lazy-dog
The slow brown fox jumps over the lazy dog
the quick brown fox jumps over the lazy dog
True
16
.strip() removes leading and trailing whitespace (but not whitespace in the middle). .split() with no arguments splits on any run of whitespace and returns a list of words. "-".join(words) does the reverse: it stitches a list of strings back together using "-" as the glue — this is the idiomatic, efficient way to build a string from many pieces. .find() returns the index where a substring first appears, or -1 if it isn’t found.
Example 3: Formatting a receipt and confirming immutability
def format_receipt(item: str, price: float, qty: int) -> str:
total = price * qty
return f"{item}: {qty} x ${price:.2f} = ${total:.2f}"
items = [("Coffee", 3.50, 2), ("Bagel", 2.25, 3), ("Juice", 4.00, 1)]
lines = [format_receipt(name, price, qty) for name, price, qty in items]
receipt = "\n".join(lines)
print(receipt)
grand_total = sum(price * qty for _, price, qty in items)
print(f"Total: ${grand_total:.2f}")
original = "immutable"
modified = original.replace("im", "un")
print(original)
print(modified)
print(original is modified)
Output:
Coffee: 2 x $3.50 = $7.00
Bagel: 3 x $2.25 = $6.75
Juice: 1 x $4.00 = $4.00
Total: $17.75
immutable
unmutable
False
The :.2f format spec inside the f-string rounds a float to two decimal places, which is essential for money. The list comprehension builds one formatted line per item, and "\n".join(lines) combines them into a single multi-line string. Finally, calling .replace() on original produces a completely separate object — original is left unchanged, and original is modified is False because they are two distinct objects in memory, proving immutability in action.
Under the Hood
When you write s = "hello", Python creates a str object in memory and binds the name s to it. Every subsequent “modification” — slicing, concatenation, calling a method — allocates a new object rather than editing the existing one. You can see this with id(): id(s) before and after a method call will differ, because the method’s result is a new object at a new memory address.
For performance, CPython automatically interns certain strings — typically short strings that look like identifiers (letters, digits, underscores) created as literals. Interned strings are stored once and reused, so two separate literals with the same interned text may report True for is comparisons even though you never explicitly linked them. This is an implementation detail, not a language guarantee, which is exactly why you should never rely on is to compare string values.
Iteration over a string yields one character at a time, since each character is itself a length-1 string: for ch in "hi": print(ch) prints h then i. Membership testing with in (e.g. "cat" in "concatenate") performs a substring search rather than checking for an exact element match, which is a subtlety worth remembering when strings are compared to lists or tuples.
Common Mistakes
Mistake 1: Trying to change a string in place. Because strings are immutable, item assignment raises an error:
word = "cat"
word[0] = "b" # TypeError: 'str' object does not support item assignment
This fails because there is no way to overwrite one character of an existing str object. The fix is to build a new string instead, for example with slicing or .replace():
word = "cat"
word = "b" + word[1:]
print(word)
Output:
bat
Mistake 2: Forgetting that methods return new strings instead of mutating. A very common beginner bug is calling a method and expecting the original variable to change:
text = " padded "
text.strip()
print(repr(text)) # still has the padding — nothing was reassigned
Output:
' padded '
The call to .strip() did produce a cleaned string, but it was discarded because the result was never assigned anywhere. The correct version reassigns the return value: text = text.strip().
Best Practices
- Prefer f-strings (
f"{value}") over older%-formatting or.format()for readability and speed, unless you’re building a template string dynamically ahead of time. - When concatenating many pieces in a loop, collect them in a list and call
"".join(list)at the end instead of repeatedly using+=, which creates a new string on every iteration and can turn an O(n) task into O(n²). - Use
infor substring checks (if "@" in email:) rather than.find()when you only need a yes/no answer. - Compare string values with
==, neveris—ischecks object identity, which depends on interning and is not guaranteed behavior. - Use raw strings (
r"...") for regular expressions and Windows file paths to avoid fighting with backslash escapes. - Use triple-quoted strings for multi-line text and docstrings rather than chaining
\ncharacters manually. - Validate string content with the
is*methods (str.isdigit(),str.isalpha(),str.isspace()) before converting types, to avoid runtime exceptions.
Practice Exercises
- Exercise 1: Write a function
reverse_words(sentence)that takes a sentence and returns it with the order of its words reversed (so"I love Python"becomes"Python love I"). Hint: combine.split(), slicing, and" ".join(). - Exercise 2: Write a function
count_vowels(text)that returns how many vowels (a, e, i, o, u, case-insensitive) appear in a string, without using any imports. - Exercise 3: Given a list of email addresses as strings, write code that builds a new list containing only the ones that contain
"@"and end with".com", using a list comprehension and the appropriate string methods.
Summary
- Strings (
str) are immutable, ordered sequences of Unicode characters — every “modifying” method actually returns a new string. - Indexing and slicing (
s[i],s[start:stop:step]) work like other sequences, with negative indices counting from the end. - f-strings (
f"{expr:.2f}") are the modern, preferred way to build formatted strings from expressions. - Use
"".join(list)instead of repeated+=concatenation for building strings from many parts efficiently. - Always compare string contents with
==, notis, since interning is an implementation detail, not a guarantee. - Remember to reassign the result of a string method — the original variable never changes on its own.
