Functions are how you turn 50 lines of scripted code into reusable building blocks. Modules and packages are how you split a 500-line file into a project. This post covers everything you need to write professional-shaped Python: def, argument styles, scope, lambda, modules, packages, import, and pip-installable libraries.

Series map: 00 Setup → 01 Syntax → 02 Data Structures → 03 Functions & Modules (this post) → 04 OOP → 05 Files & Exceptions → (future).


Learning Outcome

  • Define functions with positional, keyword, default, *args, **kwargs.
  • Understand scope (LEGB rule) and the global / nonlocal keywords.
  • Use lambda for short anonymous functions.
  • Organise code into modules + packages with __init__.py.
  • Use import, from ... import, and aliasing.
  • Understand if __name__ == "__main__".
  • Install and use third-party packages from PyPI.

Chapter 1 — Defining functions

Basic

1
2
3
4
5
6
def greet(name):
"""Return a greeting string."""
return f"Hello, {name}!"

print(greet("Ada")) # Hello, Ada!
  • def introduces a function.
  • Indented body required.
  • Triple-quoted string at the top is the docstring.
  • return exits and gives back a value. No return = returns None.

Default arguments

1
2
3
4
5
6
def power(base, exp=2):
return base ** exp

power(5) # 25
power(5, 3) # 125

Keyword arguments

1
2
3
4
5
6
7
def make_url(host, path="/", port=80, secure=False):
scheme = "https" if secure else "http"
return f"{scheme}://{host}:{port}{path}"

make_url("example.com", secure=True, port=443)
# 'https://example.com:443/'

Keyword args make calls self-documenting. Prefer them over positional when 3+ args.


Chapter 2 — Variadic arguments

*args — extra positional args

1
2
3
4
5
6
def total(*nums):
return sum(nums)

total(1, 2, 3) # 6
total(*[1, 2, 3]) # 6 (unpacked from list)

Inside the function, nums is a tuple.

**kwargs — extra keyword args

1
2
3
4
5
6
def configure(**options):
for k, v in options.items():
print(k, "=", v)

configure(host="localhost", port=8080, debug=True)

Inside, options is a dict.

Combining everything

1
2
3
4
5
6
def f(a, b, *args, c=10, **kwargs):
print(a, b, args, c, kwargs)

f(1, 2, 3, 4, c=20, name="x")
# 1 2 (3, 4) 20 {'name': 'x'}

Order: positional → *args → keyword-only → **kwargs.

How the arguments of one call fill positional parameters, *args, a keyword-only parameter and **kwargs

Keyword-only and positional-only

1
2
3
def func(pos_only, /, normal, *, kw_only):
...
  • Before / — must be positional.
  • After * — must be keyword.

Trick: using * to force keyword-only args (def f(*, debug=False)) prevents callers from passing True mysteriously without context. Great for boolean flags.


Chapter 3 — Scope (LEGB rule)

When Python looks up a name, it checks in this order:

  1. Local — inside the current function.
  2. Enclosing — outer functions (if nested).
  3. Global — at the module's top level.
  4. Built-in — print, len, etc.
1
2
3
4
5
6
7
8
9
10
11
12
13
x = "global"

def outer():
x = "enclosing"
def inner():
x = "local"
print(x) # "local"
inner()
print(x) # "enclosing"

outer()
print(x) # "global"

Modifying outer scope

1
2
3
4
5
6
counter = 0

def increment():
global counter
counter += 1 # without `global`, raises UnboundLocalError
1
2
3
4
5
6
7
8
def outer():
x = 1
def inner():
nonlocal x
x += 1 # modifies outer function's x
inner()
print(x) # 2

global and nonlocal are escape hatches. Prefer returning values to mutating outer state.

Python name lookup through Local, Enclosing, Global and Built-in scopes, and what global and nonlocal change


Chapter 4 — lambda (anonymous functions)

A lambda is a single-expression function without a name.

1
2
3
add = lambda a, b: a + b
add(3, 4) # 7

Usually used inline:

1
2
3
4
sorted(words, key=lambda w: len(w))
sorted(records, key=lambda r: r["age"])
list(filter(lambda n: n > 0, nums))

Don't assign a lambda to a name — define a proper function instead. Lambdas are for passing simple functions inline.


Chapter 5 — Modules

A module is just a .py file. The file math_helpers.py:

1
2
3
4
5
6
7
8
9
# math_helpers.py
PI = 3.14159

def area_circle(r):
return PI * r * r

def area_square(s):
return s * s

In another file:

1
2
3
4
5
import math_helpers

print(math_helpers.area_circle(5))
print(math_helpers.PI)

Or import specific names:

1
2
3
from math_helpers import area_circle, PI
print(area_circle(5))

Or rename:

1
2
3
4
5
import math_helpers as mh
from math_helpers import area_circle as circle_area
import numpy as np # canonical alias
import pandas as pd # canonical alias

Avoid from module import *

It pollutes your namespace and makes it impossible to tell where a name came from. Only acceptable in REPL exploration.


Chapter 6 — The if __name__ == "__main__" block

