Strings

Strings are immutable sequences of Unicode characters, with a rich set of built-in methods for slicing, searching, and transforming text.

A str in Python is an immutable, ordered sequence of Unicode code points. Every string operation -- concatenation, slicing, uppercasing, replacing -- produces a new string object rather than modifying the original in place. Strings share the sequence protocol with lists and tuples (indexing, slicing, iteration, membership) while adding a large family of text-specific methods.

String Literals: Quoting Styles

Strings can be written with single quotes, double quotes, or triple-quoted (single or double) for multi-line text; single and double quotes are functionally identical, so the choice is purely stylistic, useful mainly to avoid escaping a quote character that appears in the text.

a = 'hello'
b = "hello"                 # identical to a
c = "it's fine"               # double quotes avoid escaping the apostrophe
d = 'she said "hi"'             # single quotes avoid escaping the double quote
multiline = """line one
line two"""

Escape Sequences and Raw Strings

Backslash escape sequences (\n newline, \t tab, \\ backslash, \' and \") let you embed special characters. A raw string literal, prefixed with r, disables escape processing entirely -- useful for regular expressions and Windows file paths.

print("a\tb")        # a    b  (tab)
print("line1\nline2")   # two lines

path = r"C:\Users\name"    # raw string -- backslashes are literal
pattern = r"\d+\.\d+"       # common in regex, avoids double-escaping

f-strings: Formatted String Literals

f-strings (3.6+), prefixed with f, embed expressions directly inside {} and are the modern, idiomatic way to build formatted strings, replacing % formatting and .format() for new code.

name = "Ana"
age = 30
print(f"{name} is {age} years old")
print(f"{age * 2 = }")            # self-documenting expression (3.8+): "age * 2 = 60"
print(f"{3.14159:.2f}")            # format spec: "3.14"

Immutability

A string cannot be modified in place: item assignment raises TypeError, and every method that seems to 'transform' a string (upper(), replace(), strip(), ...) returns a brand-new string, leaving the original unchanged.

s = "hello"
s[0] = "H"          # TypeError: 'str' object does not support item assignment

upper_s = s.upper()    # returns a NEW string
print(s)                 # still "hello" -- unchanged
print(upper_s)             # "HELLO"

Indexing and Slicing

Strings support the same indexing and slicing rules as lists and tuples: zero-based positions, negative indices from the end, and slices that return new strings.

s = "Python"
s[0]        # 'P'
s[-1]        # 'n'
s[1:4]        # 'yth'
s[::-1]        # 'nohtyP' -- reversed
s[::2]          # 'Pto'

Concatenation and Repetition

+ concatenates strings into a new string; * repeats a string a given number of times. Both are common but, chained in a loop, are O(n^2) in CPython because each + builds a fresh string -- join() is the efficient alternative for many pieces.

greeting = "Hello, " + "world" + "!"
line = "-" * 20               # '--------------------'

# inefficient for many pieces:
result = ""
for word in ["a", "b", "c"]:
    result += word + " "        # rebuilds a new string every iteration

# efficient alternative:
result = " ".join(["a", "b", "c"])   # 'a b c'

Membership Testing

The in operator tests substring containment (not character-by-character equality), scanning the string for any occurrence of the right-hand text.

"lo" in "hello"         # True -- substring match
"z" in "hello"            # False
"" in "hello"               # True -- empty string is a substring of anything

Common String Methods: Searching and Testing

find()/index() locate a substring; startswith()/endswith() test prefixes/suffixes; is*() methods classify content.

s = "  Hello, World!  "
s.strip().find("World")     # 7 -- index of first match (after stripping)
s.strip().index("xyz")        # ValueError -- index() raises if not found
"file.txt".endswith(".txt")     # True
"123".isdigit()                    # True
"abc123".isalnum()                   # True

Common String Methods: Transforming

upper()/lower()/title()/capitalize() change case; strip()/lstrip()/rstrip() remove leading/trailing whitespace (or specified characters); replace() substitutes occurrences; split()/join() convert between strings and lists.

"  hello world  ".strip()       # "hello world"
"Hello World".lower()             # "hello world"
"hello".replace("l", "L")           # "heLLo"
"a,b,,c".split(",")                    # ['a', 'b', '', 'c']
"-".join(["a", "b", "c"])                 # "a-b-c"
"  hi  ".rstrip()                            # "  hi" -- trailing whitespace only

Strings Are Iterable Sequences

Iterating a string yields its characters one at a time (each itself a length-1 string, since Python has no separate 'char' type); len() gives the number of code points.

for ch in "abc":
    print(ch)          # a, b, c

len("hello")             # 5
list("abc")                 # ['a', 'b', 'c']

String Comparison

Strings compare lexicographically by Unicode code point using standard comparison operators, case-sensitively by default -- 'Z' (code point 90) sorts before 'a' (97).

"apple" < "banana"     # True -- lexicographic
"Zebra" < "apple"          # True -- uppercase Z (90) < lowercase a (97)
"apple".lower() == "APPLE".lower()   # True -- case-insensitive comparison

Edge Cases

The empty string "" is falsy and has length 0. Strings containing the same characters compare equal with == regardless of identity; small string interning is a CPython optimization, not something to rely on for identity comparisons.

CPython implementation detail: CPython may intern certain string literals (short, identifier-like strings) so that equal literals share one object; this is an optimization, not a language guarantee, so always use == for string equality, never is.

bool("")           # False
bool(" ")             # True -- a space is a non-empty string

a = "hello"
b = "hel" + "lo"
a == b                # True -- value equality
a is b                  # may be True or False -- CPython interning detail, don't rely on it

Complete Example: Simple Text Analysis

lower() normalizes case before splitting, so 'The' and 'the' count as the same word when deduplicated via set().

def analyze(text: str) -> dict[str, int]:
    words = text.lower().split()
    return {
        "chars": len(text),
        "words": len(words),
        "unique_words": len(set(words)),
    }

report = analyze("The quick brown fox jumps over the lazy dog. The dog barks.")
print(report)
# {'chars': 61, 'words': 12, 'unique_words': 10}

Mental Model and Quick Reference

Treat a string like a read-only tuple of characters: every operation that looks like a mutation is actually building and returning a new string. There is no in-place string editing in Python -- if you need to build text incrementally and efficiently, accumulate pieces in a list and join() them once at the end.

Method              Purpose                          Returns
strip()/lstrip()/rstrip()   remove whitespace/chars       new string
upper()/lower()/title()      change case                    new string
split(sep)                     break into a list               list[str]
join(iterable)                    combine list into a string       str
replace(old, new)                    substitute occurrences         new string
find(sub)/index(sub)                   locate a substring                int (find: -1 if absent)
startswith(p)/endswith(p)                 test prefix/suffix                  bool
in                                            test substring membership            bool

Key Takeaways

Strings are immutable sequences of Unicode code points; every 'transforming' method returns a new string.

Single and double quotes are equivalent; triple quotes span multiple lines; r-strings disable escape processing.

f-strings are the modern, idiomatic way to build formatted strings in current Python.

Indexing, slicing, iteration, and membership work the same way as on lists and tuples, since strings share the sequence protocol.

Building long strings with repeated += in a loop is inefficient; use ''.join() over a list of pieces instead.

Always compare string equality with ==, never is -- CPython's string interning is an optimization detail, not a guarantee.

s[0] = "H" # TypeError s = "H" + s[1:] # build a new string Strings can't be mutated in place; any 'edit' must construct a new string from pieces of the old one.

result = "" for w in words: result += w # O(n^2) overall result = "".join(words) # O(n) overall

Repeated += in a loop rebuilds a new string each time; join() builds the final string in one efficient pass.

if a is b: # relies on CPython interning ...

if a == b: # value equality, always correct String interning is a CPython implementation detail that doesn't apply to every string; == is the correct, portable way to compare string values.

Why does s[0] = 'X' on a string raise TypeError?

Strings are immutable in Python: item assignment is not supported by the type at all, so any change to a string's content must build and return a new string object instead of altering the existing one.

Why is repeatedly using += to build a long string in a loop considered inefficient, and what's the idiomatic alternative?

Each += creates a brand-new string and copies the old content into it, making the whole loop O(n^2) in the total length; the idiomatic fix is to collect pieces in a list and call ''.join(pieces) once, which is O(n).

What is the difference between find() and index() when a substring is not present?

find() returns -1 to signal 'not found'; index() raises ValueError instead, so index() is preferable when the substring's absence should be treated as an error rather than silently checked.

Why should you never use is to compare two strings for equality?

is checks object identity, not value equality, and while CPython sometimes interns (shares) certain string literals as an optimization, that behavior is an implementation detail, not a language guarantee -- two equal strings can be distinct objects, so == is the only reliable comparison.

What does a raw string literal like r'\n' actually contain, versus the plain literal '\n'?

r'\n' contains two literal characters, a backslash and the letter n; the plain '\n' contains one character, a newline, because the backslash escape sequence is processed normally.

What type does iterating over a string yield, and how does that relate to Python having no separate 'char' type?

Each iteration step yields a length-1 str; Python has no distinct character type, so a single character is simply a string of length one.

Is "" in "hello" True or False, and why might that be surprising?

True -- the empty string is considered a substring of every string (it matches at any position, including the very start), which can surprise people expecting in to require at least one character of overlap.

Write a function that checks whether a string is a palindrome, ignoring case and non-alphanumeric characters.

def is_palindrome(text: str) -> bool: cleaned = "".join(ch.lower() for ch in text if ch.isalnum()) return cleaned == cleaned[::-1] print(is_palindrome("A man, a plan, a canal: Panama")) # True print(is_palindrome("hello")) # False

The generator expression filters to alphanumeric characters and lowercases them, building a normalized string; comparing it to its reverse (cleaned[::-1]) checks the palindrome property in one line.

What does each print statement output?

s[1:4] prints 'yth' (indices 1,2,3). s[-3:] prints 'hon' (last three characters). s[::-1] prints 'nohtyP' (reversed). s * 2 prints 'PythonPython' (repetition, not multiplication of characters).

Write a function that counts how many times each word appears in a string, case-insensitively, ignoring surrounding punctuation, returning the result sorted by frequency descending.

import string def word_counts(text: str) -> list[tuple[str, int]]: counts: dict[str, int] = {} for raw_word in text.lower().split(): word = raw_word.strip(string.punctuation) if word: counts[word] = counts.get(word, 0) + 1 return sorted(counts.items(), key=lambda pair: -pair[1]) print(word_counts("Cat, dog, cat! Dog dog.")) # [('dog', 3), ('cat', 2)] strip(string.punctuation) removes leading/trailing punctuation from each lowercased token before counting, and sorted() with a negated-count key orders the results from most to least frequent.

s = "Python"
print(s[1:4])
print(s[-3:])
print(s[::-1])
print(s * 2)