Strings — Creation, Indexing, Slicing, Methods
Indexing, slicing, the string methods that matter, Unicode, and f-strings done right.
Strings Are Immutable Sequences of Characters
A string is a sequence of characters. Python treats single and double quotes identically — 'hello' and "hello" create the exact same object. The convention most style guides recommend is to pick one and use it consistently (double quotes are slightly more common), and to use the other quote type only when your text itself contains one.
name = "Maria"
quote = 'She said "hello" to me' # double quotes inside single quotes — no escaping needed
apostrophe = "It's a nice day" # single quote inside double quotes — no escaping needed
# Triple-quoted strings span multiple lines
bio = """Maria is a software engineer
based in Austin, Texas.
She specialises in backend systems.""".upper()) actually returns a brand new string object, leaving the original untouched. This is a deliberate design choice that makes strings safe to share across a program without fear of one part of the code silently corrupting a value another part depends on — the same immutability concept introduced for numbers back in Module 02.name = "maria"
name.upper()
print(name) # "maria" — UNCHANGED. .upper() returned a new string, and we discarded it.
name = name.upper() # you must reassign to keep the result
print(name) # "MARIA"
name[0] = "M"
# TypeError: 'str' object does not support item assignment — strings cannot be edited in placeAccessing Characters and Substrings
Every character in a string has a position, called an index, starting at 0 for the first character. Python also supports negative indices, counting backward from the end.
word = "Python"
# P y t h o n
# 0 1 2 3 4 5
# -6 -5 -4 -3 -2 -1
word[0] # "P" — first character
word[5] # "n" — last character
word[-1] # "n" — last character, the easier way
word[-6] # "P" — first character, counting from the end
word[10] # IndexError: string index out of rangeSlicing — extracting a substring
Slicing uses the syntax [start:stop:step] to extract a range of characters. The stop index is always excluded — this is the single most important rule to internalise about Python slicing.
word = "Python"
word[0:3] # "Pyt" — indices 0, 1, 2 (index 3 is EXCLUDED)
word[2:] # "thon" — from index 2 to the end
word[:3] # "Pyt" — from the start up to (not including) index 3
word[:] # "Python" — the whole string (a full copy)
word[-3:] # "hon" — the last three characters
word[::2] # "Pto" — every second character, from the start
word[::-1] # "nohtyP" — the entire string, reversed.reverse() method for strings (strings are immutable, so it would have to return a new string anyway) — the step of -1 on a full slice is the standard, expected pattern every Python developer recognises instantly.Slicing never raises an IndexError, even with out-of-range values — it simply clamps to whatever is available. This is different from direct indexing, which does raise an error for an out-of-range index.
word = "Python"
word[2:100] # "thon" — no error, just returns what's available
word[100] # IndexError: string index out of rangelen() and why it matters for slicing
len() returns the number of characters in a string — the count of Unicode code points, specifically, a distinction that becomes relevant in Part 06 of this module. It is the tool you will use constantly alongside indexing and slicing to work with the end of a string relative to its actual length.
word = "Python"
len(word) # 6
word[len(word) - 1] # "n" — the last character, the long way
word[-1] # "n" — the same thing, idiomaticallyThe Methods Every Python Developer Uses Constantly
" hello world ".strip() # "hello world" — remove leading/trailing whitespace
" hello ".lstrip() # "hello " — remove leading whitespace only
" hello ".rstrip() # " hello" — remove trailing whitespace only
"Hello".upper() # "HELLO"
"Hello".lower() # "hello"
"hello world".title() # "Hello World" — capitalise each word
"hello".capitalize() # "Hello" — capitalise only the first character
"Hello World".swapcase() # "hELLO wORLD" — flip every character's caseSearching and testing
"hello world".find("world") # 6 — index where it starts, or -1 if not found
"hello world".index("world") # 6 — same, but raises ValueError if not found
"hello world".rfind("o") # 7 — like find(), but searches from the RIGHT
"hello world".count("o") # 2 — how many times a substring appears
"hello world".startswith("hello") # True
"hello world".endswith(".com") # False
"world" in "hello world" # True — the "in" operator for substring checks"hello".isdigit() # False
"12345".isdigit() # True
"hello".isalpha() # True
"hello123".isalnum() # True — letters AND digits, no other characters
" ".isspace() # True
"Hello World".istitle() # True — every word starts with a capital.find() returns -1 when the substring is not found; .index() raises a ValueError. A common bug is treating a -1 result from .find() as truthy in a conditional — if word.find("x"): is almost always wrong, because -1 is truthy in Python. Always compare explicitly: if word.find("x") != -1:, or better, just use if "x" in word: when you only need to know whether it exists.Transforming Strings Into and Out Of Other Structures
"a,b,c".split(",") # ['a', 'b', 'c']
"hello world foo".split() # ['hello', 'world', 'foo'] — splits on any whitespace by default
"a,b,,c".split(",") # ['a', 'b', '', 'c'] — empty strings are kept
"a.b.c".split(".", 1) # ['a', 'b.c'] — maxsplit limits how many splits happen
",".join(["a", "b", "c"]) # "a,b,c"
" ".join(["hello", "world"]) # "hello world"
"".join(["h", "e", "l", "l", "o"]) # "hello" — joining with an empty separator concatenates directly"hello world".replace("world", "Python") # "hello Python"
"aaa".replace("a", "b", 2) # "bba" — the count argument limits how many replacements
"line1\nline2\nline3".splitlines() # ['line1', 'line2', 'line3'].split() and .join() are inverses of each other, and this round-trip pattern — split a string into pieces, transform them, join them back together — is one of the most common real-world string operations you will write, from parsing CSV-like text to building formatted log lines.
f-strings — Modern String Formatting
An f-string (formatted string literal) lets you embed expressions directly inside a string by prefixing it with f and wrapping expressions in curly braces. This is the modern, idiomatic way to build strings from variables in Python — introduced in Python 3.6 and now the default choice.
name = "Maria"
age = 25
print(f"{name} is {age} years old.") # "Maria is 25 years old."
print(f"Next year, {name} will be {age + 1}.") # expressions work directly inside {}
print(f"{name.upper()} works here.") # method calls work tooFormat specifications
After a colon inside the braces, you can control exactly how a value is formatted — decimal places, thousands separators, padding, and alignment.
price = 1234.5678
f"{price:.2f}" # "1234.57" — round to 2 decimal places
f"{price:,.2f}" # "1,234.57" — thousands separator + 2 decimal places
f"{price:10.2f}" # " 1234.57" — right-aligned in a field 10 characters wide
f"{price:<10.2f}" # "1234.57 " — left-aligned in a field 10 characters wide
f"{price:^12.2f}" # " 1234.57 " — centre-aligned in a field 12 characters wide
f"{42:05d}" # "00042" — zero-padded to 5 digits
f"{0.856:.1%}" # "85.6%" — format as a percentage
f"{255:x}" # "ff" — format as hexadecimal
f"{255:b}" # "11111111" — format as binary= after a variable inside an f-string prints both the variable name and its value — extremely useful for quick debugging: f"{price=}" produces "price=1234.5678" without you having to type the variable name twice..format() and %-formatting — Recognising Legacy Code
f-strings did not exist before Python 3.6. Two older formatting styles remain extremely common in existing codebases, and you need to recognise both immediately even though you should write new code with f-strings.
"{} is {} years old".format(name, age) # positional
"{n} is {a} years old".format(n=name, a=age) # named
"{0} is {1}, {0} again".format(name, age) # indices can repeat"%s is %d years old" % (name, age).format() constantly in codebases more than a few years old, in Stack Overflow answers from before 2016, and occasionally in logging configuration (Python's logging module, covered later in this track, still uses %-style formatting internally for historical reasons). Being unable to read it would slow you down in any real codebase.What a String Actually Is Under the Hood
In Python 3 (unlike Python 2), every str is a sequence of Unicode code points — abstract characters, not raw bytes. This is a deliberate, important design decision: it means "café" and "日本語" are both just ordinary strings, handled identically to plain ASCII text, with no special handling required from you.
Bytes only enter the picture when a string needs to be written to disk, sent over a network, or read from either — at that point, it must be converted to a specific byte representation, called an encoding. UTF-8 is the dominant, correct default choice for virtually all modern text — it can represent every Unicode character, and it is backward-compatible with plain ASCII.
text = "café"
encoded = text.encode("utf-8")
print(encoded) # b'caf\xc3\xa9' — the bytes object representing "café" in UTF-8
decoded = encoded.decode("utf-8")
print(decoded) # "café" — back to a normal string
print(decoded == text) # Truelen() counts code points, not always what you visually see
For the overwhelming majority of text you will work with, len() matches what you would intuitively count. But some visual characters (particularly certain emoji and combined accent characters) are actually composed of multiple Unicode code points, which means len() can occasionally return a number larger than the number of "characters" you would count by eye. This is an edge case worth knowing exists, not something you need to handle specially in typical application code.
Building Strings the Right Way
You can join strings with +, but this is not the right tool when combining many pieces in a loop.
# Slow — creates a brand new string object on every single iteration,
# because strings are immutable and += must build a new string each time:
result = ""
for word in ["a", "b", "c", "d"]:
result += word + " "
# Fast — join() builds the final string once, from a list, in one pass:
result = " ".join(["a", "b", "c", "d"])For a handful of strings this difference is invisible. Building a string from thousands of pieces in a loop with += is a genuine, measurable performance problem — because each += must allocate an entirely new string and copy the old contents into it, making the total work grow roughly with the square of the number of pieces, not linearly. .join() is the correct idiom, and interviewers specifically test for knowing this.
Escape characters and raw strings
"Line one\nLine two" # \n — newline
"Column1\tColumn2" # \t — tab
"She said \"hi\"" # \" — an escaped double quote inside a double-quoted string
"C:\\Users\\Maria" # \\ — a single literal backslashA raw string — prefixed with r — tells Python to treat backslashes as literal characters, not escape sequences. This is almost always used for file paths on Windows and for regular expression patterns (covered in depth in the Regular Expressions module later in this track).
path = r"C:\Users\Maria\Documents" # readable — no need to double every backslash
pattern = r"\d+" # a regex pattern meaning "one or more digits"A San Francisco Retailer's Customer Import Breaks on Real Names
An engineer builds a customer-import script that reads a CSV file of new sign-ups and loads them into the database. It works perfectly in testing with sample data like "John Smith" and "Jane Doe." In production, the import crashes on a real customer named José García.
What the investigation finds
The script opened the file with open("customers.csv") — no encoding specified. On the engineer's development machine (macOS), the default encoding happens to be UTF-8, so it worked in every test. The production server runs a different default locale, and the same code, run there, tries to decode the file's UTF-8 bytes using a different assumed encoding, producing a UnicodeDecodeError the moment it hits the accented character in "José."
The fix
# Before — relies on the operating system's default encoding, which varies by machine:
with open("customers.csv") as f:
...
# After — explicit, and correct on every machine, every time:
with open("customers.csv", encoding="utf-8") as f:
...This exact bug — code that works flawlessly in development and fails in production because of an unstated encoding assumption — is common enough that it has a name among experienced engineers: "works on my machine." Real-world names, addresses, and product descriptions contain non-ASCII characters constantly. This is exactly why Part 07 of this module treated Unicode and encoding as a first-class topic rather than a footnote — it is one of the most common real production bugs in text-processing code, and it is entirely preventable by always specifying an encoding explicitly.
Four Misconceptions About Strings
5 Interview Questions — With Complete Answers
String Mistakes That Show Up Constantly
Errors You Will Hit Working With Strings — And Exactly Why
🎯 Key Takeaways
- ✓Strings are immutable — every method that appears to modify a string actually returns a new one. You must reassign to keep the result.
- ✓Indexing accesses a single character (0-based, negative indices count from the end). Slicing extracts a range with [start:stop:step] — stop is always excluded, and slicing never raises IndexError.
- ✓word[::-1] is the idiomatic way to reverse a string in Python.
- ✓f-strings are the modern standard for building strings from variables, with rich format specs for decimals, padding, thousands separators, and percentages. .format() and %-formatting are older styles you must still be able to read.
- ✓Python 3 strings are Unicode code points, not bytes. Encoding converts str -> bytes; decoding converts bytes -> str. Always specify UTF-8 explicitly when opening files — relying on the OS default is a real, common production bug.
- ✓Use "".join(list) to build a string from many pieces — never += in a loop, which is a real, measurable performance problem.
- ✓Raw strings (r"...") treat backslashes literally — essential for Windows file paths and regular expressions.
- ✓.find() returns -1 when not found (never raises); .index() raises ValueError when not found. Prefer the "in" operator for a simple yes/no check.
What comes next
Module 05 covers control flow — how Python evaluates truthiness, every form of conditional logic, and the readability patterns senior engineers actually use.
Module 05 → Control FlowDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.