When you run python script.py, Python sets __name__ == "__main__" inside that file. When you import script from elsewhere, __name__ == "script".

1
2
3
4
5
6
7
# script.py
def main():
print("doing work")

if __name__ == "__main__":
main()

This lets the file be both importable (no side effects on import) and runnable (executes when run directly).

Trick: every executable script should have this block. Tests and other modules can then import its functions without triggering the whole script.


Chapter 7 — Packages

A package is a directory of modules with an __init__.py file.

1
2
3
4
5
6
7
8
mypkg/
__init__.py
math_helpers.py
string_helpers.py
utils/
__init__.py
files.py

Import:

1
2
3
from mypkg import math_helpers
from mypkg.utils.files import read_file

__init__.py

Often empty (it just marks the directory as a package). Sometimes used to re-export key names:

1
2
3
4
# mypkg/__init__.py
from .math_helpers import area_circle, PI
from .string_helpers import slugify

Now from mypkg import area_circle works directly.

Relative imports inside a package

1
2
3
4
# mypkg/utils/files.py
from ..math_helpers import PI # .. = parent package
from . import other # . = same package

Chapter 8 — Third-party packages from PyPI

1
2
3
4
pip install requests
pip install rich
pip install numpy pandas matplotlib

(Always inside a venv — see Post 00!)

Then use them:

1
2
3
4
import requests
resp = requests.get("https://api.github.com")
print(resp.json()["current_user_url"])

requirements.txt

1
2
3
4
requests>=2.28
rich>=13.0
numpy

Install:

1
2
pip install -r requirements.txt

Modern alternative: pyproject.toml

Tools like Poetry, Hatch, uv use pyproject.toml instead of requirements.txt. For your first projects, stick with requirements.txt. Migrate later.


Chapter 9 — Function design tips

  1. One purpose per function. If you can't name it cleanly, it does too much.
  2. Return values, don't print (except for genuinely user-facing functions). Lets the caller decide what to do.
  3. Keep parameter count low. 5+ args = it's time to wrap them in a dataclass or dict.
  4. Document with a docstring. Even one line beats nothing.
  5. Avoid hidden side effects. A function called calculate_total shouldn't write to the DB.

Where I got stuck: I once wrote a 200-line function that did 12 things. Refactoring it to 12 small functions took 30 minutes and the test failures dropped 80% because each piece was independently testable.


Practice Exercises

Exercise 1 — Variadic mean

Write mean(*nums) that returns the average of any number of arguments. Return 0 for empty input.

Solution
1
2
3
def mean(*nums):
return sum(nums) / len(nums) if nums else 0

Exercise 2 — Higher-order: apply

Write apply(fn, *args, **kwargs) that calls fn(*args, **kwargs) and returns the result. (Pointless? Yes. But it's the world's smallest higher-order function.)

Solution
1
2
3
4
5
6
def apply(fn, *args, **kwargs):
return fn(*args, **kwargs)

apply(max, 1, 5, 3) # 5
apply(sorted, [3, 1, 2], reverse=True) # [3, 2, 1]

Exercise 3 — Build a small module

Create text_utils.py with three functions:

  • word_count(s) → int
  • reverse_words(s) → str (reverses order of words, not letters)
  • is_palindrome(s) → bool (case + space insensitive)

Then write main.py that imports and tests each.

Solution sketch
1
2
3
4
5
6
7
8
9
10
11
# text_utils.py
def word_count(s):
return len(s.split())

def reverse_words(s):
return " ".join(reversed(s.split()))

def is_palindrome(s):
cleaned = "".join(c.lower() for c in s if c.isalnum())
return cleaned == cleaned[::-1]

Exercise 4 — Recursive factorial

Write a recursive function fact(n) that returns n!. Add a guard for n < 0.

Solution
1
2
3
4
5
def fact(n):
if n < 0:
raise ValueError("n must be non-negative")
return 1 if n <= 1 else n * fact(n - 1)

Exercise 5 — Decorator preview (advanced)

Write a function timed(fn) that takes another function, and returns a new function that prints how long fn takes when called.

Solution
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import time

def timed(fn):
def wrapper(*args, **kwargs):
start = time.perf_counter()
result = fn(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{fn.__name__} took {elapsed:.4f}s")
return result
return wrapper

# usage
slow_sum = timed(sum)
slow_sum(range(10_000_000))

This is the foundation of decorators — covered in a future advanced post.


Cheat-sheet

What Example
Define function def f(x): return x
Default arg def f(x=0):
*args tuple of extra positionals
**kwargs dict of extra keywords
Keyword-only def f(*, name):
Unpacking call f(*list, **dict)
Global modify global x inside function
Nonlocal modify nonlocal x inside nested fn
Lambda lambda x: x + 1
Import module import mod
Import name from mod import name
Alias import mod as m
Relative import from .sibling import x
Main guard if __name__ == "__main__":
Install package pip install name

What's next

Post 04 covers object-oriented Python: classes, instances, __init__, methods, inheritance, properties, dunder methods, and the modern @dataclass decorator.


References

Note: this post is in progress. Planned additions: closures explained visually, functools.partial, type hints for functions.