Skip to content

Latest commit

 

History

History
513 lines (377 loc) · 14.7 KB

File metadata and controls

513 lines (377 loc) · 14.7 KB

Understanding Strings in Python

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.


1. What Is a String, and Why Does "Immutable" Matter?

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.


2. Comparing Strings

==

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 characters
if command == "quit":
    print("Goodbye!")

is — and why it's usually the wrong choice for strings

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 write
def describe(value):
    if value is None:      # correct, idiomatic None check
        return "nothing"
    return value.upper()

.casefold() and .lower()

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 this
def same_word(a, b):
    return a.casefold() == b.casefold()

< and >

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 first
def comes_before(a, b):
    return a < b

3. Indexing and Slicing

len(s)

Returns 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")  # 5
def is_long_enough(password):
    return len(password) >= 8

s[index]

Returns the single character at a given position. Indexing starts at 0. Negative indices count from the ends[-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 need range(len(s)) at all.

Slicing: s[start:end] and s[start:end:step]

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

end is exclusive, exactly like in Java's substring(). "hello"[0:3] gives "hel", not "hell".

.find(x) and .rfind(x)

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

4. Searching Strings

in

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"  # True
def contains_word(text, word):
    return word.lower() in text.lower()  # case-insensitive

.startswith(prefix) and .endswith(suffix)

Check 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://")  # True
def is_secure(url):
    return url.startswith("https://")
"weekend".endswith("end")  # True

5. Case & Whitespace

.upper(), .lower(), .title(), .capitalize()

  • .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 capitalized
def title_case(s):
    return s.title()

.strip(), .lstrip(), .rstrip()

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 substring xy" — it means "remove any characters x or y from 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 the xs, not just two from each side. If you specifically need to remove a literal prefix/suffix, use .removeprefix() / .removesuffix() instead (next).

.removeprefix(text) and .removesuffix(text) (Python 3.9+)

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")

6. Splitting and Joining

.split(sep) and .split()

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 spaces
def parts(version):
    return version.split(".")

.join(iterable)

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)

7. Building and Transforming Strings

+ and *

+ 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 "-" * n

Reversing with slicing: s[::-1]

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

.zfill(width), .rjust(width, fillchar), .ljust(width, fillchar)

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)

8. Formatting and Converting

f-strings: f"{expression}"

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

% formatting (older style)

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 %d where a string value lands raises a TypeError at runtime.

str(x) and numeric checks like .isdigit()

str() converts any value — including None — to its string form. .isdigit() checks whether every character in a string is a digit (09); 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 character
def is_valid_int(s):
    try:
        int(s)          # more robust than .isdigit() — handles signs correctly
        return True
    except ValueError:
        return False

What's Next

That'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.