Functions #
A function is a named block of code that can be called repeatedly and helps break large programs into small, easy-to-understand, easy-to-test units. In Python, functions are first-class objects — meaning they can be stored in variables, passed as arguments, returned from other functions, and stored in data structures like lists or dicts. This flexibility makes Python functions far more powerful than in many other languages. This article covers every aspect of Python functions — from basic syntax and parameter types to closures and lambdas.
Defining and Calling Functions #
A function is defined with the def keyword, followed by the function name, parentheses containing parameters, a colon, and an indented code block.
# Basic function definition
def greet():
"""Prints a simple greeting."""
print("Hello, welcome!")
# Calling the function
greet() # → Hello, welcome!
# Function with parameters and a return value
def add(a, b):
"""Returns the sum of two numbers."""
return a + b
result = add(3, 5)
print(result) # → 8
# A function without return implicitly returns None
def print_name(name):
print(f"Name: {name}")
result = print_name("Budi")
print(result) # → None
How Parameters Are Passed: Call-by-Object #
Python uses a parameter-passing mechanism called Call-by-Object (or Pass-by-Assignment). When you pass an argument into a function, Python copies the reference to that object into the function’s parameter. Whether changes inside the function affect the outside depends entirely on whether the object is mutable or immutable.
Look at the mutation-effect visualization below:
flowchart TD
subgraph Immutable ["Immutable Object (e.g. int, str)"]
i_call["Pass Variable a = 10"] --> i_func["Function Receives Parameter x"]
i_func -->|x = 20| i_rebind["x Points to a New Object 20"]
i_rebind --> i_result["Original Variable a Stays 10"]
end
subgraph Mutable ["Mutable Object (e.g. list, dict)"]
m_call["Pass Variable b = [1]"] --> m_func["Function Receives Parameter y"]
m_func -->|"y.append(2)"| m_mutate["Modifies the Same Object in the Heap"]
m_mutate --> m_result["Original Variable b Becomes [1, 2]"]
endBased on the diagram above, modifying a mutable object inside a function affects the original variable outside, because both share the same object reference in heap memory. Conversely, changing the value of an immutable object only redirects the local parameter to a new object, leaving the original intact.
Parameter Types #
Python has a very flexible parameter system. Understanding all its forms is the key to writing clean, expressive function APIs.
Positional Parameters #
Standard parameters whose values are assigned by order at call time:
def divide(dividend, divisor):
return dividend / divisor
print(divide(10, 2)) # → 5.0 (positional: 10→dividend, 2→divisor)
Keyword Arguments #
When calling a function, you can name the parameters explicitly — allowing a different order and making the code more readable:
def create_profile(name, age, city):
return f"{name}, {age} years old, from {city}"
# Positional — the order must be exact
print(create_profile("Budi", 25, "Jakarta"))
# Keyword — free order, more explicit
print(create_profile(age=25, city="Jakarta", name="Budi"))
# Mixed — positional must come before keyword
print(create_profile("Budi", city="Jakarta", age=25))
Default Parameters #
Default values are used when an argument isn’t provided at call time:
def db_connect(host="localhost", port=5432, ssl=False):
print(f"Connecting to {host}:{port} (SSL: {ssl})")
db_connect() # → localhost:5432 (SSL: False)
db_connect("db.prod.com") # → db.prod.com:5432 (SSL: False)
db_connect("db.prod.com", ssl=True) # → db.prod.com:5432 (SSL: True)
db_connect("db.prod.com", 5433, True) # → db.prod.com:5433 (SSL: True)
Anti-Pattern: Mutable Default Argument #
This is one of Python’s most famous traps — using a mutable object (list, dict, set) as a default parameter value:
# ANTI-PATTERN: a list as default — shared across ALL calls!
def add_item(item, cart=[]):
cart.append(item)
return cart
print(add_item("apple")) # → ['apple']
print(add_item("orange")) # → ['apple', 'orange'] ← surprising!
print(add_item("mango")) # → ['apple', 'orange', 'mango'] ← old data keeps piling up!
# Why? Because the default value is evaluated ONCE when the function is defined,
# not every time the function is called. That default list is shared.
# CORRECT: use None as a sentinel, create a new object inside the function
def add_item(item, cart=None):
if cart is None:
cart = []
cart.append(item)
return cart
print(add_item("apple")) # → ['apple']
print(add_item("orange")) # → ['orange'] ← correct, a fresh list each time
Never use[],{}, orset()as a default parameter value. Always useNoneand create a new object inside the function body. This applies to every mutable object — including custom class instances.
*args — Unlimited Positional Arguments
#
*args captures all extra positional arguments into a tuple:
def sum_all(*numbers):
"""Sum any number of positional arguments."""
print(type(numbers)) # → <class 'tuple'>
return sum(numbers)
print(sum_all(1, 2, 3)) # → 6
print(sum_all(1, 2, 3, 4, 5)) # → 15
print(sum_all()) # → 0
# *args can be combined with regular parameters — but must come after
def log(level, *messages):
print(f"[{level}]", *messages)
log("INFO", "Server", "started") # → [INFO] Server started
log("ERROR", "Connection", "timed", "out") # → [ERROR] Connection timed out
**kwargs — Unlimited Keyword Arguments
#
**kwargs captures all extra keyword arguments into a dict:
def print_info(**data):
"""Print all the keyword arguments given."""
print(type(data)) # → <class 'dict'>
for key, value in data.items():
print(f" {key}: {value}")
print_info(name="Budi", age=25, city="Jakarta")
# → name: Budi
# → age: 25
# → city: Jakarta
# Useful for functions that forward kwargs to another function
def create_connection(**config):
# config can hold host, port, user, password, etc.
return Database(**config)
The Correct Parameter Order #
Python enforces a strict order for parameter types in a function definition. To visualize how Python splits parameters by how they’re passed (position vs keyword), look at the parameter-boundary map below:
flowchart LR
p_only["Positional-only"] --> Slash["Boundary /"]
Slash --> Normal["Normal (Positional or Keyword)"]
Normal --> Asterisk["Boundary *"]
Asterisk --> k_only["Keyword-only"]With these boundaries, you can design safer function interfaces, where sensitive parameters are forced to be passed explicitly by keyword, while quick utility parameters are restricted to positional only.
Here is the syntactically correct parameter order:
# The correct order:
# 1. Regular positional
# 2. *args
# 3. Keyword-only (after *args)
# 4. **kwargs
def full_function(pos1, pos2, *args, kw_only1, kw_only2="default", **kwargs):
print(f"pos1={pos1}, pos2={pos2}")
print(f"args={args}")
print(f"kw_only1={kw_only1}, kw_only2={kw_only2}")
print(f"kwargs={kwargs}")
full_function(
"a", "b", # pos1, pos2
"c", "d", # go into args
kw_only1="required", # keyword-only (required because it has no default)
extra="bonus" # goes into kwargs
)
Keyword-Only Parameters #
Parameters after * (or after *args) can only be filled by keyword, not positionally:
# Use a bare * to force all parameters after it to be keyword-only
def send_email(to, subject, *, cc=None, bcc=None, priority="normal"):
print(f"Sending to {to}: {subject}")
if cc:
print(f"CC: {cc}")
# CORRECT: keyword-only parameters are filled with keywords
send_email("[email protected]", "Hello", cc="[email protected]")
# ANTI-PATTERN: trying to fill keyword-only parameters positionally
send_email("[email protected]", "Hello", "[email protected]")
# → TypeError: send_email() takes 2 positional arguments but 3 were given
Positional-Only Parameters (Python 3.8+) #
Parameters before / can only be filled positionally, not by keyword:
# Use / to force the parameters before it to be positional-only
def divide(dividend, divisor, /):
return dividend / divisor
divide(10, 2) # ✓ positional
divide(dividend=10, divisor=2) # ✗ TypeError — keyword not allowed
Return Values (return)
#
A function can return one or many values. Without return, the function returns None.
# Return a single value
def square(x):
return x ** 2
# Return several values (packed as a tuple)
def stats(data):
return min(data), max(data), sum(data) / len(data)
minimum, maximum, average = stats([3, 1, 4, 1, 5, 9, 2, 6])
print(minimum, maximum, average) # → 1 9 3.875
# Return in the middle of a function — for early exit
def safe_divide(a, b):
if b == 0:
return None # early return
return a / b
print(safe_divide(10, 2)) # → 5.0
print(safe_divide(10, 0)) # → None
Type Hints on Functions #
Type hints make a function’s signature clearer and help IDEs provide accurate autocomplete:
from typing import Optional, Union
# Parameter and return type annotations
def compute_area(length: float, width: float) -> float:
return length * width
# Optional — may be None
def find_user(user_id: int) -> Optional[str]:
user = db.get(user_id)
return user.name if user else None
# Union (Python 3.9 and below) or | (Python 3.10+)
def format_price(price: Union[int, float]) -> str:
return f"Rp{price:,.0f}"
def format_price(price: int | float) -> str: # Python 3.10+
return f"Rp{price:,.0f}"
# Collection types
from typing import List, Dict, Tuple
def average(numbers: List[float]) -> float:
return sum(numbers) / len(numbers)
def parse_config(raw: Dict[str, str]) -> Dict[str, int]:
return {k: int(v) for k, v in raw.items()}
# A function that returns nothing
def log_error(message: str) -> None:
print(f"ERROR: {message}")
Lambda — Anonymous Functions #
A lambda is a small, unnamed function defined in a single expression. Useful for simple functions used only once, especially as a key argument in sorting.
# Syntax: lambda parameters: expression
square = lambda x: x ** 2
print(square(5)) # → 25
add = lambda a, b: a + b
print(add(3, 4)) # → 7
# The most common use: as a key in sorted()
students = [
{"name": "Budi", "score": 85},
{"name": "Ani", "score": 92},
{"name": "Citra", "score": 78},
]
# Sort by score
ranked = sorted(students, key=lambda s: s["score"], reverse=True)
for s in ranked:
print(f"{s['name']}: {s['score']}")
# → Ani: 92
# → Budi: 85
# → Citra: 78
# Lambda with filter() and map()
numbers = [1, -2, 3, -4, 5, -6]
positive = list(filter(lambda n: n > 0, numbers)) # → [1, 3, 5]
squares = list(map(lambda n: n**2, numbers)) # → [1, 4, 9, 16, 25, 36]
# ANTI-PATTERN: a complex lambda that should be a regular function
process = lambda data, threshold, scale: [x * scale for x in data if x > threshold]
# CORRECT: a regular function is more readable, can have a docstring, and can be tested
def filter_and_scale(data, threshold, scale):
"""Filter elements > threshold, then multiply by scale."""
return [x * scale for x in data if x > threshold]
Functions as First-Class Objects #
In Python, functions are objects just like ints or strings — they can be stored, passed, and returned:
# Store a function in a variable
def greet(name):
return f"Hello, {name}!"
greeting = greet # not greet() — we're storing the function, not calling it
print(greeting("Budi")) # → Hello, Budi!
# Store functions in a list
def add(a, b): return a + b
def subtract(a, b): return a - b
def multiply(a, b): return a * b
operations = [add, subtract, multiply]
for op in operations:
print(op(10, 3)) # → 13, 7, 30
# Pass a function as an argument (higher-order function)
def apply(func, data):
return [func(x) for x in data]
result = apply(lambda x: x**2, [1, 2, 3, 4, 5])
print(result) # → [1, 4, 9, 16, 25]
# Return a function from a function (function factory)
def make_multiplier(factor):
def multiplier(x):
return x * factor
return multiplier
times3 = make_multiplier(3)
times5 = make_multiplier(5)
print(times3(10)) # → 30
print(times5(10)) # → 50
Closures #
A closure is a function that “remembers” values from the outer scope where it was defined, even after that outer scope has finished executing:
def make_counter(start=0):
count = start # variable in the enclosing scope
def increment(n=1):
nonlocal count # reference to count in make_counter
count += n
return count
def reset():
nonlocal count
count = start
def value():
return count
return increment, reset, value
# Create two independent counters
inc_a, reset_a, val_a = make_counter()
inc_b, reset_b, val_b = make_counter(100)
inc_a()
inc_a()
inc_a(5)
print(val_a()) # → 7
inc_b(50)
print(val_b()) # → 150
reset_a()
print(val_a()) # → 0 (a was reset)
print(val_b()) # → 150 (b is unaffected)
Closures are often used to create functions with different initial configurations without needing a class:
def make_length_validator(min_len, max_len):
"""Creates a configurable string length validator."""
def validate(text):
return min_len <= len(text) <= max_len
return validate
validate_username = make_length_validator(3, 20)
validate_password = make_length_validator(8, 64)
print(validate_username("ab")) # → False (too short)
print(validate_username("budi123")) # → True
print(validate_password("pass")) # → False (too short)
print(validate_password("s3cr3t!pass")) # → True
Unpacking Arguments with * and **
#
The * and ** operators can also be used when calling a function to expand collections into arguments:
def add(a, b, c):
return a + b + c
numbers = [1, 2, 3]
print(add(*numbers)) # equivalent to add(1, 2, 3) → 6
# ** to expand a dict into keyword arguments
config = {"host": "localhost", "port": 5432, "ssl": True}
db_connect(**config) # equivalent to db_connect(host="localhost", port=5432, ssl=True)
# Very useful for forwarding dynamic arguments
def create_user(**data):
return User(**data)
params = {"name": "Budi", "email": "[email protected]", "active": True}
user = create_user(**params)
Summary #
- Don’t use mutable default arguments — always use
Noneand create a new object inside the function. Mutable defaults are shared across all calls.- Keyword-only parameters (after
*) force callers to use names — ideal for optional parameters whose meaning isn’t clear from position.*argsproduces a tuple,**kwargsproduces a dict — both let a function accept a flexible number of arguments.- Type hints don’t change runtime behavior — but they greatly help IDEs, linters, and code readers understand the function contract.
- Lambdas for simple one-expression functions — especially as a
keyargument insorted(),min(),max(). Don’t use lambdas for complex logic.- Functions are first-class objects — can be stored in variables, lists, dicts, passed as arguments, and returned from other functions.
- Closures “remember” the enclosing scope — useful for creating function factories and encapsulated state without a class.
*and**for unpacking at call time — expanding lists into positional args and dicts into keyword args.