Polyglot
Languages Python strings
Python § strings

Strings

Python’s str is an immutable sequence of Unicode code points. The type was unified across the Python 2-to-3 transition: in Python 3, every str is Unicode, and bytes is a separate type for raw byte data. The combination — Unicode by default, explicit byte handling, multiple literal forms — is the substance of Python’s text-handling story. Modern Python’s f-strings (PEP 498, since 3.6) admit compact and readable string formatting; the older %-style and .format() mechanisms remain available.

The str type

A str is an immutable sequence of Unicode code points:

s = "hello"
s[0]                # 'h'
s[1:3]              # 'el'
len(s)              # 5
"e" in s            # True

s + " world"        # "hello world" (a new str; s is unchanged)
s * 3               # "hellohellohello"

Strings are immutable: every “modification” produces a new string. The implication for performance: string concatenation in a loop allocates per iteration. The defence is "".join(...) or a list accumulator:

# Slow:
s = ""
for word in words:
    s = s + word + " "

# Fast:
s = " ".join(words)

Python internally optimises the simple s += part case in CPython since 2.6, but the explicit join is the conventional choice.

Indexing and slicing

s = "hello"
s[0]                # 'h'
s[-1]               # 'o' (negative index from the end)
s[1:4]              # 'ell' (slice)
s[:3]               # 'hel'
s[2:]               # 'llo'
s[::-1]             # 'olleh' (reverse)
s[::2]              # 'hlo' (every other character)

Slicing produces a new string. The slice notation [start:stop:step] is consistent across all sequence types.

String literals

Python admits several literal forms:

Regular literals

s1 = "hello"
s2 = 'hello'         # single quotes are equivalent
s3 = "she said \"hi\""
s4 = 'she said "hi"'  # avoid escaping by using the other quote

# Triple-quoted (multi-line):
multi = """line1
line2
line3"""

The single-quote and double-quote forms are interchangeable; the choice is stylistic. Triple-quoted strings admit embedded newlines without \n escapes.

Escape sequences

"\n"               # newline
"\t"               # tab
"\\"               # literal backslash
"\""               # literal double quote
"\xff"             # byte 0xff (in a string context, character U+00FF)
"é"           # 'é' (4-digit Unicode)
"\U0001f600"       # '😀' (8-digit Unicode)
"\N{GREEK SMALL LETTER ALPHA}"   # 'α' (named Unicode character)
"\0"               # null character

The full list of escapes is in the language reference.

Raw strings

The r prefix turns off escape processing:

path = r"C:\Users\alice"
regex = r"\d{4}-\d{2}-\d{2}"

Raw strings are the conventional choice for regular expressions and Windows file paths — any string with substantial backslash content.

