../

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

LiteralMeaning
"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

ExpressionResultNotes
ord("a"), chr(97)97, 'a'code point ↔ 1-character string
ord(ch) - ord("a")0 to 25 for a to zthe slot in a 26-item count list
chr(ord("a") + i)the i-th lowercase letterback 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 valueany Unicode decimal digit: int("٣") is 3; ValueError on "²"
"a" <= ch <= "z"ASCII lowercase testch.islower() and ch.isalpha() also accept "é" and "ß"
ch.isascii() and ch.isalnum()ASCII letter or digitwhat "alphanumeric" means in most problem statements
ch.lower(), ch.upper()case mappingcan change the length: "ß".upper() is 'SS'
ch.isspace()whitespacespace, \t, \n, \r, \x0b, \x0c and Unicode spaces such as "\xa0" (no-break space)
Characterisdecimal()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. constantValue
ascii_lowercase, ascii_uppercasea to z, A to Z
ascii_lettersascii_lowercase + ascii_uppercase
digits, hexdigits, octdigits0-9; plus a-fA-F; 0-7
punctuationASCII punctuation (!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~)
whitespacespace, \t, \n, \r, \x0b, \x0c
printabledigits, 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" givesJS 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]IndexErrorundefined
0 -6 1 -5 2 -4 3 -3 4 -2 5 -1 6 cut from end s = start stop s[1:4] == 'yth' start stop s[-3:] == 'hon' start stop s[::2] == 'pto' s[::-1] == 'nohtyp' start stop (excluded) s[3:0:-1] == 'hty' start 10 clamps to 6 s[10:] == '' p y t h o n

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)  # True

Methods

MethodDoesExample → 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 tuplef.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 formattingsee below
PredicateTrue 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

ExpressionReturnsNotes
t in sboolsubstring test
s.find(t, start, end), s.rfind(t)index or -1start and end search inside s[start:end] without copying it
s.index(t), s.rindex(t)indexValueError: substring not found
s.count(t)non-overlapping countoverlapping: len(re.findall("(?=aa)", "aaa")) is 2
s.startswith(t, i)boolis t at offset i; a tuple tests several
s == tboolO(1) when the lengths differ, else up to the first difference
s < tboollexicographic 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 charactermin("hello") is 'e'
s is tidentitynever 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'
OperationCostNotes
s[i], len(s)O(1)fixed-width storage keeps indexing O(1), even with emoji
s + tO(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, countO(n) in practiceTwo-Way guards long strings against quadratic cases (3.10+)
split, replace, strip, upper, ...O(n)each returns new strings
s == tO(n)O(1) when the lengths differ
hash(s)O(n) the first timecached 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 wins

CPython 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).

JobPythonWatch outMore
Char ↔ slotord(ch) - ord("a"), chr(ord("a") + i)assumes lowercase ASCII: read the constraintsStrings
Count characterscnt = [0] * 26, or Counter(s)the list is lighter for a to z; Counter takes any alphabet and Counter(a) == Counter(b) comparesCounter
Anagram key"".join(sorted(w)) (O(k log k)) or tuple(cnt) (O(k))a list can't be a dict key: use the tupleGroup anagrams
Edit characterscs = list(s), cs[i] = c, "".join(cs)s[i] = c raises TypeError
Build outputparts.append(p), then "".join(parts)res += p in a loop risks O(n²)
Reverses[::-1]; words: " ".join(reversed(s.split()))s.split(" ") keeps empty strings between repeated spaces
Palindromes == s[::-1], or two pointers i, jthe slice costs O(n) memory; two pointers can skip punctuation in O(1) spaceTwo pointers
Match at an offsets.startswith(w, i)s[i:i + len(w)] == w builds a copy firstWord Break
Longest windowlast[ch] = i; on a repeat, lo = max(lo, last[ch] + 1)store indexes, not substringsSliding window
Window with countsneed = Counter(t), add the entering char, remove the leaving onetrack how many counts are satisfied instead of comparing whole counters each stepMinimum Window Substring
Custom alphabetrank = {c: i for i, c in enumerate(order)}, key=lambda w: [rank[c] for c in w]Custom order
Digits and binaryint(s), int(s, 2), format(n, "b"), s.zfill(k)int("") and int("1.5") raise ValueErrorNumbers
Tokenizere.findall(r"\d+|[-+*/()]", expr)s.split() can't separate 12+3Stacks
Bracketsa stack of openers and pairs = {")": "(", "]": "[", "}": "{"}check the stack is empty at the endBalanced brackets
Memo over a string@cache on f(i) (and j) indexesf(s[i:]) copies and hashes each suffix: O(n) per callTop-down DP
Many same-length substringsa set of slices, or a rolling hasheach slice is O(k) to build and hash; a rolling hash updates in O(1)Recipes
Prefix lookupsa trie of nested dictsstartswith against every word is O(words × k)Tries
Counts and sets of charsCounter, set(s), dict of last indexDictionaries
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"]))  # True
0 1 2 3 4 5 6 7 s = lo i last = {'a': 0} window = s[0:1] = 'a' best = 1 lo i last = {'a': 0, 'b': 1} window = s[0:2] = 'ab' best = 2 lo i last = {'a': 0, 'b': 1, 'c': 2} window = s[0:3] = 'abc' best = 3 lo i lo jumps last = {'a': 3, 'b': 1, 'c': 2} window = s[1:4] = 'bca' best = 3 lo i lo jumps last = {'a': 3, 'b': 4, 'c': 2} window = s[2:5] = 'cab' best = 3 lo i lo jumps last = {'a': 3, 'b': 4, 'c': 5} window = s[3:6] = 'abc' best = 3 lo i lo jumps last = {'a': 3, 'b': 6, 'c': 5} window = s[5:7] = 'cb' best = 3 lo i lo jumps last = {'a': 3, 'b': 7, 'c': 5} window = s[7:8] = 'b' best = 3 a b c a b c b b

