Working With Strings
Context
Text carries its own set of operations. A piece of text can be split into parts on a chosen separator, several parts can be joined back into one, surrounding whitespace can be trimmed away, and values can be inserted into a template to build a readable line.
Every line read from a data file arrives as text before it is anything else. A line read from a comma-separated file becomes fields by splitting it, a category name arrives with a stray trailing space that has to be trimmed before it will match anything, and inconsistent capitalization has to be normalized or the same category counts as two. Reports and log lines go the other way, values folded into a sentence a human can read.
Text does not change in place, which is the single most misread fact here. Every method that looks like an edit returns a new piece of text and leaves the original untouched, so a trim whose result is not captured leaves the value exactly as it was, and the untrimmed value carries on through the pipeline looking clean in the code and dirty in the data. Splitting on the wrong separator can return the whole line as one field, which reads as malformed input. The previous section built the structures, this lesson cleans what goes into them.
Goal
- Transform and query text with string methods such as
.lower(),.upper(),.replace(), and.find(), and predict each result. - Split a string into a list of fields on a delimiter, and join a list back into one string.
- Strip leading and trailing whitespace and unwanted characters to clean raw text.
- Build readable, formatted output with f-strings, including numeric formatting.
- Explain why every string "edit" returns a new string, and rewrite code that wrongly assumes in-place mutation.
Prerequisites
Optional, neither is a gate. The lesson stands on its own, these are here if you want the common methods and string indexing in front of you first.
- YouTube - "Python Tutorial for Beginners 2: Strings - Working with Textual Data" by Corey Schafer. Watch it once for the common methods in action.
- Official docs - The Python Tutorial, "Text". Read it for indexing, slicing, and the immutability note.
Table of Contents
- String Methods
- split / join
- strip / clean
- f-string Formatting
- String Immutability
Theory
A value read from a raw text file line by line arrives as text, and it usually needs trimming, splitting and, when it is a number, casting. This lesson is the cleaning layer of every loader you will write, before a number can be averaged or a label counted, the text that carried it must be normalized. The string type you met early in the module now gains its cleaning toolbox, and one constraint from the tuples lesson, immutability, turns out to govern strings too.
String Methods
What. String methods are functions attached to a string that inspect or transform it, .lower(), .upper(), .replace(), .find(), .startswith(), and many more. Each is called with the dot syntax on a string value.
Why. Raw text is inconsistent. The same label arrives as "Cat", "cat", and "CAT ", and a program that treats those as three different categories is broken. String methods normalize text into a consistent form so comparisons, counts, and lookups behave, which is the first job of any data-cleaning step.
When. Use these methods whenever text must be standardized or queried: lowercasing before comparison, replacing stray characters, locating a substring, or checking a prefix. They apply to any string, from a single field to a whole line read from a file.
The case methods .lower() and .upper() return the string with every letter converted, which is the standard way to make comparisons case-insensitive. They take no arguments.
label = "Versicolor"
# .lower() returns a NEW lowercased string, the original is unchanged.
normalized = label.lower() # "versicolor"
# Lowercasing before comparison avoids "Cat" != "cat" bugs.
print("VERSICOLOR".lower() == normalized) # TrueLowercasing both sides is how you stop treating one category as two just because of capitalization.
.replace(old, new) returns the string with every occurrence of old swapped for new, and .find(sub) returns the index of the first occurrence of sub, or -1 if it is absent.
raw = "n/a,3.5,n/a,4.1"
# .replace swaps EVERY occurrence and returns a new string.
cleaned = raw.replace("n/a", "") # ",3.5,,4.1"
# .find returns the first index, or -1 when not present.
pos = raw.find("3.5") # 4, the index where "3.5" starts
missing = raw.find("9.9") # -1, not found (no error raised).find signaling absence with -1 rather than raising is the detail that bites. -1 is a perfectly valid index in Python, so a "not found" result quietly becomes a reference to the last character if you feed it straight into a slice.
Predicate methods like .startswith(), .endswith(), and .isdigit() return a boolean, useful for filtering and validation. They answer a yes/no question about the string.
line = "# comment line"
# .startswith returns True/False, handy for skipping headers or comments.
print(line.startswith("#")) # True
# .isdigit checks for at least one character, every one a digit.
print("42".isdigit()) # True
print("4.2".isdigit()) # False, the dot is not a digit.isdigit() is true only when every character is a digit, so "4.2" fails on the decimal point and "-3" fails on the sign. It is a useful check and not a test for "is this a number".
DON'T:
- DON'T expect a method to change the string in place.
label.lower()returns a new string, solabel.lower()alone on a line leaveslabelunchanged, you must assign the result. - DON'T use
.find()in a boolean test asif s.find(x):. Whenxis at index0,.findreturns0, which is falsy, useif x in s:for presence instead.
split / join
What. .split(sep) breaks a string into a list of substrings on a separator, and .join(iterable) is the inverse, gluing a list of strings into one string with a separator between them. Together they convert between one line of text and a list of fields.
Why. A line of data is a string, but you need its fields as separate values, "1,cat,0.9" must become ["1", "cat", "0.9"] before you can use the parts. split is the bridge from one text line to a list of fields, and join rebuilds a line for output. This pair is the heart of reading and writing delimited data.
When. Use split whenever a single string holds multiple values separated by a known character (comma, tab, space). Use join whenever you have a list of strings to write out as one line. They are the natural entry and exit points of text-based data.
.split(sep) returns a list of the pieces between each occurrence of sep.
row = "1,cat,0.91"
# split on "," yields a list of field strings.
fields = row.split(",") # ["1", "cat", "0.91"]
# No argument: split on any whitespace, collapsing runs.
words = "the quick \t brown".split() # ["the", "quick", "brown"]
# split bridges one line to a list, the type you already know.
print(fields[1]) # cat, now an indexable fieldThe no-argument form is not the same as .split(" "). It splits on any run of whitespace and discards the empty pieces, which is why the run of three spaces above collapses instead of producing two empty fields, and the tab is treated as a separator instead of coming back as a field of its own.
.join(iterable) is called on the separator string and takes an iterable of strings, inserting the separator between each. The separator is the string you call it on, which surprises beginners.
fields = ["1", "cat", "0.91"]
# join is called ON the separator, with the list as the argument.
line = ",".join(fields) # "1,cat,0.91"
# Any separator works.
tabbed = "\t".join(fields) # "1\tcat\t0.91"
# Every item MUST already be a string.
nums = [1, 2, 3]
text = ",".join([str(n) for n in nums]) # "1,2,3" after converting eachThe last example is the rule people trip on: join requires every item to be a string already, so a list of numbers has to be converted first, and ",".join([1, 2]) raises TypeError rather than converting them for you.
DON'T:
- DON'T call
joinon the list. It issep.join(list), notlist.join(sep), the separator owns the method. - DON'T pass non-strings to
join.",".join([1, 2])raisesTypeError, convert each item withstr()first.
strip / clean
What. .strip() returns the string with leading and trailing whitespace removed, and with an argument removes the specified leading and trailing characters. .lstrip() and .rstrip() strip only the left or right side.
Why. Text read from files almost always carries invisible baggage, a trailing newline \n, stray spaces, surrounding quotes. Left in place, that baggage corrupts comparisons ("cat\n" != "cat") and defeats the checks you validate with, since "42\n".isdigit() is False. Stripping is the routine first move on every field and every line you read.
When. Use .strip() on every line read from a file to drop the trailing newline, and on every field to remove padding before comparing or converting it. Use the argument form to remove specific noise characters like quotes.
The no-argument .strip() removes all leading and trailing whitespace, including spaces, tabs, and newlines.
raw_line = " cat , 0.91 \n"
# .strip() removes leading/trailing whitespace, including the newline.
clean_line = raw_line.strip() # "cat , 0.91"
# Interior whitespace is left alone.
print(clean_line) # cat , 0.91 (space before comma remains)The result is "cat , 0.91", and the space before the comma survived. strip only ever trims the two ends and never the interior, which is exactly why splitting a line is usually followed by stripping each field separately.
The argument form strips any of the given characters from both ends, which is how you remove quotes or other wrappers. It treats the argument as a set of characters, not as a substring.
quoted = '"label"'
# Argument form strips any of the given chars from both ends.
unquoted = quoted.strip('"') # "label"
# It strips a SET of characters, not a literal substring.
messy = "xxdataxx"
print(messy.strip("x")) # data, all leading/trailing x removed
# lstrip / rstrip target one side only.
print("...note".lstrip(".")) # note
print("value\n".rstrip()) # value, newline removed from the right.rstrip() with no argument is the classic way to drop a trailing newline while leaving any leading indentation intact.
A common cleaning chain combines methods, each returning a new string for the next to act on. Both cleaning steps run before the compare.
field = " Cat\n"
# Chain: strip the ends, then lowercase the result.
clean = field.strip().lower() # "cat"
print(clean == "cat") # TrueEach method after the first acts on the string the previous one returned. This strip-then-normalize chain is the standard cleaning idiom for a single field.
DON'T:
- DON'T assume
.strip("n/a")removes the substring"n/a". The argument is a character set, so it strips any leading/trailingn,/, ora, use.replace("n/a", "")to remove the substring. - DON'T forget to strip lines read from files. A leftover
\nmakes"cat\n"unequal to"cat", and it makes"42\n".isdigit()answerFalse, so a plainly numeric field is judged to be text.
f-string Formatting
What. An f-string is a string literal prefixed with f in which {expression} placeholders are replaced by the value of the expression. It embeds values directly into text.
Why. Building output by concatenating strings with + and str() is verbose and error-prone. F-strings state the result directly, the surrounding text and the embedded values read in one glance, and they support precise numeric formatting, which matters constantly when printing scores, rates, and metrics.
When. Use an f-string whenever you build a string that contains computed values: log lines, formatted reports, file names, or any human-readable output. Prefer them over + concatenation and over older formatting styles.
Any expression inside the braces is evaluated and inserted. This includes variables, arithmetic, method calls, and indexing.
name = "rf"
accuracy = 0.9234
n = 150
# {expr} placeholders are replaced by the expression's value.
line = f"model {name} scored {accuracy} on {n} samples"
# "model rf scored 0.9234 on 150 samples"
# Expressions, not just variables, are allowed inside braces.
print(f"error rate: {1 - accuracy}") # error rate: 0.0766
print(f"first letter: {name[0]}") # first letter: rA format specifier after a colon controls how a value is displayed, which is essential for numbers. :.2f shows a float with two decimals, :.1% shows it as a percentage, and a width can pad for alignment.
accuracy = 0.9234
# :.2f -> two decimal places.
print(f"acc: {accuracy:.2f}") # acc: 0.92
# :.1% -> percentage with one decimal (multiplies by 100, adds %).
print(f"acc: {accuracy:.1%}") # acc: 92.3%
# width and alignment: pad to 8 wide, right-aligned by default for numbers.
print(f"|{accuracy:8.2f}|") # | 0.92|These specifiers control presentation only and the stored number is untouched, so a value shown as 0.92 is still 0.9234 for every later comparison and sum. Formatting a number is not rounding it.
DON'T:
- DON'T forget the
fprefix. Without it,"{name}"is the literal text{name}, not the value, a silent, common mistake. - DON'T confuse rounding for display with rounding the value.
{x:.2f}only changes howxis shown, it does not modifyxitself.
String Immutability
What. String immutability means a string cannot be changed after it is created. Every method that "modifies" a string actually returns a new string, leaving the original untouched.
Why. This is the single most common source of beginner string bugs. Code that calls s.replace(...) or s.strip() and expects s to change leaves s exactly as it was, because the return value was discarded. Understanding immutability turns that mistake, which raises nothing, into an obvious "assign the result" fix. It is the same constraint you learned for tuples, now shown to govern strings too.
When. Remember immutability every time you call a string method that seems to modify: you must capture the returned value, either in a new variable or by reassigning the original name. You can never edit a character in place.
A string cannot be modified by indexed assignment, that raises TypeError, exactly as it did for tuples. Strings are immutable sequences.
text = "cat"
# Indexed assignment is forbidden, strings are immutable.
text[0] = "b" # raises TypeError: 'str' object does not support item assignmentBecause methods return new strings, calling one without keeping the result leaves the original exactly as it was. The fix is always to assign the returned value.
text = " Cat\n"
# BUG: the result is discarded, 'text' is unchanged.
text.strip()
print(repr(text)) # ' Cat\n' (still dirty!)
# FIX: reassign the name to the returned string.
text = text.strip()
print(repr(text)) # 'Cat'
# Or keep both, since the original survives anyway.
original = "Cat"
lowered = original.lower() # 'cat', 'original' is still 'Cat'repr is doing real work here, and it is worth the habit. A plain print of a dirty string shows the whitespace as blank space you cannot count and shows the trailing \n only as an empty line you can miss, so repr is how you actually see whether a strip landed. The first call computed a clean string and discarded it, and nothing raised.
To change one character, you build a new string from pieces, typically by slicing and concatenation, since direct mutation is impossible.
text = "cat"
# Build a NEW string instead of mutating: slice around the change.
fixed = "b" + text[1:] # "bat"
print(fixed, text) # bat cat (original intact)Building a new string, from pieces or with a method that returns one, is the only way to "edit" one, and it mirrors how you built a new tuple rather than mutating it.
DON'T:
- DON'T write
s.replace("a", "b")on its own line and expectsto change. Assign it:s = s.replace("a", "b"). - DON'T try
s[i] = cto change a character. Strings are immutable, build a new string with slicing and concatenation instead.
Lab Exercises
Lab Exercise 1: Normalize Labels, Then Split a Row Apart
Given labels = [" Cat ", "DOG\n", "Bird", "cat"], build a new list clean where each label is stripped of surrounding whitespace and lowercased, print it, and report how many entries equal "cat" afterwards. Print labels again to show the originals survived.
Then, given row = "1, cat , 0.91 ", split it on commas into fields and print the raw result with repr so the leftover spaces are visible. Strip each field, print the cleaned list, then rejoin the cleaned fields with a single comma and no spaces and print the tidy line.
Lab Exercise 2: Report With f-strings, and the Method Call That Did Nothing
Given raw = " accuracy = 0.9234 ", split on "=", strip both halves, and bind them to a name and a value. Convert the value to a float and print one f-string report line showing it to two decimals and, on the same line, as a percentage to one decimal.
Then show that formatting is display only: print the raw float again after the formatted line and add a comment stating whether the stored value changed.
Finally, take text = " Cat\n" and call text.strip() on a line by itself, then print repr(text). Then do it properly with a reassignment and print repr(text) again. Add a comment explaining why the first attempt raised nothing and changed nothing. Finish by building "bat" out of "cat" without assigning to any character position.
Solution 1: Normalize Labels, Then Split a Row Apart
labels = [" Cat ", "DOG\n", "Bird", "cat"]
# Each method returns a NEW string, so the comprehension collects the results.
clean = [s.strip().lower() for s in labels]
print(clean) # ['cat', 'dog', 'bird', 'cat']
print(clean.count("cat")) # 2
print(labels) # [' Cat ', 'DOG\n', 'Bird', 'cat']row = "1, cat , 0.91 "
fields = row.split(",") # cuts at each comma, keeps whatever surrounds it
print(repr(fields)) # ['1', ' cat ', ' 0.91 ']
# 'split' does not clean, so each field is stripped separately.
cleaned = [f.strip() for f in fields]
print(cleaned) # ['1', 'cat', '0.91']
# 'join' is called ON the separator, with the list as its argument.
print(",".join(cleaned)) # 1,cat,0.91Printing the split result makes the point visible, because a printed list shows each item in quotes, padding and all. split cuts at the separator and hands back everything else untouched, spaces included, so the fields arrive dirty and the stripping is a second, separate step.
DON'T:
- DON'T expect
split(",")to tidy anything, it cuts at each comma and nothing more. - DON'T write
cleaned.join(","), the separator owns the method, so it is",".join(cleaned).
Solution 2: Report With f-strings, and the Method Call That Did Nothing
raw = " accuracy = 0.9234 "
# split, then clean both halves
name, value = [part.strip() for part in raw.split("=")]
score = float(value)
print(f"{name}: {score:.2f} ({score:.1%})") # accuracy: 0.92 (92.3%)
print(score) # 0.9234
# The stored value did not change. ':.2f' and ':.1%' are display instructions,
# so 'score' is still the full 0.9234 for every later comparison and sum.text = " Cat\n"
text.strip() # computes a clean string, returns it, and nobody keeps it
print(repr(text)) # ' Cat\n'
text = text.strip() # the same call, with the result captured
print(repr(text)) # 'Cat'
# The first line raised nothing because nothing illegal happened. 'strip' built
# the clean string exactly as asked, then the line ended and it was discarded.
word = "cat"
built = "b" + word[1:] # slice around the character, then concatenate
print(built, word) # bat catDON'T:
- DON'T read a formatted number as the stored number,
{score:.2f}changes what is shown and nothing else. - DON'T call a string method on a line by itself, the absence of an error is not evidence that anything happened.
Theory Assessment
Q1: Normalizing, and the Method That Answers With an Index
Given labels arriving as "Cat", "CAT " and "cat", explain what you would call on each before comparing them and why the comparison fails without it. Then explain what .find(sub) returns when sub is absent, and why if line.find("#"): is a broken way to test whether line contains a "#".
Hint: For the second half, work out what .find returns when the substring is at the very start, and then ask what that value does inside an if.
Q2: Splitting Apart and Gluing Back
For row = "1, cat , 0.91", what does row.split(",") return exactly, including whitespace? Say what you must do next before the fields are usable. Then explain which object join is called on and why ["a","b"].join(",") does not work, and what happens if the list contains a number.
Hint: Print the split result mentally with repr rather than as plain text. For join, ask which of the two things involved, the separator or the list, is the string.
Q3: A Character Set, and a Display Instruction
Explain precisely what "banana".strip("n/a") does and why, and say what you would call instead to remove the substring "n/a" from a field. Then explain what f"{x:.2f}" does to x, and what it does not do.
Hint: The argument to strip is not a substring to match. For the second half, ask what x holds after the line has run.
Q4: Three Lines That Change Nothing
Read the following without running it, then state what each print shows.
fields = [" Cat\n", "n/a", " dog "]
for f in fields:
f.strip() # intended to clean each field in place
placeholder = "n/a".strip("n/a")
label = "data".strip("n/a")
print(fields)
print(repr(placeholder), repr(label))The loop looks like it cleans the list and the two strip calls look like they remove the same thing. Explain what each of the three actually did, and say which of them raised an error.
Hint: For the loop, ask what f is bound to and where the cleaned string it produced went. For the last two, the argument to strip is a set of characters rather than a piece of text to find.
Answer 1:
Call .strip() then .lower() on each, giving "cat" in all three cases. Without it the comparison fails because "Cat" == "cat" is False, capitalization makes them different strings, and "CAT " carries a trailing space that would keep it distinct even after lowercasing. A category column left unnormalized reports the same category several times.
.find(sub) returns -1 when sub is absent, rather than raising. if line.find("#"): is broken because .find returns 0 when the "#" is the very first character, and 0 is falsy, so the test says "not found" in exactly the case where the match is at the front. It is also true in the -1 case, since -1 is truthy, so the test is wrong in both directions. Use if "#" in line: instead.
DON'T put .find() inside a plain if, it returns an index and an index of 0 is a real match that reads as False.
Answer 2:
row.split(",") returns ['1', ' cat ', ' 0.91']. The separator is the only thing removed, so the spaces that surrounded each field are still there. Before the fields are usable each one has to be stripped separately, typically with a comprehension, because strip on the whole line before splitting would only have touched the two outer ends.
join is called on the separator, which is a string, and takes the list as its argument, so it is ",".join(["a","b"]). ["a","b"].join(",") fails because a list has no join method at all, the method belongs to str. If the list contains a number, ",".join(["a", 1]) raises TypeError, because join requires every item to be a string already and will not convert them for you, so numbers must be passed through str() first.
DON'T strip the line and assume the fields are clean, stripping the whole line only trims its two ends.
Answer 3:
"banana".strip("n/a") returns "b". The argument is a set of characters, here n, / and a, and strip removes any of them repeatedly from both ends until it meets a character that is not in the set. From the right it removes a, n, a, n, a and stops at b. From the left it stops immediately, because b is not in the set. To remove the substring "n/a" you want .replace("n/a", ""), which matches the three characters in that order.
f"{x:.2f}" produces a string in which x is displayed to two decimal places. It does not change x, does not round the stored value, and does not return a number. After that line x holds exactly what it held before, so a value shown as 0.92 still compares and sums as 0.9234.
DON'T use strip to remove a placeholder like "n/a", it will chew any leading or trailing n, / or a off a perfectly good value.
Answer 4:
fields prints [' Cat\n', 'n/a', ' dog '], entirely unchanged. placeholder prints '' and label prints 'dat'.
The loop cleaned nothing. f is bound to each string in turn, f.strip() builds a clean string and returns it, and the line ends without anyone keeping it, so the returned value is discarded. Even reassigning inside the loop with f = f.strip() would not have helped, because that would only repoint the loop variable and never touch the list. Rebuilding the list, with fields = [f.strip() for f in fields], is what actually works.
"n/a".strip("n/a") returns the empty string, which looks like the intended behavior and is a coincidence. strip was removing characters from the set {n, /, a}, and every character of "n/a" happens to be in that set, so the whole string was consumed. The same call on "data" gives "dat", which is the same rule producing an obviously wrong answer, and it shows the first result was not evidence of anything.
None of the three raised an error. The loop performed three legal method calls and discarded three legal results, and both strip calls did exactly what strip is specified to do. This is the shape of the whole lesson's danger: string operations fail by returning something you ignored, or by succeeding at a slightly different job than the one you meant to ask for.
DON'T treat "it ran without error" as evidence a cleaning step worked, check the value afterwards with repr.
Assignment
Assignment: A Field Parser, and Two Ways It Quietly Gets the Wrong Answer
Build the following in one file, in this order.
Write parse_record(line) taking a comma-separated line such as ' "12" , Cat , n/a , 0.83 ' and returning a list of cleaned fields, where each field has its surrounding whitespace and double quotes removed, the placeholder "n/a" in any capitalization becomes the empty string, a numeric-looking field is converted to float, and any other text field is lowercased. Print the result together with the type of each element.
Then demonstrate the two failures rather than describing them.
First, write a second version whose only difference is that it uses field.strip(' "n/a') to do the quote-stripping and placeholder removal in one call, run it on the same line plus the extra field "banana", and print what comes back. Add a comment stating what happened to "banana" and why.
Second, write a third version whose only difference is that it calls field.strip() on a line by itself instead of reassigning, run it on the same line, and print what comes back. Add a comment stating what that version produced and why nothing raised.
Finish by printing the three results one under another so the differences line up.
Hints:
- A field "looks numeric" when
field.replace(".", "", 1).isdigit()is true, which removes at most one decimal point before testing, so"0.83"passes and"4.2.1"and"cat"do not. - For the first failure, remember
striptakes a set of characters and applies it to both ends repeatedly, so ask which characters of"banana"are in the set' "n/a'.
Solution:
# 'looks numeric' means: at least one digit, at most one decimal point, nothing else.
def is_numeric(field):
# removes ONE dot, then tests
return field.replace(".", "", 1).isdigit()
def parse_record(line):
out = []
for field in line.split(","): # split first, clean each field after
f = field.strip() # ends only, so quotes may still surround it
f = f.strip('"') # remove surrounding double quotes
if f.lower() == "n/a": # placeholder, in any capitalization
f = ""
if is_numeric(f):
out.append(float(f)) # a real number, not text
else:
out.append(f.lower()) # text fields are normalized
return out
line = ' "12" , Cat , n/a , 0.83 '
parsed = parse_record(line)
print(parsed) # [12.0, 'cat', '', 0.83]
print([type(v) for v in parsed]) # [<class 'float'>, <class 'str'>, <class 'str'>, <class 'float'>]# FAILURE 1: one strip call asked to do two different jobs.
def parse_greedy(line):
out = []
for field in line.split(","):
f = field.strip(' "n/a') # a CHARACTER SET, not a quote and a placeholder
if f.lower() == "n/a":
f = ""
if is_numeric(f):
out.append(float(f))
else:
out.append(f.lower())
return out
print(parse_greedy(' "12" , Cat , n/a , 0.83 , banana '))
# [12.0, 'cat', '', 0.83, 'b']
# The first four fields are correct. 'banana' became 'b', because the set is
# {space, ", n, /, a} and the trailing 'a', 'n', 'a', 'n', 'a' were each eaten
# in turn. 'Cat' survived because neither 'C' nor 't' is in the set.# FAILURE 2: the first cleaning call is made but never captured.
def parse_dropped(line):
out = []
for field in line.split(","):
field.strip() # returns a clean string, which nobody keeps
f = field # so 'f' starts as the raw field, padding and all
f = f.strip('"')
if f.lower() == "n/a":
f = ""
if is_numeric(f):
out.append(float(f))
else:
out.append(f.lower())
return out
print(parse_dropped(' "12" , Cat , n/a , 0.83 '))
# [' "12" ', ' cat ', ' n/a ', ' 0.83 ']
# Every field kept its padding, so the quote strip, the placeholder test and
# the number test all missed. The four discarded calls were all legal, so
# nothing raised at any point.print(parse_record(line))
print(parse_greedy(line))
print(parse_dropped(line))Line up those three and notice what parse_greedy did on the original line: nothing wrong at all. It returns [12.0, 'cat', '', 0.83], which is correct, and it stays correct for as long as no field carries a tab, a line's trailing newline or any other whitespace but the plain space, which its character set leaves in place, and no value, once its padding and quotes are gone, begins or ends with an n, a /, an a or a space kept inside its quotes. The bug is invisible on the sample and waits for the first value like banana, or anna, or any category ending in a. A test built from the line above would pass.
parse_dropped is easier to catch here only because these fields are visibly padded. On a file whose fields happen to arrive with no whitespace around them, not even a line's trailing newline, it returns exactly the right answer too, and stays wrong in the same silent way.
The two mistakes have different roots and the same symptom. strip is a character-set operation that people read as a substring operation, and every string method returns rather than modifies. Both fail by producing a string, which means the surrounding code carries on happily, and the damage surfaces wherever the value is finally compared or counted.
DON'T hand strip a multi-character argument expecting it to match that text, and don't call a string method for its effect, there isn't one.
Additional Resources
Optional, and nothing later depends on either. Worth opening if you want every string method or more on format specifiers.
- Official docs -
string, "Format Specification Mini-Language". The full grammar behind:.2f,:.1%and:8.2f, the specifiers this lesson used. - Official docs - Built-in Types, "Text Sequence Type, str", every string method, one entry each.
Summary
Strings are where your programs first meet the messy reality of outside data. Inside the program, a number is a clean number, but the moment data arrives as text it carries whitespace, inconsistent capitalization, stray quotes, and placeholder markers, and none of it can be trusted until it has been cleaned. The tools in this lesson are that cleaning layer: lowercasing to make comparisons sane, stripping to drop the invisible newline that breaks comparisons, splitting to turn one line into a list of fields, joining to put them back, and f-strings to render results that a human can read. Each method is small and concrete, and together they are the vocabulary of every data loader you will ever write.
The one idea that elevates this above a list of methods is immutability, and it is worth dwelling on because it explains a bug you will otherwise write many times. A string cannot be changed. Every method that looks like it edits a string actually returns a new one and leaves the original exactly as it was. Call s.strip() and ignore the result, and s stays exactly as it was, the cleaned string computed and thrown away. Once you internalize that strings, like the tuples before them, are immutable, the rule writes itself: capture what the method returns, every time. The no-op stops being a mystery and becomes an obvious missing assignment.
You now have the cleaning layer, but cleaning presupposes something to clean, and so far almost every string in your code has been typed by hand. The next lesson opens the door to real data: files on disk. You will learn to open a file safely so it always closes, to read it a line at a time, and to write your results back out, with the encoding set explicitly so a UTF-8 file reads the same on every machine. The strip-split-clean reflexes you built here are exactly what each of those lines will need the instant it arrives off the disk.
Cheat Sheet
Part A - Reference Table
| Method | Call | Returns | Notes |
|---|---|---|---|
| Lowercase | s.lower() |
new str | for case-insensitive compare |
| Uppercase | s.upper() |
new str | original is unchanged, str is immutable |
| Replace | s.replace(a, b) |
new str | swaps ALL occurrences |
| Find | s.find(sub) |
index or -1 |
use in for presence |
| Starts with | s.startswith(p) |
bool | skip headers/comments |
| Split | s.split(sep) |
list | no arg = any whitespace |
| Join | sep.join(items) |
str | items must be strings |
| Strip | s.strip() / s.strip(chars) |
new str | ends only, char set |
| f-string | f"{x:.2f}" |
str | .2f, .1%, width specifiers |
| Mutate char | s[i] = c |
raises TypeError |
impossible, rebuild via slicing |
Part B - Compact Code Block
s = " Cat \n"
clean = s.strip().lower() # "cat", chained, returns new
fields = "1, a ,2".split(",") # ["1", " a ", "2"]
fields = [f.strip() for f in fields] # ["1", "a", "2"]
line = ",".join(fields) # "1,a,2", sep owns join
n = ",".join([str(x) for x in [1, 2]]) # convert non-strings
acc = 0.9234
print(f"acc={acc:.2f} ({acc:.1%})") # acc=0.92 (92.3%)
s = s.replace("a", "@") # MUST reassign, immutable
fixed = "b" + "cat"[1:] # "bat", build new (no s[0]=...)