The exception: a raw string cannot end with an odd number of backslashes (r"a\" is a syntax error).

Byte strings

The b prefix produces bytes:

b = b"hello"            # bytes, not str
b[0]                    # 104 (the byte value, an int)

Combinations:

  • rb"…" or br"…" — raw bytes.
  • f"…" — formatted string (treated below).
  • Combinations like rf"…" — raw f-string.

The u"…" prefix (Unicode string) is admitted but redundant in Python 3; every string is already Unicode.

f-strings (formatted string literals)

Python 3.6 introduced f-strings — formatted string literals with embedded expressions:

name = "Alice"
age = 30
greeting = f"Hello, {name}, age {age}"
# "Hello, Alice, age 30"

pi = 3.14159
formatted = f"pi: {pi:.2f}"
# "pi: 3.14"

# Expressions inside the braces:
nums = [1, 2, 3]
summary = f"sum: {sum(nums)}, max: {max(nums)}"

# Nested braces for literal braces:
template = f"{{key}}: {value}"   # literal {key}: value

# Format specifiers:
hex_value = f"{255:#x}"           # "0xff"
padded = f"{42:>10}"              # "        42" (right-aligned, width 10)
percent = f"{0.123:.1%}"          # "12.3%"

The expression inside {} is evaluated at runtime; the result’s __format__ method produces the formatted string. The format specifier (after :) follows the Format Specification Mini-Language (the same one used by str.format() and format()).

Python 3.12 admitted nested expressions in f-strings (the { and } inside the expression no longer need careful escaping in some cases); the mechanism is mature and widely used.

f-strings are the conventional contemporary form for string formatting.

The .format() method

The older formatting mechanism:

"{} is {}".format("alice", 30)        # "alice is 30"
"{0} or {1}, {0}".format("yes", "no")  # "yes or no, yes" (positional)
"{name} is {age}".format(name="alice", age=30)   # "alice is 30" (named)
"{:>10}".format("hi")                 # "        hi" (format spec)
"{:.2f}".format(3.14159)              # "3.14"

The .format() form predates f-strings and is still supported. It is the conventional form for templates loaded from configuration or i18n files (where the format string is not a literal):

template = load_template("greeting")     # "Hello, {name}!"
result = template.format(name="alice")

For new code with literal strings, f-strings are preferable.

The % formatting (legacy)

The oldest formatting mechanism:

"%s is %d" % ("alice", 30)            # "alice is 30"
"%(name)s is %(age)d" % {"name": "alice", "age": 30}
"%.2f" % 3.14159                       # "3.14"

The % form is similar to C’s printf. It is uncommon in modern Python; new code uses f-strings.

String methods

str has a substantial set of methods:

MethodEffect
s.upper(), s.lower()Case conversion
s.title(), s.capitalize(), s.swapcase()Other case conversions
s.strip(chars), s.lstrip(), s.rstrip()Strip whitespace or specified chars
s.startswith(prefix), s.endswith(suffix)Prefix/suffix tests
s.find(sub), s.rfind(sub)Position; -1 if not found
s.index(sub), s.rindex(sub)Position; raises ValueError if not found
s.count(sub)Count of non-overlapping occurrences
s.replace(old, new, count)Replace
s.split(sep), s.rsplit(sep, maxsplit)Split into list
s.splitlines(keepends)Split on line boundaries
s.join(iterable)Join an iterable of strings
s.partition(sep), s.rpartition(sep)Split into three parts
s.center(width, fill), s.ljust(width), s.rjust(width)Padding
s.zfill(width)Pad with zeros (for numbers as strings)
s.translate(table)Apply a translation table
s.encode(encoding)Convert to bytes
s.format(*args, **kwargs)Format
s.isdigit(), s.isalpha(), s.isalnum(), s.isspace()Predicates
s.isupper(), s.islower(), s.istitle()Case predicates

The full list is in the standard library; the conventional ones are split, join, replace, strip, format, and the predicates.

# Tokenise:
words = "alice bob carol".split()         # ['alice', 'bob', 'carol']
fields = "a=1,b=2,c=3".split(",")          # ['a=1', 'b=2', 'c=3']

# Join:
csv = ",".join(["a", "b", "c"])           # "a,b,c"
sentence = " ".join(words)

# Replace:
fixed = "hello world".replace("world", "python")

# Strip:
clean = "  hello  ".strip()                # "hello"

# Predicates:
"123".isdigit()                            # True
"hello".isalpha()                          # True

Encoding and decoding

str is Unicode; bytes is raw bytes. Conversion requires an explicit charset:

# str → bytes:
"hello".encode("utf-8")                  # b'hello'
"héllo".encode("utf-8")                  # b'h\xc3\xa9llo'
"hello".encode("ascii")                  # b'hello'
"héllo".encode("ascii")                  # UnicodeEncodeError

# bytes → str:
b"hello".decode("utf-8")                 # 'hello'
b"h\xc3\xa9llo".decode("utf-8")          # 'héllo'
b"\xff\xfe".decode("utf-8")              # UnicodeDecodeError

The conventional choices:

  • UTF-8 — the dominant encoding for new data.
  • latin-1 (ISO-8859-1) — for legacy systems and as a one-to-one byte-to-character mapping.

For tolerant decoding, the errors argument:

b"\xff\xfe".decode("utf-8", errors="replace")     # 'ÿþ' (substitution)
b"\xff\xfe".decode("utf-8", errors="ignore")       # '' (skip invalid)
b"\xff\xfe".decode("utf-8", errors="strict")       # default; raises

Comparison and ordering

String comparison is lexicographic on Unicode code points:

"abc" < "abd"          # True
"abc" < "abcd"         # True (shorter prefixes are smaller)
"Z" < "a"              # True (uppercase code points are smaller)
"abc" == "abc"         # True
"abc" == "abc".lower() # True

The comparison is not locale-aware. For locale-aware comparison, use locale.strcoll (or external libraries like pyicu).

For case-insensitive comparison:

"ABC".lower() == "abc".lower()    # True
"ABC".casefold() == "abc"          # True; better for some Unicode

import unicodedata
unicodedata.normalize("NFC", "café") == unicodedata.normalize("NFC", "café")

casefold() is the conventional contemporary case-insensitive comparison; lower() is sufficient for ASCII.

Multi-line strings and line handling

text = """
This is a
multi-line string.
"""

for line in text.splitlines():
    process(line)

# Or with a generator-friendly form:
for line in text.split("\n"):
    process(line)

The splitlines() method handles platform-specific line endings (CR, LF, CRLF) and is the conventional choice over split("\n").

Translation tables

For substantial character substitutions, str.translate with a translation table:

table = str.maketrans("aeiou", "*****")
"hello world".translate(table)       # "h*ll* w*rld"

# Or removing characters:
table = str.maketrans("", "", "aeiou")
"hello world".translate(table)       # "hll wrld"

The mechanism is faster than repeated replace calls for many substitutions.

Regular expressions

Python’s regex support is in the re module:

import re

# Match:
match = re.match(r"\d+", "abc 123")
if match:
    print(match.group())         # nothing; match starts from the beginning

match = re.search(r"\d+", "abc 123")
if match:
    print(match.group())         # "123"

# Find all:
re.findall(r"\d+", "1 and 2 and 3")  # ["1", "2", "3"]

# Replace:
re.sub(r"\d+", "X", "1 and 2 and 3")  # "X and X and X"

# Compile for repeated use:
pattern = re.compile(r"\d{4}-\d{2}-\d{2}")
pattern.search("date: 2024-01-15")

The full treatment is in the standard library; for non-trivial regex, a compiled pattern is conventionally preferable to repeated re.search calls.

A note on bytes and bytearray

For byte-level operations:

b = b"hello"
b[0]                          # 104 (an int)
chr(104)                      # 'h'
bytes([104, 101, 108, 108, 111])   # b'hello'

# Mutable variant:
ba = bytearray(b"hello")
ba[0] = ord("H")
bytes(ba)                     # b'Hello'

bytes is the immutable byte type; bytearray is the mutable variant. The conventional uses are protocol implementation, file I/O, and any byte-level work.

Common patterns

String building with join

parts = []
for item in items:
    parts.append(format_item(item))
result = "\n".join(parts)

The join form is faster than repeated + concatenation for substantial sequences.

f-string with conditionals

status = "ok"
message = f"Status: {status if status else 'unknown'}"

The {} admits any expression, including conditionals.

Multi-line f-string

report = f"""
Report
------
Name:  {name}
Total: {total:,}
Date:  {date:%Y-%m-%d}
"""

The triple-quoted f-string admits formatted multi-line output.

Tokenising

import re

# Simple split:
words = text.split()                       # whitespace
csv_fields = csv_line.split(",")

# Regex-based split:
tokens = re.split(r"[\s,]+", text)         # whitespace or comma

Reading lines

with open("input.txt") as f:
    for line in f:
        process(line.rstrip())              # strip trailing newline

The for line in f is the conventional iteration; each line includes its newline (which rstrip() removes).

A note on Unicode

Python’s str is Unicode throughout; the language handles non-ASCII text by default. The substantial discipline:

  • In-memory textstr (Unicode).
  • On-disk and on-wirebytes with explicit encoding/decoding.
  • Encoding for new data — UTF-8.
  • Iteration over str — by code point (not byte, not grapheme cluster).

For grapheme-aware iteration (combining characters, emoji), libraries (grapheme, regex) are needed. For most application work, Python’s built-in Unicode handling is sufficient.