A lecture for adult learners — this is the reading to work through before
python-strings-exercises.md. Its goal isn't to make you a Python expert;
it's to give you enough working knowledge of the most common string methods
that you can read a chunk of Python code someone (or some AI agent) wrote
and judge, with confidence, whether it does what it claims to do.
If you've already read the Java lecture in this repo, a lot of this will feel familiar — strings work similarly in most languages. We'll point out the places where Python does something differently than you might expect, especially if you're coming from Java.
A string in Python — "hello", "42", "" — is a sequence of characters,
just like in Java. And just like in Java, Python strings never change once
created. Every method that looks like it's "modifying" a string is
actually building a brand-new string and handing it back to you. The
original is untouched.
name = " ana "
name.strip() # builds a new, trimmed string... and discards it
print(name) # still prints " ana "The new value has to be captured, usually by reassigning the variable:
name = " ana "
name = name.strip() # capture the result
print(name) # prints "ana"Keep this rule in your head for every method below: it returns a new value; it never changes the string you called it on.
Checks whether two strings hold the same characters. This is the normal, correct way to compare string content in Python.
When to use it: any time you're checking whether string content matches — usernames, commands, filenames, anything a human typed or a file produced.
a = "hello"
b = "".join(["h", "e", "l", "l", "o"]) # built at runtime
same = (a == b) # True — same charactersif command == "quit":
print("Goodbye!")is checks whether two variables refer to the exact same object in
memory, not whether they hold the same text. Python sometimes reuses the
same object for identical short string literals behind the scenes, which can
make is seem to work — but that behavior is an implementation detail,
not something your code should ever depend on.
When to use it: almost never for strings. is is the right tool for
exactly one very common case: checking whether something is None.
a = "cat"
b = "cat"
a is b # often True — but don't rely on this!
a == b # True, reliably — this is what you should actually writedef describe(value):
if value is None: # correct, idiomatic None check
return "nothing"
return value.upper()Both normalize case so you can compare strings while ignoring
uppercase/lowercase differences. .casefold() is the more thorough of the
two — it correctly handles some Unicode cases .lower() misses (for
example, the German ß) — so it's the preferred choice specifically for
comparisons.
When to use it: matching user input where case shouldn't matter — a yes/no prompt, a search term, a case-insensitive command.
"YES".lower() == "yes" # True
"Straße".casefold() == "strasse" # True — .lower() alone would miss thisdef same_word(a, b):
return a.casefold() == b.casefold()Python strings support the ordinary comparison operators directly, doing an alphabetical (technically: character-code-point) comparison.
When to use it: sorting strings, or checking alphabetical order.
"apple" < "banana" # True — "apple" comes firstdef comes_before(a, b):
return a < bReturns the number of characters in the string.
When to use it: anywhere you need to know "how long is this," including as a bound for loops or slicing.
len("hello") # 5def is_long_enough(password):
return len(password) >= 8Returns the single character at a given position. Indexing starts at 0.
Negative indices count from the end — s[-1] is the last character.
When to use it: inspecting one character at a specific position.
"hello"[0] # 'h'
"hello"[-1] # 'o'def print_chars(s):
for i in range(len(s)):
print(s[i])The more idiomatic way to visit every character is
for ch in s:— strings are directly iterable in Python, so you usually don't needrange(len(s))at all.
Returns a piece of the string. Like s[index], the start is included and
end is excluded. Omitting start means "from the beginning"; omitting
end means "to the end." A step of -1 walks backward.
When to use it: pulling out a portion of text you know the position of, or reversing a string entirely.
"hello world"[6:] # "world" (index 6 to the end)
"hello world"[:5] # "hello" (start to index 5, excluded)
"hello"[-3:] # "llo" (last 3 characters)
"hello"[::-1] # "olleh" (the whole string, reversed)def last_three(s):
return s[-3:]
endis exclusive, exactly like in Java'ssubstring()."hello"[0:3]gives"hel", not"hell".
find locates the position of the first occurrence of a substring;
rfind locates the last. Both return -1 if there's no match — neither
raises an exception, which is a nice contrast to indexing directly.
When to use it: locating where something is inside a string — e.g. the
position of a file extension's ..
"report.final.pdf".find(".") # 6 (the first dot)
"report.final.pdf".rfind(".") # 12 (the last dot)def extension(filename):
dot = filename.rfind(".")
if dot == -1:
return ""
return filename[dot + 1:]Returns True if the substring appears anywhere in the string. This is the
idiomatic Python way to check "does this text show up somewhere in there?" —
notice it's an operator, not a method call.
When to use it: a simple containment check, when you don't care exactly where the match is.
"World" in "Hello World" # Truedef contains_word(text, word):
return word.lower() in text.lower() # case-insensitiveCheck whether the string begins (or ends) with specific text.
When to use it: validating a format — URLs, filenames, command prefixes — where the position of the match matters, not just its presence.
"https://example.com".startswith("https://") # Truedef is_secure(url):
return url.startswith("https://")"weekend".endswith("end") # True.upper()/.lower()— the whole string, all-uppercase or all-lowercase..title()— capitalizes the first letter of every word, lowercases the rest of each word..capitalize()— capitalizes only the very first letter of the entire string, lowercasing everything else (including later words) — easy to confuse with.title().
When to use it: normalizing text for comparison or display.
"hello".upper() # "HELLO"
"hello WORLD".title() # "Hello World"
"hello WORLD".capitalize() # "Hello world" — note: only "Hello" is capitalizeddef title_case(s):
return s.title()Remove whitespace from both ends (.strip()), just the left (.lstrip()),
or just the right (.rstrip()). Never touches the middle.
When to use it: cleaning up user-typed input — form fields, file lines, command-line arguments.
" hello ".strip() # "hello"def is_blank(s):
return s.strip() == ""Careful with the optional argument.
.strip("xy")does not mean "remove the substringxy" — it means "remove any charactersxoryfrom each end, as many as there are.""xxhixx".strip("x")correctly gives"hi", but"xxxhixxx".strip("xx")behaves the same as.strip("x")and strips all thexs, not just two from each side. If you specifically need to remove a literal prefix/suffix, use.removeprefix()/.removesuffix()instead (next).
Remove an exact, literal piece of text from the start or end — and only if
it's actually there. Unlike .strip(), these treat the argument as a whole
substring, not a set of characters, and they do nothing (no error) if the
string doesn't start/end with it.
When to use it: stripping a known literal prefix or suffix — a URL scheme, a file marker, a fixed tag.
"xxhelloxx".removeprefix("xx").removesuffix("xx") # "hello"def strip_xx(s):
return s.removeprefix("xx").removesuffix("xx")Breaks a string into a list of pieces. With an explicit separator, it splits
at every literal occurrence — note this is a literal string, not a
regular expression (a real difference from Java's String.split(), which
is regex-based). Called with no arguments, it splits on any run of
whitespace and automatically discards empty results — usually what you
actually want for splitting words in a sentence.
When to use it: breaking up delimited data — CSV fields, words in a sentence, path segments.
"1.2.3".split(".") # ["1", "2", "3"] — literal dot, no escaping needed
"hello world".split() # ["hello", "world"] — collapses the extra spacesdef parts(version):
return version.split(".")The reverse of split() — called on the delimiter, glues an iterable of
strings together with that delimiter placed between each pair.
When to use it: building a delimited string from a list — a CSV row, a comma-separated summary.
Important: every item in the iterable must already be a str. Joining a
list of numbers requires converting them first.
",".join(["a", "b", "c"]) # "a,b,c"
",".join(str(n) for n in [1, 2, 3]) # "1,2,3"def join_numbers(numbers):
return ",".join(str(n) for n in numbers)+ concatenates two strings. * between a string and an integer repeats it
that many times.
When to use it: simple concatenation, or building repeated text like separators and padding.
"Hello" + ", " + "world!" # "Hello, world!"
"-" * 4 # "----"def repeat_dash(n):
return "-" * nPython has no built-in .reverse() method on strings (that exists on
list, which is a common mix-up) — the idiomatic way to reverse a string is
the full-string slice with a step of -1.
When to use it: reversing text, or checking palindromes.
"hello"[::-1] # "olleh"def is_palindrome(s):
return s == s[::-1]Pad a string out to a target width. .zfill() pads with "0" on the left
specifically (and correctly handles a leading - sign). .rjust() /
.ljust() pad with spaces by default, but accept a custom fill character as
a second argument.
When to use it: formatting numbers or text into fixed-width columns — report output, aligned tables, zero-padded IDs.
str(42).zfill(5) # "00042"
str(42).rjust(5, "0") # "00042" — equivalent, more general
"hi".ljust(6, ".") # "hi...."def padded(n):
return str(n).zfill(5)The modern, preferred way to build a string from a template with embedded
values. Anything inside {} is a real Python expression, evaluated fresh
each time the f-string runs — not just a plain variable name.
When to use it: building any string with data plugged into it — this should be your default choice for string formatting in Python.
name, age = "Ana", 30
f"{name} is {age} years old." # "Ana is 30 years old."def describe(name, age):
return f"{name} is {age} years old."An older template style using %s (any value), %d (integer), etc., with
the values supplied as a tuple after %. You'll still see this in existing
code, so it's worth recognizing, even though f-strings are preferred for new
code.
When to use it: mostly just to read it in legacy code. Prefer f-strings when writing new code.
"%s is %d years old." % ("Ana", 30) # "Ana is 30 years old."Just like Java's
String.format,%-specifiers are matched by position — putting%dwhere a string value lands raises aTypeErrorat runtime.
str() converts any value — including None — to its string form.
.isdigit() checks whether every character in a string is a digit
(0–9); note that it returns False for a leading - or +, so it's
not a reliable stand-in for "can int() parse this."
When to use it: str() for general conversion; .isdigit() only for a
quick, narrow digits-only check.
str(None) # "None" — never raises
str(42) # "42"
"42".isdigit() # True
"-42".isdigit() # False — the sign isn't a digit characterdef is_valid_int(s):
try:
int(s) # more robust than .isdigit() — handles signs correctly
return True
except ValueError:
return FalseThat's the toolkit. Head over to python-strings-exercises.md and put it to
work — each exercise shows you a snippet using one or more of these methods
and asks you to decide whether it's doing the right thing. Some are correct;
some have a bug hiding in exactly the kind of detail covered above (a
strip() call that removes more than intended, an is comparison standing
in for ==, a .capitalize() used where .title() was needed). A few even
test whether you're carrying over the wrong assumption from another
language, like assuming .split() is regex-based the way it is in Java.
Reading the code carefully — the same way you'd review a teammate's or an AI
agent's pull request — is the actual skill being built here.