Strings are immutable sequences of characters — every "modification" actually creates a new string object:
s = "hello"
s[0] = "H" # TypeError: 'str' object does not support item assignment
s = "H" + s[1:] # "Hello" — a brand new string, `s` now points to it
This immutability is why building a large string by repeated += in a loop is a real performance trap — each += allocates an entirely new string and copies the old contents into it, making an O(n)-per-iteration operation inside an O(n) loop, i.e. O(n²) overall:
# Slow for large n — a new string object allocated on every iteration
result = ""
for word in words:
result += word + " "
# Fast — str.join builds the final string once
result = " ".join(words)
.format() vs. %name, score = "Ana", 97.456
f"{name} scored {score:.1f}" # "Ana scored 97.5" — preferred in modern code
"{} scored {:.1f}".format(name, score) # same result, older style
"%s scored %.1f" % (name, score) # oldest style, still seen in logging calls
f-strings (Python 3.6+) are the current convention — they're evaluated at runtime as real expressions (f"{a + b}" works), not just variable substitution, and are generally the fastest of the three. The %-style is still common specifically in logging calls (logging.info("%s failed", name)) because the string interpolation there is deferred — it only happens if that log level is actually enabled, avoiding the cost of formatting a message nobody will see.
" hi ".strip() # "hi" — also lstrip(), rstrip()
"a,b,,c".split(",") # ['a', 'b', '', 'c']
",".join(["a", "b", "c"]) # "a,b,c"
"Hello".lower() # "hello"
"cat" in "concatenate" # True — substring check
"hello".replace("l", "L") # "heLLo"