Strings and Formatting

Article · 11:00

Strings are immutable sequences of characters, and every method that looks like it edits one actually returns a new string. f-strings and the format spec that goes inside them, splitting and joining, and why encoding is a decision rather than a detail.

This lesson has 16 runnable examples. Press Try yourself! under any of them to open it in the editor.

Strings and Formatting

A Python string is an immutable sequence of Unicode code points. Both halves of that sentence have consequences: immutability decides how you build strings efficiently, and Unicode decides what len means.

Immutable, and what that costs

PY Example 1 5 lines
greeting = "hello"
upper = greeting.upper()
print(greeting, upper)         # hello HELLO - the original is untouched
# greeting[0] = "H"            # TypeError: 'str' object does not support
                               # item assignment

Open this example in the editor and change it. Nothing you type is saved.

Every string method returns a new string. That is why building one in a loop with += is wasteful - each round allocates a whole new string and copies everything so far:

PY Example 2 16 lines
import time

words = ["word"] * 20000

start = time.perf_counter()
out = ""
for w in words:
    out += w                   # quadratic: copies the accumulated string
slow = time.perf_counter() - start

start = time.perf_counter()
out = "".join(words)           # one pass, one allocation
fast = time.perf_counter() - start

print(len(out))
print("join is faster:", fast < slow)

Open this example in the editor and change it. Nothing you type is saved.

CPython has an optimisation that sometimes makes += on strings fast, but it only applies when the string has exactly one reference, so it disappears the moment the code changes slightly. join is always right.

f-strings

PY Example 3 16 lines
name = "Aisha"
score = 0.8637
count = 7

print(f"{name} scored {score:.1%} over {count} attempts")
# Aisha scored 86.4% over 7 attempts

print(f"{score:.2f}")          # 0.86    - two decimal places
print(f"{count:03d}")          # 007     - zero-padded
print(f"{count:>6}|")          # '     7|' - right-aligned in 6
print(f"{name:<10}|")          # 'Aisha     |' - left-aligned
print(f"{name:^11}|")          # '   Aisha   |' - centred
print(f"{1234567:,}")          # 1,234,567 - thousands separators
print(f"{1234567:_}")          # 1_234_567
print(f"{255:#x} {255:#b}")    # 0xff 0b11111111
print(f"{3.14159:>10.2f}|")    # '      3.14|' - align and precision together

Open this example in the editor and change it. Nothing you type is saved.

The = suffix prints the expression as well as the value, which is a debugging shortcut worth knowing:

PY Example 4 3 lines
total = 42
print(f"{total=}")             # total=42
print(f"{total * 2=}")         # total * 2=84

Open this example in the editor and change it. Nothing you type is saved.

Any expression is allowed, including a call, and the width can itself be a variable:

PY Example 5 7 lines
name = "Aisha"
width = 12
print(f"{name.upper():>{width}}|")         # '       AISHA|'
print(f"{sum([1, 2, 3])}")                 # 6

from datetime import date
print(f"{date(2026, 10, 7):%d %B %Y}")     # 07 October 2026

Open this example in the editor and change it. Nothing you type is saved.

Two gotchas. A literal brace is doubled: f"{{literal}}" prints {literal}. And in Python 3.11 an f-string cannot contain the same quote character as the one that delimits it, so f"{d["key"]}" is a syntax error - use different quotes, or pull the lookup out into a variable.

The three formatting styles, and which to use

PY Example 6 5 lines
name, score = "Aisha", 0.8637

print(f"{name}: {score:.1%}")                              # f-string
print("{}: {:.1%}".format(name, score))                    # str.format
print("%s: %.1f%%" % (name, score * 100))                  # percent, legacy

Open this example in the editor and change it. Nothing you type is saved.

Use f-strings. The exceptions are a template that must be stored or translated - where str.format lets you keep the string separate from the values - and logging, where the percent style is genuinely better:

PY Example 7 2 lines
import logging
logging.getLogger(__name__).info("saved %s rows for %s", 12, "aisha")

Open this example in the editor and change it. Nothing you type is saved.

The logging call defers formatting until the message is actually emitted, so a debug line in a hot loop costs almost nothing when debug logging is off. An f-string there would be formatted every time regardless.

Splitting and joining

PY Example 8 8 lines
line = "  aisha , noshad ,sam  "

print(line.split(","))              # ['  aisha ', ' noshad ', 'sam  ']
print([p.strip() for p in line.split(",")])    # ['aisha', 'noshad', 'sam']

text = "one  two   three"
print(text.split())                 # ['one', 'two', 'three']
print(text.split(" "))              # ['one', '', 'two', '', '', 'three']

Open this example in the editor and change it. Nothing you type is saved.

split() with no argument splits on any run of whitespace and drops empties - which is what you want for prose, and not what split(" ") does.

PY Example 9 8 lines
header = "Content-Type: text/html; charset=utf-8"

key, sep, value = header.partition(":")
print(repr(key), repr(sep), repr(value))
# 'Content-Type' ':' ' text/html; charset=utf-8'

missing = "no colon here"
print(missing.partition(":"))       # ('no colon here', '', '')

Open this example in the editor and change it. Nothing you type is saved.

partition splits once and always returns three parts, so it never raises and never needs a length check. rpartition does the same from the right, which is how you split a filename from its extension without a regular expression.

PY Example 10 2 lines
print("a,b,c".split(",", 1))        # ['a', 'b,c'] - at most one split
print("a,b,c".rsplit(",", 1))       # ['a,b', 'c'] - from the right

Open this example in the editor and change it. Nothing you type is saved.

