All three interpolate values into strings. % is the oldest C-style syntax.
str.format() uses {} placeholders and is more flexible. f-strings
(Python 3.6+) embed expressions inline and are the fastest and most readable.
name, n = "Ada", 3
"Hi %s, %d items" % (name, n) # old % style
"Hi {}, {} items".format(name, n) # str.format
f"Hi {name}, {n} items" # f-string — preferred
f"{n * 2 = }" # "n * 2 = 6" — self-documenting
Rule of thumb: prefer f-strings for new code — they evaluate expressions
directly and avoid the argument-ordering errors of % and .format().
A str is a sequence of Unicode code points (text); bytes is a
sequence of raw bytes (0-255). You convert between them with encode
(str -> bytes) and decode (bytes -> str), specifying an encoding like UTF-8.
s = "café"
b = s.encode("utf-8") # b'caf\xc3\xa9' — 5 bytes (é is 2)
b.decode("utf-8") # "café" — back to text
len(s), len(b) # (4, 5)
s + b # TypeError — can't mix str and bytes
Why it matters: files and network sockets deal in bytes, your program logic in str. Rule of thumb: decode bytes to str as early as possible and encode back to bytes only at the I/O boundary.
split breaks a string into a list on a separator; strip removes
leading/trailing whitespace (or given characters); join glues an iterable of
strings together with a separator. All return new strings since str is
immutable.
" a,b,c ".strip() # "a,b,c"
"a,b,c".split(",") # ["a", "b", "c"]
",".join(["a", "b", "c"]) # "a,b,c"
"Hello".lower(), "Hi".upper() # ("hello", "HI")
"hello".replace("l", "L") # "heLLo"
Rule of thumb: sep.join(list) is the inverse of text.split(sep), and chaining
these covers most everyday text wrangling.
Strings are immutable, so each += creates a brand-new string and copies
everything so far — turning a loop into O(n^2) work and lots of garbage. join
allocates the result once, making it O(n).
# Slow — new string every iteration
result = ""
for word in words:
result += word
# Fast — single allocation
result = "".join(words)
Rule of thumb: collect pieces in a list (or generator) and call "".join(...) —
it's the Python equivalent of a StringBuilder.
A raw string (r"...") tells Python not to process backslash escapes, so
\n, \t, etc. stay as literal backslash-plus-character. They're ideal for
regular expressions and Windows file paths.
print("a\tb") # a b — \t is a tab
print(r"a\tb") # a\tb — backslash kept literally
import re
re.findall(r"\d+", "x12y3") # ['12', '3'] — no double-backslashing
path = r"C:\Users\name" # backslashes stay intact
Rule of thumb: reach for r"..." whenever your string is full of backslashes —
it avoids the noise and bugs of escaping every one.
Inside {} (or after : in format), a format spec controls alignment,
width, and precision: {value:[fill][align][width][,][.precision][type]}. It
works in f-strings and str.format alike.
f"{42:5}" # " 42" — width 5, right-aligned (default for numbers)
f"{'hi':<5}|" # "hi |" — left-align in width 5
f"{'hi':^5}|" # " hi |" — center
f"{42:05}" # "00042" — zero-padded
f"{3.14159:.2f}" # "3.14" — 2 decimal places
f"{1234567:,}" # "1,234,567" — thousands separator
f"{0.25:.1%}" # "25.0%" — percentage
Rule of thumb: .2f controls decimals, a number sets width, and </>/^ set
alignment — combine them for clean tabular output.
A str object cannot be changed in place — every "modification" returns a new
string. You can't assign to an index. This enables string hashing (so strings can be
dict keys/set members) and safe sharing.
s = "hello"
s[0] = "H" # TypeError — item assignment not allowed
s = "H" + s[1:] # "Hello" — build a new string instead
{"key": 1} # works because str is hashable/immutable
Rule of thumb: treat strings as read-only values; to "edit" one, create a new string
(or work in a list of chars and "".join at the end).
Use in for a boolean membership test, find/index for positions, and
startswith/endswith for prefixes/suffixes. find returns -1 when missing;
index raises ValueError.
"ell" in "hello" # True
"hello".find("l") # 2 — first index, -1 if absent
"hello".index("z") # ValueError
"hello".count("l") # 2
"file.txt".endswith((".txt", ".md")) # True — tuple of options
Rule of thumb: use in for yes/no, find when "not found" is normal (it returns
-1), and index when absence is an error.
Beyond lower/upper, there's title, capitalize, casefold (aggressive
lowercasing for caseless matching), and a family of is* predicates: isdigit,
isalpha, isalnum, isspace, isidentifier.
"hello world".title() # "Hello World"
"ß".casefold() # "ss" — better than lower() for matching
"123".isdigit() # True
"abc1".isalnum() # True
" ".isspace() # True
Rule of thumb: use casefold() for case-insensitive comparison and the is*
methods for quick input validation.
Unicode lets the same glyph be encoded differently — e.g. "é" as one code point
or as "e" + combining accent. They look identical but differ byte-for-byte. Use
unicodedata.normalize to canonicalize before comparing.
import unicodedata
a = "café" # precomposed é
b = "café" # e + combining accent
a == b # False!
unicodedata.normalize("NFC", a) == unicodedata.normalize("NFC", b) # True
Rule of thumb: normalize user/text input to a canonical form (NFC) before comparing, deduping, or using strings as keys.
Slicing uses s[start:stop:step] and returns a new string. Omitted bounds
default to the ends; a negative step walks backwards (the classic reverse trick).
s = "abcdef"
s[1:4] # "bcd"
s[:3] # "abc"
s[-2:] # "ef"
s[::2] # "ace" — every other char
s[::-1] # "fedcba" — reversed
Rule of thumb: s[::-1] reverses, s[a:b] never errors on out-of-range bounds, and
slices always copy (cheap for strings).
Conversion flags pick which dunder to call: !r uses repr(), !s uses
str(), !a uses ascii(). The = specifier (3.8+) prints both the
expression text and its value — great for debugging.
name = "Ada"
f"{name!r}" # "'Ada'" — repr, keeps the quotes
f"{name!s}" # "Ada" — str
x = 5
f"{x = }" # "x = 5" — self-documenting debug
f"{x=:.2f}" # "x=5.00" — combine with a format spec
Rule of thumb: use !r in logs/errors to show quoting clearly, and f"{var=}" for
quick print-debugging.
Triple quotes ("""...""") span multiple lines and preserve newlines — used for
docstrings and block text. Adjacent string literals are joined at compile time
(implicit concatenation), handy for splitting long literals.
doc = """line 1
line 2""" # contains a real newline
msg = ("part one " # implicit concatenation —
"part two") # becomes "part one part two"
# gotcha: a forgotten comma in a list silently concatenates!
items = ["a" "b", "c"] # ['ab', 'c']
Rule of thumb: triple quotes for multi-line/docstrings; implicit concat for readable long literals — but watch for missing commas in lists.
Build a translation table with str.maketrans and apply it with translate
— one pass for many character mappings or deletions, faster and cleaner than chained
replace calls.
table = str.maketrans("aeiou", "AEIOU")
"hello world".translate(table) # "hEllO wOrld"
drop = str.maketrans("", "", "!?.") # third arg = chars to delete
"h!e?l.lo".translate(drop) # "hello"
Rule of thumb: for multiple single-char substitutions/removals, translate beats
stacking .replace(); for substrings or patterns, use replace or re.sub.
partition(sep) splits on the first occurrence and always returns a
3-tuple (before, sep, after) — even when the separator is missing (then sep
and after are empty). This avoids the unpacking errors split can cause.
"key=value=x".split("=", 1) # ['key', 'value=x']
"key=value".partition("=") # ('key', '=', 'value')
"novalue".partition("=") # ('novalue', '', '') — safe, no error
"a.b.c".rpartition(".") # ('a.b', '.', 'c') — from the right
Rule of thumb: use partition/rpartition for clean "split once into head/sep/tail"
with guaranteed three parts; use split when you want a variable-length list.
More Fundamentals interview questions
More ways to practice
The self-quiz is live. Join our channel for updates, new content & tech tips.