Strings
Text in Python 3.14, ordered for DS&A work first: characters and code points, slicing, every str
method, searching and comparing, costs, and the idioms string problems are built from; then regex,
f-strings and the format mini-language, t-strings (new in 3.14), bytes and Unicode. Numbers parsed
out of text are in Numbers; the TypeScript side is in
Strings.
Literals & basics
| Literal | Meaning |
|---|---|
"a", 'a' | same type; pick one quote style (ruff/black default to ") |
"""...""", '''...''' | multi-line; newlines kept |
r"\d+\n" | raw: backslashes are literal (regex, Windows paths) |
f"{x}", rf"..." | formatted string, evaluated now |
t"{x}" | template string (3.14): a Template object, not a str |
b"...", rb"..." | bytes, not str |
"a" "b" | adjacent literals concatenate at compile time |
"\n", "\t", "\\", "\x41", "é", "\N{EURO SIGN}" | escapes |
str is an immutable sequence of Unicode code points. Every method returns a new string, and
s[0] = "x" raises TypeError: 'str' object does not support item assignment. There is no char
type: s[0] is a 1-length str. To edit characters, convert to a list, change it, and join it back.
s = "hello"
len(s) # 5, counts code points
"ell" in s # True, substring test
s * 2 # 'hellohello'
s < "help" # True, compares code points
str(42), repr("x") # ('42', "'x'")
", ".join(["a", "b", "c"]) # 'a, b, c'
", ".join(map(str, [1, 2])) # join needs str items
chars = list(s) # ['h', 'e', 'l', 'l', 'o']: mutable
chars[0] = "j"
"".join(chars) # 'jello'Characters & code points
| Expression | Result | Notes |
|---|---|---|
ord("a"), chr(97) | 97, 'a' | code point ↔ 1-character string |
ord(ch) - ord("a") | 0 to 25 for a to z | the slot in a 26-item count list |
chr(ord("a") + i) | the i-th lowercase letter | back from slot to letter |
ord(ch) - ord("0") | 0 to 9 for "0" to "9" | ASCII only: ord("٣") - ord("0") is 1587 |
int(ch) | digit value | any Unicode decimal digit: int("٣") is 3; ValueError on "²" |
"a" <= ch <= "z" | ASCII lowercase test | ch.islower() and ch.isalpha() also accept "é" and "ß" |
ch.isascii() and ch.isalnum() | ASCII letter or digit | what "alphanumeric" means in most problem statements |
ch.lower(), ch.upper() | case mapping | can change the length: "ß".upper() is 'SS' |
ch.isspace() | whitespace | space, \t, \n, \r, \x0b, \x0c and Unicode spaces such as "\xa0" (no-break space) |
| Character | isdecimal() | isdigit() | isnumeric() | int(ch) |
|---|---|---|---|---|
"7" | ✓ | ✓ | ✓ | 7 |
"٣" (Arabic-Indic three) | ✓ | ✓ | ✓ | 3 |
"²" (superscript two) | ✓ | ✓ | ValueError | |
"½" | ✓ | ValueError | ||
"三" | ✓ | ValueError |
isdecimal() is the test that matches what int() accepts; isdigit() lets "²" through.
string. constant | Value |
|---|---|
ascii_lowercase, ascii_uppercase | a to z, A to Z |
ascii_letters | ascii_lowercase + ascii_uppercase |
digits, hexdigits, octdigits | 0-9; plus a-fA-F; 0-7 |
punctuation | ASCII punctuation (!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~) |
whitespace | space, \t, \n, \r, \x0b, \x0c |
printable | digits, letters, punctuation and whitespace |
import string
s = "leetcode"
counts = [0] * 26
for ch in s:
counts[ord(ch) - ord("a")] += 1
first_unique = next(
(ch for ch in s if counts[ord(ch) - ord("a")] == 1), ""
) # 'l'
present = [
chr(ord("a") + i) for i, n in enumerate(counts) if n
] # ['c', 'd', 'e', 'l', 'o', 't']
string.ascii_lowercase.index("e") # 4
def caesar(s: str, k: int) -> str:
a = ord("a")
return "".join(chr((ord(c) - a + k) % 26 + a) for c in s)
caesar("xyz", 3) # 'abc'Indexing & slicing
s[start:stop:step]: stop is excluded, negatives count from the end, out-of-range slices clamp
instead of raising.
| Expression | "python" gives | JS equivalent |
|---|---|---|
s[0], s[-1] | 'p', 'n' | s[0], s.at(-1) |
s[1:4] | 'yth' | s.slice(1, 4) |
s[:2], s[2:] | 'py', 'thon' | s.slice(0, 2), s.slice(2) |
s[-3:] | 'hon' | s.slice(-3) |
s[::2] | 'pto' | none |
s[::-1] | 'nohtyp' | [...s].reverse().join("") |
s[10:] | '' | s.slice(10) |
s[10] | IndexError | undefined |
1/6
Indexes name the cuts between characters. s[1:4] starts at cut 1 and stops at cut 4, so it is 'yth'.
Every slice is a new string, so s[i:j] costs O(j − i) time and memory. Inside loops, compare in
place instead of slicing:
s = "hello world"
s.startswith("world", 6) # True: no copy of s[6:]
s.endswith("lo", 0, 5) # True: looks at s[0:5]
s[6:11] == "world" # True, but builds 'world' first
def is_pal(s: str, i: int, j: int) -> bool:
while i < j: # indexes, not s[i:j] copies
if s[i] != s[j]:
return False
i, j = i + 1, j - 1
return True
is_pal("xracecarx", 1, 7) # TrueMethods
| Method | Does | Example → result |
|---|---|---|
split(sep=None, maxsplit=-1) | split; no sep = runs of whitespace, trimmed | " a b ".split() → ['a', 'b'] |
split(" ") | split on each single space | "a b".split(" ") → ['a', '', 'b'] |
rsplit(sep, 1) | split from the right | "a.b.c".rsplit(".", 1) → ['a.b', 'c'] |
splitlines() | split on \n, \r\n, etc. | "a\nb".splitlines() → ['a', 'b'] |
partition(sep) | split once into 3 parts | "k=v=w".partition("=") → ('k', '=', 'v=w') |
sep.join(xs) | join str items | "-".join("abc") → 'a-b-c' |
strip(), lstrip(), rstrip() | trim whitespace, or any chars in the argument | "xxhixx".strip("x") → 'hi' |
removeprefix(p), removesuffix(s) | remove an exact affix once | "v1.2".removeprefix("v") → '1.2' |
startswith(x, start), endswith(x) | also accept a tuple | f.endswith((".jpg", ".png")) |
find(x), rfind(x) | index or -1 | "abc".find("z") → -1 |
index(x), rindex(x) | index or ValueError | |
count(x) | non-overlapping occurrences | "aaa".count("aa") → 1 |
replace(old, new, count=-1) | replace all or first count | "a-b-c".replace("-", "+", count=1) → 'a+b-c' |
lower(), upper() | case mapping | "straße".upper() → 'STRASSE' |
casefold() | aggressive lower for comparisons | "Straße".casefold() → 'strasse' |
title(), capitalize(), swapcase() | word / first-letter case | "they're".title() → "They'Re" (naive) |
center(w, c), ljust(w), rjust(w) | pad to width | "x".center(5, "-") → '--x--' |
zfill(w) | zero-pad, sign-aware | "-42".zfill(5) → '-0042' |
expandtabs(4) | tabs to spaces | |
translate(table) | map/delete chars via str.maketrans | "abc".translate(str.maketrans("ab", "xy", "c")) → 'xy' |
encode("utf-8") | str → bytes | "é".encode() → b'\xc3\xa9' |
format(...), format_map(d) | legacy formatting | see below |
| Predicate | True when |
|---|---|
isdecimal(), isdigit(), isnumeric() | digits, narrowest to widest: see Characters & code points |
isalpha(), isalnum() | letters (any script), letters or digits |
isspace(), isupper(), islower(), istitle() | whitespace / case checks; empty string is False |
isascii() | all code points below 128 (empty is True) |
isidentifier(), isprintable() | valid Python name; no control chars |
"".split() is [] but "".split(",") is [''], and "a,,b".split(",") keeps the empty middle
field. int(s) is the real validity test for numbers: it accepts " 42 ", "-7", "1_000" and
other scripts' digits, and raises ValueError otherwise.
textwrap. | Does |
|---|---|
dedent(s) | remove common leading indentation (triple-quoted blocks) |
indent(s, "> ") | prefix every non-blank line |
fill(s, width=70), wrap(s, width=70) | re-wrap into one string, or a list of lines |
shorten(s, 15, placeholder="…") | collapse whitespace and truncate on a word boundary |
Searching & comparing
| Expression | Returns | Notes |
|---|---|---|
t in s | bool | substring test |
s.find(t, start, end), s.rfind(t) | index or -1 | start and end search inside s[start:end] without copying it |
s.index(t), s.rindex(t) | index | ValueError: substring not found |
s.count(t) | non-overlapping count | overlapping: len(re.findall("(?=aa)", "aaa")) is 2 |
s.startswith(t, i) | bool | is t at offset i; a tuple tests several |
s == t | bool | O(1) when the lengths differ, else up to the first difference |
s < t | bool | lexicographic by code point: "Z" < "a", "apple" < "apples" |
sorted(s) | list of characters | "".join(sorted(s)) for a sorted string |
min(s), max(s) | smallest / largest character | min("hello") is 'e' |
s is t | identity | never for equality: whether equal strings share one object (interning) is an implementation detail |
in, find, index, count, split and replace are linear in practice. Since 3.10 CPython
sometimes switches to Crochemore & Perrin's Two-Way algorithm to avoid quadratic behavior on long
strings (What's New in 3.10 (opens in a new tab)).
Hand-written KMP or a rolling hash (see Recipes) pays off when you need every overlapping
match, or when you compare many substrings of one length.
import re
"banana".find("an"), "banana".rfind("an") # (1, 3)
"banana".find("an", 2) # 3: searching s[2:]
"banana".count("ana") # 1: non-overlapping
len(re.findall("(?=ana)", "banana")) # 2: overlapping
order = "hlabcdefgijkmnopqrstuvwxyz" # an alien alphabet
rank = {ch: i for i, ch in enumerate(order)}
words = ["hello", "leetcode"]
words == sorted(words, key=lambda w: [rank[c] for c in w])
# True
sorted(["b", "a", "B"]) # ['B', 'a', 'b']: code points
sorted(["b", "a", "B"], key=str.casefold) # ['a', 'b', 'B']
a = "hello world"
b = "".join(["hello", " world"])
a == b, a is b # (True, False): compare with ==Building strings & costs
Strings are immutable, so s += piece creates a new string each time. CPython can sometimes extend
s in place when nothing else refers to it, but
PEP 8 (opens in a new tab) calls that optimization
"fragile even in CPython" and absent elsewhere. Collect pieces in a list and "".join them once, or
write to an io.StringIO.
import io
parts: list[str] = []
for i in range(3):
parts.append(f"<{i}>")
"".join(parts) # '<0><1><2>'
buf = io.StringIO()
for word in ["a", "b"]:
buf.write(word.upper())
buf.getvalue() # 'AB'
", ".join(str(n) for n in range(3)) # '0, 1, 2'| Operation | Cost | Notes |
|---|---|---|
s[i], len(s) | O(1) | fixed-width storage keeps indexing O(1), even with emoji |
s + t | O(len(s) + len(t)) | always a new string |
s[i:j] | O(j − i) | copies |
s[::-1], "".join(reversed(s)) | O(n) | |
"".join(parts) | O(total length) | one allocation |
t in s, find, index, count | O(n) in practice | Two-Way guards long strings against quadratic cases (3.10+) |
split, replace, strip, upper, ... | O(n) | each returns new strings |
s == t | O(n) | O(1) when the lengths differ |
hash(s) | O(n) the first time | cached on the string afterward, so later dict and set lookups skip it |
sorted(s) | O(n log n) | returns a list; a 26-slot count is O(n) |
list(s), set(s), Counter(s) | O(n) |
import sys
sys.getsizeof("a" * 100) # 141: 1 byte per char
sys.getsizeof("€" * 100) # 258: 2 bytes per char
sys.getsizeof("😀" * 100) # 460: 4 bytes per char
sys.getsizeof("a" * 99 + "😀") # 460: the widest char winsCPython stores each string at 1, 2 or 4 bytes per character, chosen by its widest character (PEP 393 (opens in a new tab)): one emoji in otherwise ASCII text quadruples it.
DS&A idioms
The string moves that problem solutions are built from, and the trap in each. The patterns themselves, with TypeScript and JavaScript beside Python, are in the DS&A sheets (linked in the last column).
| Job | Python | Watch out | More |
|---|---|---|---|
| Char ↔ slot | ord(ch) - ord("a"), chr(ord("a") + i) | assumes lowercase ASCII: read the constraints | Strings |
| Count characters | cnt = [0] * 26, or Counter(s) | the list is lighter for a to z; Counter takes any alphabet and Counter(a) == Counter(b) compares | Counter |
| Anagram key | "".join(sorted(w)) (O(k log k)) or tuple(cnt) (O(k)) | a list can't be a dict key: use the tuple | Group anagrams |
| Edit characters | cs = list(s), cs[i] = c, "".join(cs) | s[i] = c raises TypeError | |
| Build output | parts.append(p), then "".join(parts) | res += p in a loop risks O(n²) | |
| Reverse | s[::-1]; words: " ".join(reversed(s.split())) | s.split(" ") keeps empty strings between repeated spaces | |
| Palindrome | s == s[::-1], or two pointers i, j | the slice costs O(n) memory; two pointers can skip punctuation in O(1) space | Two pointers |
| Match at an offset | s.startswith(w, i) | s[i:i + len(w)] == w builds a copy first | Word Break |
| Longest window | last[ch] = i; on a repeat, lo = max(lo, last[ch] + 1) | store indexes, not substrings | Sliding window |
| Window with counts | need = Counter(t), add the entering char, remove the leaving one | track how many counts are satisfied instead of comparing whole counters each step | Minimum Window Substring |
| Custom alphabet | rank = {c: i for i, c in enumerate(order)}, key=lambda w: [rank[c] for c in w] | Custom order | |
| Digits and binary | int(s), int(s, 2), format(n, "b"), s.zfill(k) | int("") and int("1.5") raise ValueError | Numbers |
| Tokenize | re.findall(r"\d+|[-+*/()]", expr) | s.split() can't separate 12+3 | Stacks |
| Brackets | a stack of openers and pairs = {")": "(", "]": "[", "}": "{"} | check the stack is empty at the end | Balanced brackets |
| Memo over a string | @cache on f(i) (and j) indexes | f(s[i:]) copies and hashes each suffix: O(n) per call | Top-down DP |
| Many same-length substrings | a set of slices, or a rolling hash | each slice is O(k) to build and hash; a rolling hash updates in O(1) | Recipes |
| Prefix lookups | a trie of nested dicts | startswith against every word is O(words × k) | Tries |
| Counts and sets of chars | Counter, set(s), dict of last index | Dictionaries |
from collections import Counter, defaultdict
def longest_unique(s: str) -> int:
last: dict[str, int] = {}
lo = best = 0
for i, ch in enumerate(s):
if last.get(ch, -1) >= lo:
lo = last[ch] + 1 # jump past the repeat
last[ch] = i
best = max(best, i - lo + 1)
return best
print(longest_unique("abcabcbb")) # 3
def anagram_groups(words: list[str]) -> list[list[str]]:
groups: defaultdict[tuple[int, ...], list[str]]
groups = defaultdict(list)
for w in words:
cnt = [0] * 26
for ch in w:
cnt[ord(ch) - ord("a")] += 1
groups[tuple(cnt)].append(w)
return list(groups.values())
print(anagram_groups(["eat", "tea", "tan", "ate", "nat"]))
# [['eat', 'tea', 'ate'], ['tan', 'nat']]
Counter("anagram") == Counter("nagaram") # True
def word_break(s: str, words: list[str]) -> bool:
ok = [True] + [False] * len(s) # ok[i]: s[:i] splits
for i in range(len(s)):
if ok[i]:
for w in words:
if s.startswith(w, i): # no slicing
ok[i + len(w)] = True
return ok[-1]
print(word_break("leetcode", ["leet", "code"])) # True1/8
i = 0: 'a' is new, so the window is s[0:1] = 'a' and best is 1.
Regular expressions
import re
LINE = re.compile(
r"""
(?P<ts>\d{4}-\d{2}-\d{2}T[\d:]+Z) \s+ # timestamp
(?P<level>INFO|WARN|ERROR) \s+ # level
(?P<msg>.*) # message
""",
re.VERBOSE,
)
if m := LINE.match("2026-09-25T10:00:00Z ERROR disk full"):
print(m["level"], m.group("msg")) # ERROR disk full
print(m.groupdict()["ts"], m.span("msg"))
print(re.findall(r"(\w)=(\d)", "a=1 b=2"))
# [('a', '1'), ('b', '2')]: tuples of groups
print(re.split(r"[,;]\s*", "a, b;c")) # ['a', 'b', 'c']
print(re.sub(r"(?P<user>\w+)@", r"\g<user> at ", "bob@x"))
prices = "apple 1.50, pear 2.25"
doubled = re.sub(
r"\d+\.\d+",
lambda m: f"{float(m[0]) * 2:.2f}", # fn replacement
prices,
flags=re.ASCII, # count/flags: keyword only
)| Function | Returns |
|---|---|
re.compile(p, flags) | Pattern; module functions cache, so compile for reuse and naming |
match(p, s) | Match | None, anchored at the start only |
fullmatch(p, s) | Match | None, whole string (use for validation) |
search(p, s) | first match anywhere |
findall(p, s) | list of strings, or tuples when the pattern has several groups |
finditer(p, s) | iterator of Match objects (positions + named groups) |
sub(p, repl, s, count=0) | new string; repl may be a function of Match |
subn(...) | (new_string, n_replacements) |
split(p, s, maxsplit=0) | list; capturing groups are kept in the result |
re.escape(s) | escape user input for use inside a pattern |
Match | Gives |
|---|---|
m[0], m.group() | whole match |
m[1], m["name"] | a group (None if it did not participate) |
m.groups(), m.groupdict() | all groups as tuple / dict |
m.start(), m.end(), m.span(g) | positions |
| Syntax | Meaning |
|---|---|
(?P<name>...) | named group; JS's (?<name>...) is a syntax error |
(?P=name), \1 | backreference in the pattern |
\g<name>, \g<1> | group in a sub replacement (JS: $<name>, $1) |
(?:...) | non-capturing group |
(?=...), (?!...), (?<=...), (?<!...) | lookarounds; lookbehind must be fixed width |
(?>...), a*+, a++ | atomic group, possessive quantifiers (3.11+) |
*?, +?, ?? | lazy quantifiers |
\A, \z / \Z | start / end of string (\z added 3.14) |
\b, \d, \w, \s | Unicode-aware by default on str patterns |
(?i), (?x) | inline flags at the pattern start |
| Flag | Short | Effect |
|---|---|---|
re.IGNORECASE | re.I | case-insensitive |
re.MULTILINE | re.M | ^/$ match at each line |
re.DOTALL | re.S | . matches \n too |
re.VERBOSE | re.X | whitespace ignored, # comments allowed |
re.ASCII | re.A | \d, \w, \b ASCII only |
Combine with |: re.I | re.M. Bad patterns raise re.PatternError (3.13+, alias re.error).
Use r"..." for every pattern. \d matches any Unicode decimal digit; add re.ASCII when input
must be 0-9.
f-strings
Since 3.12 (PEP 701) the expression part is full Python: reuse the outer quotes, use backslashes,
span lines and add # comments.
from datetime import UTC, datetime
name, n, price = "Ada", 3, 1234.5
user = {"name": "ann"}
now = datetime(2026, 9, 25, 14, 30, tzinfo=UTC)
print(f"{name} has {n} item{'s' if n != 1 else ''}")
print(f"{user["name"]}") # same quotes (3.12+)
print(f"{'\n'.join(['a', 'b'])}") # backslash (3.12+)
print(f"{price:,.2f}") # 1,234.50
print(f"{now:%Y-%m-%d %H:%M}") # 2026-09-25 14:30
print(f"{name!r}") # 'Ada': repr()
print(f"{n=}") # n=3: self-documenting
print(f"{price = :.1f}") # price = 1234.5
print(f"{{literal braces}}") # {literal braces}
width = 8
print(f"[{name:>{width}}]") # nested spec: [ Ada]
print(f"""{
n * price # comments allowed inside
}""")| Part | Meaning |
|---|---|
{expr} | format(expr, ""), which is usually str(expr) |
{expr!r}, {expr!s}, {expr!a} | repr(), str(), ascii() before formatting |
{expr:spec} | format spec (next section); a datetime takes strftime codes |
{expr=} | prints expr= then the repr; with a spec, the formatted value |
{expr:{w}.{p}f} | nested fields fill in the spec |
{{, }} | literal braces |
Format spec mini-language
[[fill]align][sign][z][#][0][width][grouping][.precision[grouping]][type]. It is shared by
f-strings, format() and str.format.
| Spec | Input | Output | Meaning |
|---|---|---|---|
>8, <8, ^8 | "hi" | ' hi', 'hi ', ' hi ' | right, left, center in width 8 |
*^10 | "hi" | '****hi****' | fill character |
=+6 | -3 | '- 3' | pad after the sign |
+, (space) | 3 | '+3', ' 3' | always show sign; space for positives |
05d, 08.3f | 42, 3.14159 | '00042', '0003.142' | zero-pad |
,d, _d | 1234567 | '1,234,567', '1_234_567' | thousands separator |
,.4_f | 1234.56789 | '1,234.567_9' | group the fraction too (3.14) |
.2f | 2.675 | '2.67' | fixed decimals (binary float!) |
.3g | 3.14159 | '3.14' | significant figures |
.2e | 0.0000123 | '1.23e-05' | scientific |
.1% | 0.256 | '25.6%' | multiply by 100 |
z.2f | -0.0001 | '0.00' | no negative zero |
x, X, o, b | 255 | 'ff', 'FF', '377', '11111111' | bases |
#x, #010b | 255, 42 | '0xff', '0b00101010' | with prefix; 0 pads after it |
c | 65 | 'A' | code point to char |
n | 1234 | locale-aware | uses locale settings |
Numbers default right-aligned, strings left-aligned. Custom classes hook in via __format__.
t-strings (3.14)
t"..." (PEP 750) builds a string.templatelib.Template holding the literal parts and the
evaluated values separately, so a library can escape or parameterize them. It is not a str:
str(t) gives its repr, and t + "x" is a TypeError (t1 + t2 works).
Template / Interpolation | Content |
|---|---|
iter(t) | str and Interpolation items in order, empty strings skipped |
t.strings | tuple of static parts, always one more than interpolations |
t.interpolations, t.values | the Interpolations; just their values |
i.value | evaluated object (already computed, not lazy) |
i.expression | source text, e.g. "user.name" |
i.conversion | "r", "s", "a" or None |
i.format_spec | text after :, e.g. ".2f" |
convert(v, conv) | apply !r/!s/!a (from string.templatelib) |
from html import escape
from string.templatelib import (
Interpolation,
Template,
convert,
)
def html(t: Template) -> str:
out: list[str] = []
for item in t:
match item:
case str() as text:
out.append(text) # trusted literal
case Interpolation(value, _, conv, spec):
v = format(convert(value, conv), spec)
out.append(escape(v)) # untrusted value
return "".join(out)
def sql(t: Template) -> tuple[str, list[object]]:
query = "?".join(t.strings)
return query, list(t.values)
evil = "<script>alert(1)</script>"
print(html(t"<p>Hi {evil}!</p>"))
# <p>Hi <script>alert(1)</script>!</p>
print(sql(t"SELECT * FROM users WHERE id = {42}"))
# ('SELECT * FROM users WHERE id = ?', [42])Like JS tagged templates (html`...`), but the processing function is called explicitly.
Legacy formatting
| Style | Example | Use |
|---|---|---|
| f-string | f"{name} is {age}" | default choice |
str.format | "{} is {}".format(name, age), "{0}{0}".format("a") | a template string stored as data |
format_map | "{name}".format_map(row) | template filled from a dict |
% | "%s is %d, %.2f" % (name, age, x), "%(n)s" % {"n": 1} | old code; logging messages |
string.Template | Template("$who paid $$$amt").substitute(who="ann", amt=5) | user-editable templates; safe_substitute leaves unknown $x |
format(x, spec) | format(0.5, ".0%") → '50%' | format one value |
logging takes %-style arguments so it formats only records it emits:
log.info("user %s logged in", user_id), not an f-string.
Bytes & encodings
str is text (code points); bytes is raw octets. Convert at the edges: decode on input, encode on
output. Always pass encoding=: until UTF-8 mode becomes the default in 3.15 (PEP 686), open()
without it uses the locale encoding, which is not UTF-8 on many Windows setups.
import tempfile
from pathlib import Path
data = "héllo €".encode("utf-8")
# b'h\xc3\xa9llo \xe2\x82\xac'
text = data.decode("utf-8")
len("€"), len("€".encode()) # (1, 3)
b"\xff".decode("utf-8", errors="replace") # '�'
"é".encode("ascii", errors="ignore") # b''
"é".encode("ascii", errors="backslashreplace") # b'\\xe9'
b"ab"[0], list(b"ab") # (97, [97, 98]): ints
bytes.fromhex("dead").hex(":") # 'de:ad'
bytearray(b"abc").upper() # mutable variant
with tempfile.TemporaryDirectory() as tmp:
path = Path(tmp) / "notes.txt"
path.write_text(text, encoding="utf-8")
path.read_text(encoding="utf-8") == text # True| Error handler | On a bad char |
|---|---|
strict (default) | raise UnicodeDecodeError / UnicodeEncodeError |
replace | ? when encoding, U+FFFD when decoding |
ignore | drop it |
backslashreplace | \xe9 escape |
xmlcharrefreplace | é (encode only) |
surrogateescape | round-trip undecodable bytes (file names) |
base64.b64encode(b), b.hex(), int.from_bytes(b, "big") and struct.pack cover the other
bytes ↔ text conversions. uv run python -X utf8 app.py forces UTF-8 mode.
Unicode
| Task | Tool |
|---|---|
| Case-insensitive compare | a.casefold() == b.casefold() ("ß" → "ss") |
Same text, different code points ("\u00e9" vs "e\u0301", both shown as é) | unicodedata.normalize("NFC", s) before compare/store |
Compatibility folding ("fi" → "fi", "①" → "1") | normalize("NFKC", s) |
| Strip accents | "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") |
| Char metadata | unicodedata.name("€"), category("é") ('Ll'), numeric("½") |
| Check form | unicodedata.is_normalized("NFC", s) |
| User-perceived characters | len counts code points: len("👍🏽") == 2; use the regex package's \X for graphemes |
| Sort by locale | locale.strxfrm or PyICU; plain sorted sorts by code point |
| Form | Does |
|---|---|
NFC | compose (e + accent → é); the web/macOS-neutral default |
NFD | decompose into base + combining marks |
NFKC, NFKD | also fold compatibility chars (ligatures, width, superscripts); lossy |
Coming from JavaScript
The TypeScript version of this sheet is Strings.
JS String | Python str |
|---|---|
s.length | len(s): code points, so len("😀") is 1 (JS counts 2 UTF-16 units) |
s[i], s.charAt(i), s.at(-1) | s[i], s[-1]: out of range raises IndexError instead of undefined / "" |
s.charCodeAt(i), s.codePointAt(i) | ord(s[i]): always the whole code point |
String.fromCharCode(c), String.fromCodePoint(c) | chr(c) |
s.slice(a, b) | s[a:b] |
s.substring(a, b) | s[min(a, b):max(a, b)]: substring swaps reversed arguments, a slice returns '' |
s.indexOf(t), s.lastIndexOf(t) | s.find(t), s.rfind(t) (-1); s.index(t) raises instead |
s.includes(t) | t in s |
s.startsWith(t, i), s.endsWith(t) | s.startswith(t, i), s.endswith(t) |
s.split(""), [...s] | list(s); s.split("") raises ValueError: empty separator |
s.trim(), s.trimStart(), s.trimEnd() | s.strip(), s.lstrip(), s.rstrip() |
s.padStart(5, "0"), s.padEnd(5) | s.rjust(5, "0") (or sign-aware s.zfill(5)), s.ljust(5) |
s.repeat(3) | s * 3 |
s.replace("a", "b") | s.replace("a", "b", 1): Python's replace changes every match by default |
s.replaceAll("a", "b") | s.replace("a", "b") |
s.toUpperCase(), s.toLowerCase() | s.upper(), s.lower(); s.casefold() for comparisons |
a.localeCompare(b) | locale.strcoll(a, b); sort with key=locale.strxfrm |
`${a} and ${b}` | f"{a} and {b}" |
s.match(re), s.matchAll(re), re.test(s) | re.search(p, s), re.finditer(p, s), re.search(p, s) is not None |
Number(s), parseInt(s, 10) | int(s), float(s): no partial parse, so int("12px") raises |
String(n), n.toString(2) | str(n), format(n, "b") |
s.normalize("NFC") | unicodedata.normalize("NFC", s) |
[...s].reverse().join("") | s[::-1] |
s + 1 | TypeError: no coercion; s + str(1) or an f-string |
Recipes
Palindromes
When checking a phrase while ignoring case and punctuation, or finding the longest palindromic substring.
def is_palindrome(s: str) -> bool:
i, j = 0, len(s) - 1
while i < j:
if not s[i].isalnum():
i += 1
elif not s[j].isalnum():
j -= 1
elif s[i].lower() != s[j].lower():
return False
else:
i, j = i + 1, j - 1
return True
def longest_palindrome(s: str) -> str:
best = (0, 0) # [lo, hi) of the longest so far
for center in range(2 * len(s) - 1):
lo = center // 2
hi = lo + center % 2 # odd length: hi == lo
while lo >= 0 and hi < len(s) and s[lo] == s[hi]:
lo, hi = lo - 1, hi + 1
if hi - lo - 1 > best[1] - best[0]:
best = (lo + 1, hi)
return s[best[0] : best[1]]
phrase = "A man, a plan, a canal: Panama"
print(is_palindrome(phrase)) # True
print(longest_palindrome("babad")) # babExpanding around all 2n − 1 centers is O(n²) time and O(1) space; Manacher's algorithm does it in
O(n).
Anagram keys and counts
When checking two words, or finding every anagram of p inside s with a fixed-size window.
from collections import Counter
def is_anagram(a: str, b: str) -> bool:
return len(a) == len(b) and Counter(a) == Counter(b)
def anagram_starts(s: str, p: str) -> list[int]:
k, need = len(p), Counter(p)
have = Counter(s[:k])
out = [0] if have == need else []
for i in range(k, len(s)):
have[s[i]] += 1 # char entering the window
have[s[i - k]] -= 1 # char leaving it
if have == need: # zero counts compare as absent
out.append(i - k + 1)
return out
print(is_anagram("listen", "silent")) # True
print(anagram_starts("cbaebabacd", "abc")) # [0, 6]Since 3.10, Counter equality treats missing and zero counts alike, so the decremented keys don't
need deleting.
Is subsequence
When s must appear in t in order, not necessarily contiguously.
def is_subsequence(s: str, t: str) -> bool:
it = iter(t)
return all(ch in it for ch in s) # `in` consumes it
print(is_subsequence("ace", "abcde")) # True
print(is_subsequence("aec", "abcde")) # Falsech in it advances the shared iterator up to the match, so the whole check is one O(len(t)) pass.
For many s against one t, store each character's positions and bisect into them.
Longest common prefix
When finding the shared start of several strings.
import os
def common_prefix(words: list[str]) -> str:
if not words:
return ""
lo, hi = min(words), max(words) # the extremes
i = 0
while i < len(lo) and lo[i] == hi[i]:
i += 1
return lo[:i]
words = ["flower", "flow", "flight"]
print(common_prefix(words)) # fl
print(os.path.commonprefix(words)) # flThe common prefix of the alphabetically smallest and largest strings is shared by every string
between them. os.path.commonprefix compares character by character, so it works on any strings.
Every match: KMP
When you need every occurrence of a pattern, overlaps included, in O(n + m).
def prefix_function(p: str) -> list[int]:
pi = [0] * len(p)
k = 0
for i in range(1, len(p)):
while k and p[i] != p[k]:
k = pi[k - 1] # fall back to a shorter border
if p[i] == p[k]:
k += 1
pi[i] = k
return pi
def find_all(text: str, p: str) -> list[int]:
if not p:
return list(range(len(text) + 1))
pi = prefix_function(p)
out: list[int] = []
k = 0
for i, ch in enumerate(text):
while k and ch != p[k]:
k = pi[k - 1]
if ch == p[k]:
k += 1
if k == len(p):
out.append(i - k + 1)
k = pi[k - 1] # keep going: overlaps count
return out
print(prefix_function("abab")) # [0, 0, 1, 2]
print(find_all("aaaa", "aa")) # [0, 1, 2]pi[i] is the length of the longest proper prefix of p[: i + 1] that is also its suffix. The same
table answers "shortest repeating unit": n - pi[-1], when it divides n.
1/7
pi[0] is always 0: a single character has no proper prefix.
Rolling hash (Rabin–Karp)
When comparing every substring of length k: repeated DNA sequences, or longest duplicate substring
with a binary search on k.
def repeated(s: str, k: int) -> set[str]:
"""Length-k substrings that occur more than once."""
if not 0 < k <= len(s):
return set()
base, mod = 131, (1 << 61) - 1
top = pow(base, k - 1, mod) # weight of the oldest char
seen: dict[int, list[int]] = {} # hash -> start indexes
out: set[str] = set()
h = 0
for i, ch in enumerate(s):
if i >= k: # drop s[i - k] from the window
h = (h - ord(s[i - k]) * top) % mod
h = (h * base + ord(ch)) % mod
if i < k - 1:
continue
start = i - k + 1
bucket = seen.setdefault(h, [])
for j in bucket: # compare only on a hash hit
if s[j : j + k] == s[start : i + 1]:
out.add(s[j : j + k])
break
else:
bucket.append(start)
return out
dna = "AAAAACCCCCAAAAACCCCCCAAAAAGGGTTT"
print(sorted(repeated(dna, 10)))
# ['AAAAACCCCC', 'CCCCCAAAAA']Each step updates the hash in O(1); characters are compared only when two hashes agree, so a
collision can never produce a wrong answer. For short windows a plain set of slices is simpler and,
in CPython, faster, because slicing and hashing run in C: about 3× faster at k = 10 on 100,000
characters. The rolling hash wins once k reaches the hundreds (8× faster at k = 5000), since
each slice costs O(k).
Decode k[...] with a stack
When expanding nested repeat counts such as 3[a2[c]].
def decode(s: str) -> str:
stack: list[tuple[list[str], int]] = []
cur: list[str] = []
k = 0
for ch in s:
if ch.isdecimal():
k = k * 10 + int(ch) # counts can be multi-digit
elif ch == "[":
stack.append((cur, k)) # save the outer state
cur, k = [], 0
elif ch == "]":
outer, times = stack.pop()
outer.append("".join(cur) * times)
cur = outer
else:
cur.append(ch)
return "".join(cur)
print(decode("3[a2[c]]")) # accaccacc
print(decode("2[abc]3[cd]ef")) # abcabccdcdcdefSlugify
When turning a title into a URL segment.
import re
import unicodedata
def slugify(text: str, sep: str = "-") -> str:
norm = unicodedata.normalize("NFKD", text)
ascii_only = norm.encode("ascii", "ignore").decode()
words = re.findall(r"[a-z0-9]+", ascii_only.lower())
return sep.join(words)
print(slugify("Crème Brûlée: 10 Tips!"))
# creme-brulee-10-tipsCase conversions
When mapping JSON camelCase keys to Python snake_case and back.
import re
_WORDS = re.compile(r"[A-Z]?[a-z0-9]+|[A-Z]+(?![a-z])")
def words(s: str) -> list[str]:
return [w.lower() for w in _WORDS.findall(s)]
def snake(s: str) -> str:
return "_".join(words(s))
def kebab(s: str) -> str:
return "-".join(words(s))
def camel(s: str) -> str:
first, *rest = words(s) or [""]
return first + "".join(w.capitalize() for w in rest)
print(snake("parseHTTPResponse2"), kebab("user_id"))
# parse_http_response2 user-id
print(camel("created-at_utc")) # createdAtUtcParse key=value lines
When reading .env-style or ini-like text without a library.
def parse_kv(text: str) -> dict[str, str]:
out: dict[str, str] = {}
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
key, sep, value = line.partition("=")
if not sep:
raise ValueError(f"no '=' in {raw!r}")
out[key.strip()] = value.strip().strip("\"'")
return out
print(parse_kv('# db\nHOST = localhost\nNAME="app"\n'))
# {'HOST': 'localhost', 'NAME': 'app'}Aligned table output
When printing rows to a terminal without rich or tabulate.
rows = [("apple", 3, 1.5), ("kiwi", 12, 0.25)]
head = ("item", "qty", "price")
w = max(len(r[0]) for r in [*rows, head])
print(f"{head[0]:<{w}} {head[1]:>4} {head[2]:>7}")
print(f"{'':-<{w}} {'':->4} {'':->7}")
for name, qty, price in rows:
print(f"{name:<{w}} {qty:>4} {price:>7,.2f}")item qty price
----- ---- -------
apple 3 1.50
kiwi 12 0.25References
- Python docs: Text sequence type (str) (opens in a new tab): every
strmethod - Python docs: string (opens in a new tab): format spec mini-language,
Template, constants - Python docs: string.templatelib (opens in a new tab): t-string
TemplateandInterpolation - Python docs: Lexical analysis, f-strings (opens in a new tab): literal and escape grammar
- Python docs: re (opens in a new tab): regex syntax, flags,
Match - Python docs: Regular expression HOWTO (opens in a new tab): longer guide
- Python docs: Unicode HOWTO (opens in a new tab): code points, encodings, normalization
- Python docs: codecs error handlers (opens in a new tab):
replace,surrogateescape, ... - Python docs: textwrap (opens in a new tab) and unicodedata (opens in a new tab)
- What's New in Python 3.10: Optimizations (opens in a new tab): the Two-Way substring search
- PEP 8: Programming recommendations (opens in a new tab): don't rely on in-place
+=for strings - PEP 393: Flexible string representation (opens in a new tab): 1, 2 or 4 bytes per character
- PEP 750: Template strings (opens in a new tab): t-string rationale and examples
- PEP 701: Syntactic formalization of f-strings (opens in a new tab): the 3.12 grammar changes
- cp-algorithms: Prefix function, KMP (opens in a new tab) and String hashing (opens in a new tab): the theory behind the KMP and rolling-hash recipes