The other methods you will reach for:

PY Example 11 7 lines
path = "  /var/log/app.log  "
print(path.strip())                 # '/var/log/app.log'
print(path.strip().removeprefix("/var/"))      # 'log/app.log'
print(path.strip().removesuffix(".log"))       # '/var/log/app'
print("Hello".startswith(("He", "Ho")))        # True - a tuple of options
print("a-b-c".replace("-", "+", 1))            # 'a+b-c' - count limits it
print("Hello World".casefold())                # 'hello world' - for comparing

Open this example in the editor and change it. Nothing you type is saved.

Use casefold() rather than lower() when comparing strings for equality across languages; it handles cases like the German sharp s that lower() does not.

Characters, bytes and encodings

A string is code points. A file, a socket and a database column hold bytes. encode and decode are the border between them, and crossing it implicitly is where mojibake comes from.

PY Example 12 15 lines
text = "cafe" + chr(0x301) + " nai" + chr(0x308) + "ve"   # combining accents
simple = "caf" + chr(0xe9)                                 # precomposed e-acute

print(len(simple))                       # 4 - one code point for the accent
utf8 = simple.encode("utf-8")
print(len(utf8))                         # 5 - the accent takes TWO bytes
print(list(utf8))                        # [99, 97, 102, 195, 169]

print(utf8.decode("utf-8"))              # café - back to a string
print(utf8.decode("latin-1"))            # café - mojibake, silently

try:
    utf8.decode("ascii")
except UnicodeDecodeError as error:
    print(type(error).__name__)          # UnicodeDecodeError

Open this example in the editor and change it. Nothing you type is saved.

Decoding with the wrong codec is sometimes an error and sometimes a silent mess - latin-1 can decode any byte sequence, so it never complains and is never right for UTF-8 data. Always name the encoding explicitly when you open a file:

PY Example 13 1 lines
# open(path, encoding="utf-8")   - not the platform default

Open this example in the editor and change it. Nothing you type is saved.

The platform default differs between Linux and Windows, which is how a program that works on one developer's machine produces garbage on another's.

Normalisation matters when comparing text from different sources:

PY Example 14 8 lines
import unicodedata

composed = "caf" + chr(0xe9)                     # one code point
decomposed = "cafe" + chr(0x301)                 # 'e' plus combining accent

print(composed == decomposed)                    # False - different code points
print(len(composed), len(decomposed))            # 4 5
print(unicodedata.normalize("NFC", decomposed) == composed)   # True

Open this example in the editor and change it. Nothing you type is saved.

Two strings that render identically can compare unequal. Normalise to NFC before storing or comparing user-entered text.

A worked example

PY Example 15 46 lines
import unicodedata

RAW = """
2026-10-01 | Aisha Khan   | aisha@example.com   | 1999
2026-10-02 | Noshad Alam  | NOSHAD@EXAMPLE.COM  | 450
2026-10-02 | Sam O'Neill  | sam@example.com     | 1299
bad line with no pipes
2026-10-03 | Leah Brown   | leah@example.com    | not-a-number
"""


def parse(line):
    """Return a record, or None, without raising on malformed input."""
    parts = [p.strip() for p in line.split("|")]
    if len(parts) != 4:
        return None
    day, name, email, amount = parts
    if not amount.isdigit():
        return None
    return {
        "day": day,
        "name": unicodedata.normalize("NFC", name),
        "email": email.casefold(),
        "pence": int(amount),
    }


records = [r for r in (parse(line) for line in RAW.strip().splitlines()) if r]

for r in records:
    print(f"{r['day']}  {r['name']:<14} {r['email']:<22} {r['pence'] / 100:>8,.2f}")

print("-" * 56)
total = sum(r["pence"] for r in records)
print(f"{'total':<38} {total / 100:>8,.2f}")
print(f"{len(records)} of {len(RAW.strip().splitlines())} lines parsed")

# The two lines that did not parse are the point: partition and isdigit
# let the parser reject them without a try block and without a regex.
print()
print("emails, deduplicated and sorted:")
print(", ".join(sorted({r["email"] for r in records})))

# And a report line built with join rather than +=.
columns = ["day", "name", "email", "pence"]
print(" | ".join(c.upper().center(10) for c in columns))

Open this example in the editor and change it. Nothing you type is saved.

Three habits are on display. isdigit() validates before int() rather than catching the exception afterwards - cheaper, and the intent is clearer. casefold() normalises the email so two spellings of the same address deduplicate. And the final report line uses join over a generator rather than accumulating with +=.

When it goes wrong

Symptom Cause
Building a string in a loop is slow += is quadratic; use join
UnicodeDecodeError reading a file Wrong encoding, or no encoding= given
Text came out as é instead of an accent UTF-8 bytes decoded as latin-1
Two identical-looking strings are unequal Different normalisation; use NFC
len disagrees with the visible characters Code points are not grapheme clusters
split(" ") produced empty strings Use split() for runs of whitespace
f"{d["k"]}" is a syntax error Nested same quotes; use different ones
A debug log line is slow when debug is off An f-string formats eagerly; pass args to the logger

A check you can run

PY Example 16 4 lines
s1 = "caf" + chr(0xe9)
s2 = "cafe" + chr(0x301)
print(s1, s2)
print(s1 == s2, len(s1), len(s2))

Open this example in the editor and change it. Nothing you type is saved.

Both print the same word. They are not equal, and their lengths differ. Any time you compare, deduplicate or use as a dictionary key text that came from a user, a web form or a file, that difference is waiting - and unicodedata.normalize("NFC", text) at the boundary is the one line that removes it.