Chapter 6 — Functions: Reusable Building Blocks
Lesson 6.1 — Your First Function
Look back at the regex email check you wrote in Chapter 3. If you needed to check ten different emails across your code, you'd have to retype that same regex pattern ten times. Copy, paste, copy, paste — and if you ever find a bug in it, you now have to fix it in ten different places, and you will absolutely forget one of them.
This is exactly the problem functions solve. A function lets you write a piece of logic once, give it a name, and reuse it as many times as you want, from anywhere in your code.
You've actually been using functions this whole course — print(), len(), type() are all functions Python built for you. Now you're going to build your own.
pythondef greet_pipeline(): print("Starting pipeline run...") print("Checking source files...") greet_pipeline()
Output / NoteStarting pipeline run... Checking source files...
Let's break that down. def tells Python "I'm defining a function." greet_pipeline is the name we chose. The parentheses and colon start the function's body — everything indented underneath belongs to it. Notice that writing the function doesn't run it. Nothing happened until that last line, greet_pipeline(), actually called it. Defining and calling are two separate steps, and mixing them up is a common early confusion — write the recipe first, then decide when to actually cook.
The real power shows up once a function can take a name that's used every single time we call it. Notice the pattern below: same greeting logic, different pipeline names.
pythondef greet_pipeline(pipeline_name): print(f"Starting {pipeline_name}...") print("Checking source files...") greet_pipeline("orders_sync") greet_pipeline("customer_sync")
Output / NoteStarting orders_sync... Checking source files... Starting customer_sync... Checking source files...
That pipeline_name inside the parentheses is called a parameter — a placeholder for a value you'll provide each time you call the function. Same function, called twice, with two different results. That's the whole point: write the logic once, reuse it with whatever input you hand it.
Try It Yourself:
Write a function called log_status that takes one parameter, system_name, and prints f"{system_name}: OK". Call it three times with three different made-up system names.
Lesson 6.2 — Parameters, Defaults & Return Values
Functions get genuinely useful once they can hand something back to you, not just print it out. That's the difference between a function that just performs an action, and one that actually computes a result you can use later in your code.
pythondef is_valid_price(price): return price > 0 result = is_valid_price(24.99) print(result) result2 = is_valid_price(-5) print(result2)
Output / NoteTrue False
return sends a value back out of the function, to wherever it was called from — that's what let us store it in result and use it afterward. This is different from print(), which just displays something and then forgets it. A function that returns a value gives you something you can store, pass to another function, or check with an if statement, exactly like any other value.
Functions can take more than one parameter, separated by commas:
pythondef calculate_total(price, quantity): return price * quantity order_total = calculate_total(24.99, 3) print(f"Order total: ${order_total}")
Output / NoteOrder total: $74.97
You can also give a parameter a default value — something it uses automatically if the caller doesn't provide one:
pythondef load_summary(system_name, rows=0): if rows == 0: return f"{system_name}: no data loaded" return f"{system_name}: {rows} rows loaded" print(load_summary("Billing", 3200)) print(load_summary("Marketing"))
Output / NoteBilling: 3200 rows loaded Marketing: no data loaded
Notice the second call didn't pass a rows value at all — it fell back to the default, 0, automatically. Defaults are genuinely useful for exactly this kind of situation: a value that's usually provided, but should have a sensible fallback on the rare occasion it isn't.
One more small but important habit: once a function hits a return, it stops immediately — nothing after that line runs, even inside the same function.
pythondef check_row(row_count): if row_count == 0: return "Empty file" return "File has data" print(check_row(0))
Output / NoteEmpty file
That function never even looked at the second return line, because the first one already fired and exited. This is a genuinely useful pattern — handle the special case first, return immediately, and let the normal case fall through underneath.
Try It Yourself:
Write a function called apply_discount that takes price and a parameter discount_percent with a default value of 0. It should return the price after the discount is subtracted. Call it once with just a price, and once with both a price and a discount, and print both results.
Lesson 6.3 — Docstrings: Notes for Future You
Here's a small habit that pays off far more than its size suggests, especially once your scripts start getting longer than a few lines. A docstring is a short note, written right inside a function, explaining what it does.
pythondef calculate_error_rate(total_rows, bad_rows): """ Calculate the percentage of bad rows out of total rows. Returns a float, e.g. 0.05 for a 5% error rate. """ return bad_rows / total_rows rate = calculate_error_rate(4500, 225) print(f"{rate:.1%}")
Output / Note5.0%
That triple-quoted text right under the def line is the docstring. Python doesn't run it as code — it's a note for humans, including future-you, who will absolutely forget exactly how this function works six weeks from now. That's not a knock on your memory. It happens to every engineer, on every project, without exception.
A good docstring answers one simple question: if a teammate — or you, much later — saw only the function's name and had to guess how to use it, would this note actually help? It doesn't need to be long. It just needs to say what goes in, and what comes back out, especially anything that isn't obvious from the name alone.
Here's a slightly fuller example, closer to what you'll actually write:
pythondef is_valid_email(email): """ Check whether a string looks like a valid email address. Returns True or False. Does not verify the email actually exists. """ return "@" in email and "." in email print(is_valid_email("priya.shah@company.com"))
Output / NoteTrue
Notice that second sentence in the docstring — "does not verify the email actually exists." That's exactly the kind of honest limitation worth writing down. It stops a future reader from trusting the function for more than it actually does, which is a very real, very common source of bugs: someone assuming a check is stricter than it really is.
You won't write a docstring for every tiny function in this course — that would be overkill, and overkill has its own cost. But for anything you'd hand to a teammate, or reuse across multiple scripts, it's a habit worth building now, while it's still cheap to build.
Try It Yourself:
Go back to the apply_discount function you wrote in the last lesson, and add a short docstring explaining what it does, what it expects, and what it returns.
Lesson 6.4 — Hands-On: Build an Email Validator Function
Let's bring this chapter together by wrapping up the regex email check from Chapter 3 into something properly reusable — a real function, with parameters, a return value, and a docstring, ready to be used anywhere in a bigger script.
Here's where we're starting from:
pythonimport re email = "priya.shah@company.com" is_valid = bool(re.search(r"[\w.]+@[\w.]+\.\w+", email)) and email.count("@") == 1 print(is_valid)
That worked fine for one email. But copy-pasting those two lines every time you need to check an email is exactly the repeated-code problem this chapter opened with. Let's fix it properly.
Your task:
- Write a function called
is_valid_emailthat takes one parameter,email. - Inside, clean the email first — strip whitespace and lowercase it, exactly like you did back in Chapter 3.
- Run the same regex and
@count check. returnTrueorFalse.- Add a short docstring.
- Test it against a small list of emails, some valid, some not.
Here's the shape to build from:
pythonimport re def is_valid_email(email): """ Check whether a string is a valid-looking email address. Cleans the input first (strips spaces, lowercases), then checks the pattern and confirms exactly one @ symbol. Returns True or False. """ email = email.strip().lower() has_valid_pattern = bool(re.search(r"[\w.]+@[\w.]+\.\w+", email)) has_one_at_symbol = email.count("@") == 1 return has_valid_pattern and has_one_at_symbol test_emails = [ " Priya.Shah@Company.com ", "raj_kumar@company.com", "amit.verma@@company.com", "not-an-email", ] for email in test_emails: print(f"{email.strip()}: {is_valid_email(email)}")
Output / NotePriya.Shah@Company.com: True raj_kumar@company.com: True amit.verma@@company.com: False not-an-email: False
Notice how little the loop at the bottom needs to know about how the validation actually works — it just calls is_valid_email(email) and trusts the answer. That's the real payoff of a function: the messy details are hidden away in one place, tested once, and every other part of your code gets to stay simple.
This is genuinely the shape of real, reusable validation logic — a function like this could just as easily be called from a script that reads a thousand emails from a CSV file, which is exactly what you'll be doing soon.
Try It Yourself:
Add a second function, is_valid_phone, that takes a phone number as a string and returns True if it contains exactly 10 digits (hint: you can count digits with a comprehension checking char.isdigit(), from Chapter 4). Test it against a few made-up phone numbers, including at least one with letters mixed in.