1/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
)
FunctionReturns
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
MatchGives
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
SyntaxMeaning
(?P<name>...)named group; JS's (?<name>...) is a syntax error
(?P=name), \1backreference 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 / \Zstart / end of string (\z added 3.14)
\b, \d, \w, \sUnicode-aware by default on str patterns
(?i), (?x)inline flags at the pattern start
FlagShortEffect
re.IGNORECASEre.Icase-insensitive
re.MULTILINEre.M^/$ match at each line
re.DOTALLre.S. matches \n too
re.VERBOSEre.Xwhitespace ignored, # comments allowed
re.ASCIIre.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
}""")
PartMeaning
{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.

SpecInputOutputMeaning
>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.3f42, 3.14159'00042', '0003.142'zero-pad
,d, _d1234567'1,234,567', '1_234_567'thousands separator
,.4_f1234.56789'1,234.567_9'group the fraction too (3.14)
.2f2.675'2.67'fixed decimals (binary float!)
.3g3.14159'3.14'significant figures
.2e0.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, b255'ff', 'FF', '377', '11111111'bases
#x, #010b255, 42'0xff', '0b00101010'with prefix; 0 pads after it
c65'A'code point to char
n1234locale-awareuses 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 / InterpolationContent
iter(t)str and Interpolation items in order, empty strings skipped
t.stringstuple of static parts, always one more than interpolations
t.interpolations, t.valuesthe Interpolations; just their values
i.valueevaluated object (already computed, not lazy)
i.expressionsource text, e.g. "user.name"
i.conversion"r", "s", "a" or None
i.format_spectext 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 &lt;script&gt;alert(1)&lt;/script&gt;!</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

StyleExampleUse
f-stringf"{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.TemplateTemplate("$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.

s[i] ord(s[i]) UTF-8 bytes in memory h 104 U+0068 68 1 byte 4 bytes é 233 U+00E9 c3 a9 2 bytes 4 bytes € 8364 U+20AC e2 82 ac 3 bytes 4 bytes 😀 128512 U+1F600 f0 9f 98 80 4 bytes 4 bytes len(s) == 4, len(s.encode()) == 10: UTF-8 uses 1 to 4 bytes in memory, the widest character sets 4 bytes for every character
One str, three sizes: 4 code points, 10 UTF-8 bytes, 4 bytes per character in memory
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 handlerOn a bad char
strict (default)raise UnicodeDecodeError / UnicodeEncodeError
replace? when encoding, U+FFFD when decoding
ignoredrop it
backslashreplace\xe9 escape
xmlcharrefreplace&#233; (encode only)
surrogateescaperound-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

TaskTool
Case-insensitive comparea.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 metadataunicodedata.name("€"), category("é") ('Ll'), numeric("½")
Check formunicodedata.is_normalized("NFC", s)
User-perceived characterslen counts code points: len("👍🏽") == 2; use the regex package's \X for graphemes
Sort by localelocale.strxfrm or PyICU; plain sorted sorts by code point
FormDoes
NFCcompose (e + accent → é); the web/macOS-neutral default
NFDdecompose into base + combining marks
NFKC, NFKDalso fold compatibility chars (ligatures, width, superscripts); lossy

Coming from JavaScript

The TypeScript version of this sheet is Strings.

JS StringPython str
s.lengthlen(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 + 1TypeError: 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"))  # bab

Expanding 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"))  # False

ch 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))  # fl

The 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.

i p = pi = 0 1 2 3 4 5 6 a b a b a c a 0 pi[0] = 0: no proper prefix of 'a' is also its suffix a b a b a c a 0 0 pi[1] = 0: no proper prefix of 'ab' is also its suffix a b a b a c a 0 0 1 pi[2] = 1: 'a' is a prefix and a suffix of 'aba' a b a b a c a 0 0 1 2 pi[3] = 2: 'ab' is a prefix and a suffix of 'abab' a b a b a c a 0 0 1 2 3 pi[4] = 3: 'aba' is a prefix and a suffix of 'ababa' a b a b a c a 0 0 1 2 3 0 pi[5] = 0: no proper prefix of 'ababac' is also its suffix a b a b a c a 0 0 1 2 3 0 1 pi[6] = 1: 'a' is a prefix and a suffix of 'ababaca' prefix same text as a suffix

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"))  # abcabccdcdcdef

Slugify

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-tips

Case 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"))  # createdAtUtc

Parse 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.25

References