Python Functions - Silent None Return Crashes
TypeError: can only concatenate str (not 'NoneType') from a function that prints.
- A function is a named, reusable block of code defined with
defthat only runs when you call it by name with parentheses - Parameters are placeholders in the definition; arguments are real values passed at call time — default parameters make arguments optional
returnhands a value back to the caller for storage and reuse —print()only displays and discards, producing None- Variables inside a function are local — pass data in via parameters and out via return, never rely on global mutation
- Default arguments must come after non-default arguments —
def f(a, b=10)works,def f(a=10, b)raises SyntaxError - Biggest production trap: a function that prints but never returns silently gives None to every caller downstream
Think of a function like a vending machine. You walk up, press a button (call the function), and it does a specific job — dispensing a snack — every single time, without you needing to understand how the machine works inside. You can press that button a hundred times and get the same result each time. A Python function is exactly that: a reusable, self-contained set of instructions you can trigger whenever you need them, just by calling its name. And like a vending machine that takes your coin and gives back a snack, a function can take inputs and give back a result.
Every app you've ever used — Spotify, Instagram, Google Maps — is built on thousands of small, focused jobs that run on demand. When you hit Search, something processes your query. When you tap Like, something updates a counter. When a payment goes through, something generates a transaction ID and routes it to half a dozen downstream services. Each of those 'somethings' is a function.
Functions are the fundamental building block of every serious Python program, and understanding them deeply — not just syntactically — is the single biggest leap you'll make early in your Python journey.
Without functions, your code would be a giant wall of repeated instructions. Imagine writing the same 10 lines to calculate a discount every time a user adds something to a cart. Change the discount rule once and you'd have to hunt down every copy, hope you found them all, and pray you edited each one consistently. Functions solve this by letting you write that logic once, give it a name, and call it from anywhere. This is the DRY principle — Don't Repeat Yourself — and functions are the primary mechanism Python gives you to enforce it.
By the end of this article you'll know how to write your own functions, pass information into them, get results back out, and avoid the mistakes that trip up nearly every beginner — including at least one that experienced developers still walk into occasionally.
What a Function Is and How to Build Your First One
A function is a named block of code that only runs when you call it. Python needs four things to create one: the def keyword (short for 'define'), a name you choose, a pair of parentheses, and a colon. Everything indented underneath that colon is the function's body — the actual instructions it will execute.
The `def` keyword is you telling Python: 'store these instructions under this name for later.' Nothing runs at that point. It's like writing a recipe on a card and putting it in a drawer. The recipe doesn't cook itself — you have to take it out and follow it. Calling the function is you following the recipe.
Naming matters more than beginners usually expect. Use lowercase letters with underscores for readability — calculate_tax, not CalculateTax or ct. A good function name reads like a verb phrase because it does something specific. If you can't describe your function in a short verb phrase, it's almost certainly trying to do too many things at once — and that's your cue to split it up.
One thing I'd add that most beginner tutorials skip: a function defined but never called is dead code. It ships to production, takes up space, and executes exactly zero times. In large codebases, dead functions accumulate quietly over years. They confuse new engineers who can't tell whether the function is unused or critical. Delete them, or at minimum add a comment explaining why they exist but aren't being called yet.
IndentationError usually means you mixed spaces and tabs somewhere, or forgot to indent a line that should be inside the function. Most editors can be configured to show whitespace characters, which makes these errors much easier to spot.coverage.py or pytest-cov — to find functions with 0% coverage. Delete them or document exactly why they exist.Passing Information In — Parameters and Arguments Demystified
A function with no inputs is like a vending machine with only one button. Useful, but limited. Parameters let you feed information into a function so it can work with different data each time — which is what makes functions genuinely powerful rather than just convenient shorthand for repeated code.
A parameter is the placeholder name inside the function definition. An argument is the actual value you pass in when you call the function. People use these words interchangeably in conversation and that's fine, but knowing the distinction will help you during code reviews, reading error messages, and technical interviews.
You can define as many parameters as you need, separated by commas. Python also supports default parameter values — if a caller doesn't provide an argument for a parameter that has a default, the function uses the default instead. This makes your functions flexible without requiring callers to always supply every piece of data. Think of it like a coffee order: if you don't specify milk, the barista uses the default. You can always override it, but you don't have to specify it every single time.
Keyword arguments are worth knowing early. Instead of passing arguments by position, you can pass them by name: calculate_final_price(discount_percent=20, original_price=50). The order no longer matters because Python matches by name. This makes calls with multiple parameters much easier to read and nearly impossible to mix up — particularly valuable when a function has four or five parameters and positional order is hard to remember.
One trap that catches people who've used other languages: Python's mutable default argument. If you use a list or dictionary as a default parameter value, that object is created exactly once — at function definition time, not at each call. Every invocation that uses the default shares the same object. If any call mutates it, the next call starts with the mutated version. The fix is simple but non-obvious: use None as the default and create a fresh object inside the function body.
calculate_final_price(discount_percent=20, original_price=50). The position no longer matters because Python matches by name. This is especially valuable when a function has several parameters and positional order is hard to remember — or when reading the call site six months later and you need to understand what each value is without going back to the definition. It also makes it much harder to accidentally swap two values of the same type.Getting Information Back Out — The return Statement
So far our functions print things, but printing and returning are completely different operations. displays text on your screen — the value is shown once and then gone. print()return hands the value back to the caller, where it can be stored in a variable, used in a calculation, passed into another function, or sent across a network.
This is the difference between a calculator that shows you the answer on its display (useful, but the moment you clear it the answer is gone) versus one that writes the answer on a receipt you can take with you, add to another number, or hand to someone else.
A function stops executing the moment it hits a return statement — any code written after that line in the same function won't run. You can have multiple return statements inside a function, typically inside if/else branches, which lets you return different values based on different conditions. Just make sure every branch returns something and that all branches return the same type — inconsistent return types are one of the things that makes callers fragile.
If a function reaches the end of its body without hitting any return statement, Python silently returns None. No warning, no error — just None. This is the most common source of silent bugs I've seen in production Python: a function that looks correct during manual testing because print() shows the expected output on screen, but every caller quietly receives None and breaks downstream.
One pattern worth knowing early: returning multiple values. Python lets you return more than one value by separating them with commas — return width, height. Python wraps them in a tuple automatically, and the caller can unpack them cleanly: w, h = . This is idiomatic Python and appears constantly in professional code.get_dimensions()
def classify_temperature(celsius: float) -> str: — makes the contract explicit and catchable by mypy before anything ships.print() makes the output appear on screen — the developer sees the right value and assumes the function works. It only breaks when the caller tries to store or use the return value in the next step.result = my_function() and get None, the function is using print() where it should be using return. The fix is always the same — add the return statement. The diagnostic is also always the same — store the return value in a test and assert it isn't None.print() displays and discards. They are completely different operations and should never be confused.print() — it shows text on screen immediately and is right for user-facing outputprint() produces None, return produces the actual value the caller needsprint() for the user-facing message, return for the data. Never make print() the only output mechanism for a function that has callers depending on its result.Variable Scope — Why Your Variable Disappears Outside the Function
Scope is one of those concepts that trips up nearly every beginner, but the mental model is simple once it clicks: variables created inside a function live only inside that function. They're born when the function is called and they disappear when the function returns. Code outside the function cannot see or access them.
This is called local scope. Variables defined outside all functions live in global scope and can be read from anywhere in the same file. Modifying them from inside a function requires the global keyword — which you should almost always avoid. Global mutation creates invisible dependencies: Function A modifies a global, Function B reads the same global, and now the behaviour of B depends on whether A ran first and in what order. Under concurrency, that becomes a race condition.
The right pattern is always the same: pass data in via parameters, get data out via return. Think of a function as a sealed workshop. You pass raw materials in through a hatch (parameters), work happens inside, and finished goods come back out (return). The workshop doesn't secretly rearrange your living room while it works, and your living room can't interfere with what's happening inside the workshop.
Python actually resolves variable names by searching through four scopes in order — local, enclosing, global, built-in — known as the LEGB rule. Understanding this explains why a variable inside a nested function can see variables from the outer function (enclosing scope), and why you can always use len or print without importing them (built-in scope). When you define a local variable with the same name as a global, the local one wins inside the function — this is called shadowing, and it's usually a sign you've accidentally reused a name.
- Local variables are born when the function is called and die when it returns — they exist only inside the function body
- Global variables can be READ from inside a function, but modifying them requires the global keyword — which creates invisible coupling and should almost always be avoided
- The LEGB rule describes Python's name resolution order: Local → Enclosing → Global → Built-in — Python searches in this order and stops at the first match
- Using global creates hidden connections between functions — a mutation in one function silently changes what another function sees, which is especially dangerous under concurrency
- If you feel the urge to use global, your function almost certainly needs a parameter instead — or the state belongs in a class
Silent None Injection Crashes Downstream Payment Pipeline
print() to display the transaction ID during development but had no return statement. Python silently returned None. The email template tried to concatenate None into a formatted string, which raises TypeError on every single call. The bug was completely invisible during manual testing because print() showed the ID on screen — the developer never tried to store the return value in a variable or pass it downstream. The function looked correct from the terminal. It wasn't.print(transaction_id) to return transaction_id in the calculate_transaction function. Added a unit test that asserts the return value is a non-None string. Added a type annotation (-> str) to the function signature so mypy would flag callers expecting a string. Implemented a linter rule equivalent to flake8-bugbear B905 to catch functions that print values but never return them.- A function that prints but never returns silently produces None — this is the most common source of silent bugs in Python codebases and it won't show up until something downstream tries to use the value
- Always test the return value of a function, not just its printed output — store it in a variable and assert its type and content in a unit test
- print() is for debugging and user-facing messages; return is for data flow — they are completely different operations and should never be confused in production code
- Type annotations on function signatures are a lightweight safety net —
-> strwon't stop a bug at runtime but mypy will catch callers misusing the return value before code ships
print() instead of return — or has a code path that falls off the end without hitting a return statement. Add an explicit return on every branch, including edge cases.global keyword in the function body. Refactor to pass the data in as a parameter and return the modified value — global mutation creates invisible coupling that breaks under load and concurrency.Key takeaways
def defines a functionreturn hands a value back to the caller so it can be stored, passed on, or computed withprint(), which only displays and discards. A function with no return statement silently returns None.Common mistakes to avoid
5 patternsConfusing print() with return
result = my_function() — gives None. Downstream code crashes with TypeError or AttributeError when it tries to use None as if it were a real value. The developer looks at the terminal, sees the right output, and is confused why downstream is broken.print() for a user-facing message and return for the data that callers need. But return is never optional when something downstream depends on the value. Add a unit test that stores the return value in a variable and asserts it is not None.Calling a function before defining it
if __name__ == '__main__': block at the bottom.Putting a default argument before a non-default argument
def register_user(username, role='viewer') works correctly. def register_user(role='viewer', username) raises SyntaxError immediately. The rule is: required parameters first, optional parameters last.Using a mutable object — list, dict, set — as a default argument
def append_item(item, target_list=None): if target_list is None: target_list = []. This ensures each call gets an independent object. Some teams enforce this via a linter rule — it's consistent enough a mistake that automation catches it reliably.Using the global keyword to share state between functions
Interview Questions on This Topic
What's the difference between a parameter and an argument in Python? Can you give a concrete example?
def greet(name):, name is the parameter. In greet('Alice'), 'Alice' is the argument. Parameters define the contract the function advertises; arguments fulfill that contract at call time. The distinction matters in code reviews and error messages — Python's TypeError messages will tell you about missing or unexpected arguments, not parameters.Frequently Asked Questions
That's Functions. Mark it forged?
7 min read · try the examples if you haven't