Strings
Python strings — the methods you will actually use, slicing with negative indices, and the f-string tricks that make code read like prose.
What you'll learn
- Slicing and indexing, including negative indices that count from the end
- The string methods every Python programmer knows by heart
- f-string formatting for numbers, alignment, dates, and debugging
- Why a string is immutable, and why that makes join faster than +=
Before you start
A string is a sequence of characters — but the single most important fact about it is one word: immutable. Once a string exists, it can never be changed. Every operation that looks like a modification — lower-casing it, replacing a letter, trimming a space — does not touch the original at all. It builds and hands back a brand-new string. Hold on to that idea; half of this lesson follows from it.
Indexing and slicing
You reach into a string by position, and Python offers two rulers at once: positive indices counting from the left starting at 0, and negative indices counting from the right starting at -1.
s = "datarekha"
print(s[0]) # first character
print(s[-1]) # last character — negative counts from the end
print(s[0:4]) # 'data' — a slice [start:stop)
print(s[4:]) # 'rekha' — from position 4 to the end
print(s[:4]) # 'data' — from the start up to 4
print(s[::-1]) # reversed — step of -1
print(len(s))
d
a
data
rekha
data
ahkeratad
9
The slice s[a:b:c] reads as start (included), stop (excluded), step. The exclusive stop is the part people forget: s[0:4] gives you positions 0, 1, 2, 3 — four characters — and stops before 4. Here is the whole ruler in one picture:
The real payoff is that this exact slicing works on every Python sequence — lists, tuples, even raw bytes — so the effort you spend internalising it here pays back many times over.
The string methods you will use daily
A small set of methods does the overwhelming majority of real text work. Read them less as a list to memorise and more as a vocabulary you will recognise everywhere:
" hi ".strip() # 'hi' — remove surrounding whitespace
"FOO".lower() # 'foo'
"foo bar".split() # ['foo', 'bar'] — split on whitespace
"foo,bar".split(",") # ['foo', 'bar'] — split on a delimiter
",".join(["a", "b", "c"]) # 'a,b,c' — join with a separator
"hello".replace("l", "L") # 'heLLo'
"hello".startswith("he") # True
"hello".endswith("o") # True
f"Hi {name}" # f-string — the modern way to interpolate
Notice that split() and join() are mirror images of one another — one breaks text apart on a delimiter, the other stitches pieces back together with one. You will pair them constantly when cleaning data.
f-strings — far beyond {name}
You already met the basic f-string. Its real power lives after the colon, in the format spec, which controls width, precision, alignment, and more:
from datetime import datetime
# Number formatting
pi = 3.14159265358979
print(f"{pi:.2f}") # 2 decimals
print(f"{pi:.4f}") # 4 decimals
print(f"{1234567:,}") # thousands separator
print(f"{0.85:.1%}") # as a percentage
# Width and alignment — left-justify the name, right-justify the count
for w in ["Pen", "Notebook", "Headphones"]:
print(f"{w:<12}|{len(w):>3}")
# Dates, formatted by strftime codes
now = datetime(2026, 5, 27, 14, 30)
print(f"{now:%Y-%m-%d %H:%M}")
# Debug mode — prints the expression text AND its value
x, y = 7, 12
print(f"{x=}, {y=}, {x+y=}")
3.14
3.1416
1,234,567
85.0%
Pen | 3
Notebook | 8
Headphones | 10
2026-05-27 14:30
x=7, y=12, x+y=19
That last line is a quiet hero of debugging. Writing f"{x=}" prints both the name x and its value — so a quick print(f"{x=}, {y=}, {x+y=}") shows you exactly what each expression evaluates to, with no risk of mislabelling which number is which.
Raw strings — for paths and regex
A backslash inside a normal string starts an escape sequence: \n is a newline, \t a tab. That is a problem the moment your text genuinely contains backslashes, like Windows paths or regular expressions. Prefix the string with r and the backslashes are taken literally:
path = r"C:\Users\Aarav\data.csv" # backslashes stay as backslashes
pattern = r"\d{4}-\d{2}-\d{2}" # a regex for YYYY-MM-DD
Without the r, that \d and those \U sequences would be mangled. Make raw strings a reflex for every regex pattern you write.
Why immutability changes how you build strings
Now back to the opening fact. Because a string cannot change, you cannot assign into one position, and trying is an error:
s = "hello"
s[0] = "H" # TypeError — a string can't be modified in place
s = "H" + s[1:] # instead, build a NEW string
This is not just a rule to obey; it has a real performance edge. If you grow a string with += inside a loop, every iteration copies the entire string-so-far into a new object — turning what feels like simple appending into roughly O(n²) work. Collect the pieces in a list and join them once instead:
parts = ["row" + str(i) for i in range(1000)]
# Slow: each += copies the whole accumulated string again — O(n^2)
bad = ""
for p in parts:
bad += p + "\n"
# Fast: join walks the list once and allocates the result once — O(n)
good = "\n".join(parts)
print("Same result:", bad == good + "\n")
print("Total length:", len(good))
Same result: True
Total length: 6889
Both produce the same text, but join builds it in a single pass. On a thousand small rows you might not feel it; on a million you very much will.
In one breath
- A string is an immutable sequence — every “edit” returns a new string.
- Slice with
[start:stop:step]; stop is excluded, and negative indices count from the right. strip,lower,split,join,replace,startswith/endswithdo most real text work.- f-string format specs after
:control precision (.2f), separators (,), percentages (.1%), width, and alignment;f"{x=}"is a debugging gift. - Build big strings with
"".join(parts), never+=in a loop.
Practice
Quick check
What’s next
Strings live inside lists, and lists inside dicts. Next comes lists — sorting, copying, and the shared-reference trap that bites Python programmers more than any other.
Practice this in an interview
All questionsSorting both strings and comparing is O(n log n). Using Counter or a character frequency array is O(n) and is the preferred approach. The solution must handle case sensitivity and spaces consistently or the comparison will silently give wrong answers.
Strings are immutable so they can be safely shared (interned), used as dict keys, and passed across threads without locks. Every operation that looks like mutation — slicing, concatenation, `.replace()` — returns a new string object. Interning means CPython may store only one copy of a string literal and reuse it, saving memory for repeated identifiers and short strings.
`__repr__` is the developer-facing representation — unambiguous, ideally eval-able back to the object. `__str__` is the user-facing string — readable and concise. When only `__repr__` is defined, Python falls back to it for `str()` as well, so implement `__repr__` first.
The .str accessor vectorizes Python string methods across a Series without a Python-level loop, propagates NaN automatically, and integrates cleanly into method chains. Calling apply(lambda x: x.upper()) does the same work slower and breaks on NaN unless you add a null check.