Defining and calling functions in Python is where a script turns into software you can reuse. A def statement creates a function object and binds it to a name; the parentheses you add at the call site are what actually run the body.
What does def actually do?
When Python reaches a def line, it does three things: it compiles the body into a code object, it builds a function object that holds that code, and it binds the name you wrote to that object. The body itself does not run. That is the whole difference between defining and calling a function, and it explains why a function can exist in your file without any output appearing until you invoke it.
Because the binding is just a name, you can rebind it. Running the same def statement twice replaces the name with a brand-new function object, and any reference you captured earlier still points at the old one.
def greet(name):
"""Return a greeting for name."""
return f"Hello, {name}!"
print(greet("Ada"))
print(greet("Grace"))
# The name now points at a different function object
def greet(name):
"""A newer, louder greeting."""
return f"HEY {name.upper()}!"
print(greet("Ada"))
print(greet.__doc__)
That is also why a def inside a loop or inside another function is a common source of confusion. The name is created fresh each time the statement executes, and the last one wins.
The function object carries a little metadata about where it came from. greet.__module__ tells you the module it was defined in and greet.__qualname__ gives the dotted path, which is how tracebacks can point at Outer.<locals>.inner when a function is nested. None of this changes how you call the function, but it explains why a function printed to the console looks more like a small record than like a block of code.
def greet(name):
"""Return a greeting for name."""
return f"Hello, {name}!"
print(greet.__module__)
print(greet.__qualname__)
print(callable(greet))
print(greet("Ada"))
Type hints are optional in Python, but they earn their place as soon as a function has more than one or two parameters. The annotation def greet(name: str) -> str: is ignored at runtime, yet it tells your editor exactly what to expect and lets a type checker catch a mismatch before you run anything.
Why does the body only run when you call it?
Calling a function means writing the name, then parentheses, then arguments. Everything inside the body executes top to bottom, in a fresh local scope, with its own set of variable names. When the body reaches a return, the value on the right-hand side is handed back to the caller and the function stops.
Arguments are evaluated at the moment of the call, in the scope of the caller. That detail matters more than it sounds: if you pass a variable and then change it before the function body runs, the function sees the new value, because evaluation and execution are separate steps.
def show(value):
print("function sees:", value)
number = 10
show(number) # function sees: 10
number = 20
show(number) # function sees: 20
Two habits separate people who write maintainable Python from people who fight it. First, functions that return a value are easy to test and reuse; functions that only print force the caller to scrape text off the screen. Second, a function should do one job, named so the name reads like a sentence: calculate_total, parse_config, send_welcome_email.
The single most common beginner mistake here is a function that both prints and returns, so the caller gets a value but also sees output on the terminal. Test it later and the print output pollutes your test log, while the returned value is what the logic actually depends on. Pick one: a function that computes returns, and the outermost layer of your program decides what to print.
How do you call a function?
Python gives you several ways to pass arguments, and the differences show up in every real codebase.
| Call style | What it means | Example |
|---|---|---|
| Positional | Values are matched to parameters by position | greet("Ada") |
| Keyword | Values are matched by parameter name, in any order | greet(name="Ada") |
| Mixed | Positional first, keyword after | greet("Ada", greeting="Hi") |
| Default | Parameter has a value already, so you may omit it | greet("Ada", punctuation="?") |
| Unpacking | Values arrive from a list or dict | greet(*names), greet(**options) |
Keyword arguments make call sites self-documenting. If a function takes five parameters, send(to=addr, subject=title, body=msg) tells the next reader exactly what each value means without opening the definition.
A function with no return statement still returns something: None. That is why result = compute() followed by print(result) prints the word None, and why you should check for None with is None rather than a truth test. The next two lessons dig into argument binding and return values in detail; for now the important habit is to read a function's signature before you call it.
Can a function live in a variable or a dict?
Yes. Functions are objects, exactly like integers and strings. You can assign one to a variable, put it in a list or dict, pass it to another function, or return it. This is the single most useful thing to understand about Python functions, because it lets you replace long if/elif chains with a lookup table.
def add(a, b):
return a + b
def multiply(a, b):
return a * b
operations = {"add": add, "multiply": multiply}
for name, op in operations.items():
print(name, "->", op(3, 4))
That pattern shows up everywhere in production code: a plugin registry, a command parser, a framework that maps HTTP routes to handler functions. The dict on the right of the assignment holds functions, so operations["add"](3, 4) works exactly like add(3, 4) does.
The same property lets you pass behaviour into a function. A function that accepts another function as a parameter is the foundation of decorators, of event handlers, and of any sort of pluggable pipeline. You will meet that style again in the advanced-functions module, but the core idea is just this: functions are values, and values can be passed around.
How do you document a function so help() works?
Put a string literal as the first statement in the body and it becomes the function's docstring, stored on the __doc__ attribute. It costs one line and shows up in help(), in your editor's hover tooltip, and in generated API documentation. Describe what the function returns, not how it works internally.
import inspect
def greet(name, greeting="Hello", punctuation="!"):
"""Return a greeting for name, with a custom opener and ending mark."""
return f"{greeting}, {name}{punctuation}"
print(greet.__doc__)
print(inspect.signature(greet))
print(greet("Ada"))
print(greet(greeting="Hi", name="Grace", punctuation="?"))
inspect.signature is the tool to reach for when you cannot remember a function's parameters. It prints the exact signature, defaults included, which is a faster answer than scrolling through a file.
Quick reference
Three mistakes account for most function bugs in new code:
- Forgetting the parentheses. Writing
greetgives you the function object; writinggreet()runs it with no arguments. The difference shows up as<function greet at 0x...>in a printout instead of your text. - Expecting a return value that never comes. A function with no
returnhands backNone, and every operation onNonefails with anAttributeErrororTypeErrorthat points at the wrong line. - Rebinding a function name by accident. If you write
greet = something_elseinside a function, the name is now local and the outer one is untouched until you sayglobal.
Try it yourself: write a function describe_order(item, price, quantity, discount=0) that returns a formatted summary line, then call it four ways — positionally, with keywords, mixing both, and with the discount omitted. Add a docstring, then use inspect.signature to print the signature before you call it. Finally, put two different functions into a dict and drive them from a loop, so you practise treating functions as values rather than as things you only call.