Python Functions - Silent None Return Crashes
TypeError: can only concatenate str (not 'NoneType') from a function that prints.
20+ years shipping production Python across data and backend systems. Drawn from code that ran under real load.
- 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.