← Back to Course Index
Lesson 2 of 10

Functions, Modules & Scope

~20 min · def, *args, **kwargs, imports, closures

Ref
Primary Source
Official Docs — Defining Functions (section 4.7–4.8)

Covers default arguments, keyword arguments, *args, **kwargs, and unpacking. Short and dense — worth reading after this lesson.

1 — Defining Functions

The core syntax is similar to JS — just different keywords and indentation.

JavaScript
function add(a, b) {
  return a + b;
}

// Arrow function
const add = (a, b) => a + b;

// Default params
function greet(name = "World") {
  return `Hello, ${name}!`;
}

// No explicit return = undefined
function nothing() {}
Python
def add(a, b):
    return a + b

# Lambda (limited to one expression)
add = lambda a, b: a + b

# Default params
def greet(name="World"):
    return f"Hello, {name}!"

# No explicit return = None
def nothing():
    pass  # 'pass' is required for empty blocks
⚠ Mutable Default Arguments — Classic Trap

Never use a mutable type (list, dict) as a default argument. It's shared across all calls:

# BAD — the list is created ONCE and reused
def append_to(item, lst=[]):
    lst.append(item)
    return lst

append_to(1)  # [1]
append_to(2)  # [1, 2]  ← surprise!

# GOOD — use None as sentinel
def append_to(item, lst=None):
    if lst is None:
        lst = []
    lst.append(item)
    return lst

2 — *args and **kwargs

These are Python's equivalent of JS's ...rest — but more powerful because they work for both positional and keyword arguments.

# *args — captures extra positional args as a tuple
def log(*args):
    for item in args:
        print(item)

log("error", "file not found", 404)
# "error"
# "file not found"
# 404

# **kwargs — captures extra keyword args as a dict
def create_user(**kwargs):
    print(kwargs)

create_user(name="Alice", age=30, admin=True)
# {"name": "Alice", "age": 30, "admin": True}

# Combining both
def mixed(required, *args, **kwargs):
    print(required, args, kwargs)

mixed("hi", 1, 2, x=10, y=20)
# "hi" (1, 2) {"x": 10, "y": 20}

Unpacking — the reverse direction

def add(a, b, c):
    return a + b + c

nums = [1, 2, 3]
add(*nums)        # unpacks list → add(1, 2, 3)

config = {"a": 1, "b": 2, "c": 3}
add(**config)     # unpacks dict → add(a=1, b=2, c=3)

3 — Keyword-Only & Positional-Only Arguments

Python lets you force callers to use argument names — no equivalent in JS.

# After the *, all args MUST be passed by name
def send_email(to, *, subject, body):
    pass

send_email("alice@x.com", subject="Hi", body="Hello")  # ✅
send_email("alice@x.com", "Hi", "Hello")               # ❌ TypeError

# / makes args before it positional-ONLY (Python 3.8+)
def distance(x, y, /):
    return (x**2 + y**2) ** 0.5

distance(3, 4)         # ✅
distance(x=3, y=4)    # ❌ TypeError

4 — Scope: LEGB Rule

Python resolves names in this order: Local → Enclosing → Global → Built-in. Similar to JS's scope chain.

JavaScript
let x = "global";

function outer() {
  let x = "outer";
  function inner() {
    // Closure — sees outer x
    console.log(x); // "outer"
  }
  inner();
}

// Modify outer from inner
function counter() {
  let count = 0;
  return () => {
    count++;       // closure captures count
    return count;
  };
}
Python
x = "global"

def outer():
    x = "outer"
    def inner():
        # Closure — sees outer x
        print(x)  # "outer"
    inner()

# Modify outer from inner — needs 'nonlocal'
def counter():
    count = 0
    def increment():
        nonlocal count   # ← required to reassign
        count += 1
        return count
    return increment
💡 nonlocal vs global

Use nonlocal to reassign a variable from an enclosing (but not global) scope. Use global to reassign a module-level variable. Both are rarely needed — if you find yourself using them a lot, refactor to a class or pass the value explicitly.

5 — The Module System

Python's import system is like JS require() / import — but the mental model is different. Every .py file is a module. Every directory with an __init__.py is a package.

JavaScript (ESM)
// Named export
export function add(a, b) { return a + b; }
export const PI = 3.14;

// Default export
export default class Foo {}

// Import
import { add, PI } from './math.js';
import Foo from './foo.js';
import * as math from './math.js';
Python
# math_utils.py — everything is exported by default
def add(a, b):
    return a + b

PI = 3.14

# Importing
import math_utils           # access as math_utils.add()
from math_utils import add  # like named import
from math_utils import *    # import everything (avoid this)
import math_utils as mu     # alias

# There's no "default export" — just import by name

The Standard Library — batteries included

import os           # operating system interface
import sys          # system-specific params
import json         # JSON encode/decode
import math         # math functions
import re           # regular expressions
import datetime     # dates and times
from pathlib import Path  # modern file path handling
from collections import defaultdict, Counter
💡 if __name__ == "__main__"

This is Python's entry-point guard — equivalent to checking if a file is the "main" file in Node.js. Code inside this block only runs when the file is executed directly, not when it's imported.

def main():
    print("Running!")

if __name__ == "__main__":
    main()

🧠 Quiz — Retrieval Practice

1. What does *args capture in a function call?

2. What is the mutable default argument trap? Best fix?

3. To reassign a variable from an enclosing function scope, you use:

4. What does if __name__ == "__main__": guard against?

0/4

Questions answered correctly.