My Notes

Python Functions - The Complete Breakdown

Updated 6/29/202612 min readDownload PDFEdit

Python Functions — The Complete Breakdown

So what even is a function?

You've been writing code that runs top to bottom, once, in one shot. That works for small scripts. But real programs do the same things over and over — check if a user is valid, calculate a score, format some output. Writing that logic every time it's needed is how code becomes a 500-line disaster that nobody can read or fix.

A function is the solution. You write the logic once, give it a name, and call that name whenever you need it. Change the logic in one place and it updates everywhere. That's the whole idea.

Python uses the def keyword to define a function.


The absolute basics — defining and calling

def greet():
    print("Hello, world!")

greet()

Output:

Hello, world!

Two steps. First you define it with def. Then you call it by writing its name with (). Nothing happens until you call it — defining a function doesn't run it.

Call it as many times as you want:

greet()
greet()
greet()

Output:

Hello, world!
Hello, world!
Hello, world!

One definition, unlimited calls.


Parameters — giving the function inputs

A function that always does the exact same thing isn't very useful. Parameters let you pass data in so the function can work with it.

def greet(name):
    print(f"Hello, {name}!")

greet("Kalyan")
greet("Jordan")
greet("Alice")

Output:

Hello, Kalyan!
Hello, Jordan!
Hello, Alice!

name is the parameter — a placeholder inside the function. When you call greet("Kalyan"), the string "Kalyan" gets passed in and name holds it for that call.

Multiple parameters

def add(a, b):
    print(a + b)

add(3, 5)
add(10, 20)

Output:

8
30

Parameters are separated by commas. The values you pass in (called arguments) map to parameters in order — first to first, second to second.


return — giving the function an output

So far the functions just print things. But printing is a side effect — the result disappears after it hits the screen. return sends a value back to whoever called the function, so you can store it, use it, pass it somewhere else.

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

result = add(3, 5)
print(result)

Output:

8

The function does the math and hands back 8. You caught it in result and printed it. Now you can use that value anywhere.

Without return, the function gives back None:

def add(a, b):
    a + b    # calculated but thrown away

result = add(3, 5)
print(result)

Output:

None

This is a classic bug. The math happened but return was never called, so None came back instead. Always return if you want the value.


The difference between print and return

This trips up nearly every beginner. They look similar but they're completely different.

def with_print(x):
    print(x * 2)

def with_return(x):
    return x * 2

with_print(5)            # prints 10, returns None
value = with_return(5)   # prints nothing, returns 10
print(value)             # prints 10

Output:

10
10

print displays something to the screen — that's it. return hands a value back to the caller so they can use it. A function with only print is a dead end. A function with return is a building block you can chain together.

In real code, almost every function you write will use return. print is mostly for debugging or final output.


Default parameters — optional arguments

You can give parameters a default value. If the caller doesn't pass anything for that parameter, the default kicks in.

def greet(name, greeting="Hello"):
    print(f"{greeting}, {name}!")

greet("Kalyan")              # uses default
greet("Jordan", "Hey")       # overrides default
greet("Alice", "Yo")

Output:

Hello, Kalyan!
Hey, Jordan!
Yo, Alice!

Default parameters must come after non-default ones. This is a syntax rule — Python needs to know which arguments map where.

def greet(greeting="Hello", name):   # SyntaxError
    pass

Non-default before default. Always.


Keyword arguments — calling by name

Normally arguments map positionally — first value to first parameter, second to second. But you can also call a function using the parameter names explicitly.

def describe(name, age, city):
    print(f"{name} is {age} years old and lives in {city}.")

describe("Kalyan", 22, "Boston")                          # positional
describe(age=22, city="Boston", name="Kalyan")            # keyword — order doesn't matter
describe("Kalyan", city="Boston", age=22)                 # mixed — positional first

Output:

Kalyan is 22 years old and lives in Boston.
Kalyan is 22 years old and lives in Boston.
Kalyan is 22 years old and lives in Boston.

Keyword arguments make function calls self-documenting. When you see city="Boston" you immediately know what that argument means without counting positions.


*args — accepting any number of positional arguments

Sometimes you don't know how many arguments will be passed. *args collects all extra positional arguments into a tuple.

def total(*args):
    print(args)
    return sum(args)

print(total(1, 2, 3))
print(total(10, 20, 30, 40, 50))

Output:

(1, 2, 3)
6
(10, 20, 30, 40, 50)
150

The * is what makes it special. args is just a conventional name — you could call it *numbers or *values, though *args is the standard.

You can loop through it:

def print_all(*items):
    for item in items:
        print(f"- {item}")

print_all("python", "security", "hacking", "ctf")

Output:

- python
- security
- hacking
- ctf

**kwargs — accepting any number of keyword arguments

**kwargs collects extra keyword arguments into a dictionary.

def show_info(**kwargs):
    for key, value in kwargs.items():
        print(f"{key}: {value}")

show_info(name="Kalyan", age=22, city="Boston")

Output:

name: Kalyan
age: 22
city: Boston

kwargs is short for "keyword arguments." The ** unpacks the dictionary. Useful when you're building flexible functions that can accept arbitrary named data.

Using both together

def mixed(*args, **kwargs):
    print("Positional:", args)
    print("Keyword:", kwargs)

mixed(1, 2, 3, name="Kalyan", city="Boston")

Output:

Positional: (1, 2, 3)
Keyword: {'name': 'Kalyan', 'city': 'Boston'}

Order in the definition must be: regular params → *args**kwargs.


Returning multiple values

Python functions can return more than one value. They come back as a tuple.

def min_max(numbers):
    return min(numbers), max(numbers)

low, high = min_max([5, 2, 9, 1, 7])
print(f"Min: {low}, Max: {high}")

Output:

Min: 1, Max: 9

min(numbers), max(numbers) creates a tuple (1, 9). On the calling side, low, high = ... unpacks it into two separate variables. This is called tuple unpacking — one of Python's cleanest features.


Scope — where variables live

Variables created inside a function are local — they only exist inside that function. They disappear when the function ends.

def calculate():
    result = 42    # local variable
    return result

calculate()
print(result)      # NameError: name 'result' is not defined

The outside world can't see inside the function. This is intentional — it prevents functions from accidentally messing with each other's variables.

Global variables

Variables defined outside all functions are global — they can be read from anywhere.

language = "Python"

def show_language():
    print(language)    # can read the global

show_language()

Output:

Python

But to modify a global variable inside a function, you need to declare it with global:

count = 0

def increment():
    global count
    count += 1

increment()
increment()
print(count)

Output:

2

Without global count, Python would treat count inside the function as a new local variable and throw an error when you try to add to it. The global keyword says "I mean the one defined outside."

In real code, avoid global when possible — it makes code hard to reason about. Pass values in as parameters and return them instead.


Docstrings — documenting your function

A docstring is a string right after the def line that explains what the function does. It's not required but it's good practice.

def check_score(score):
    """
    Check if a score is passing or failing.
    Returns 'Pass' if score >= 70, otherwise 'Fail'.
    """
    return "Pass" if score >= 70 else "Fail"

print(check_score.__doc__)

Output:


    Check if a score is passing or failing.
    Returns 'Pass' if score >= 70, otherwise 'Fail'.
    

__doc__ accesses the docstring. Most editors also show it as a tooltip when you hover the function name. Write docstrings for any function someone else (or future you) will use.


Type hints — optional but useful

Python 3.5+ lets you annotate what types a function expects and returns. They're not enforced — Python won't crash if you pass the wrong type — but they make code way more readable and work with tools like linters and editors.

def add(a: int, b: int) -> int:
    return a + b

def greet(name: str) -> str:
    return f"Hello, {name}!"

def check_score(score: int) -> str:
    return "Pass" if score >= 70 else "Fail"

a: int says "this parameter should be an integer." -> int says "this function returns an integer." It's documentation that lives in the code itself.


Lambda functions — one-line anonymous functions

A lambda is a tiny function written in a single line, without a name, without def. Used for quick throwaway functions — usually as arguments to other functions.

square = lambda x: x ** 2
print(square(5))

Output:

25

The equivalent full function:

def square(x):
    return x ** 2

Lambda syntax: lambda parameters: expression. It automatically returns the expression — no return needed.

Where lambdas actually shine — sorting:

people = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 22},
    {"name": "Kalyan", "age": 22},
]

people.sort(key=lambda person: person["age"])
print(people)

Output:

[{'name': 'Bob', 'age': 22}, {'name': 'Kalyan', 'age': 22}, {'name': 'Alice', 'age': 30}]

The key= argument tells sort() how to compare items. Instead of defining a full function, the lambda does it in one shot inline. Keep lambdas short — if the logic is complex, write a real def instead.


Recursion — functions that call themselves

A recursive function calls itself. It's useful for problems that naturally break down into smaller versions of the same problem.

The classic example — factorial. 5! = 5 × 4 × 3 × 2 × 1 = 120.

def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n - 1)

print(factorial(5))

Output:

120

Every recursive function needs two things:

  1. A base case — the condition where it stops. Here it's if n == 1: return 1.
  2. A recursive case — where it calls itself with a smaller version of the problem. Here it's factorial(n - 1).

Without the base case, it calls itself forever and crashes with a RecursionError.

Recursion is elegant but not always the best tool — for most problems, a loop is simpler and faster. Use recursion when the problem itself is recursive by nature (trees, directories, mathematical sequences).


Higher-order functions — functions that take or return functions

In Python, functions are first-class objects. You can pass them around like variables, store them in lists, and return them from other functions.

Passing a function as an argument

def apply(func, value):
    return func(value)

def double(x):
    return x * 2

def square(x):
    return x ** 2

print(apply(double, 5))   # 10
print(apply(square, 5))   # 25

Output:

10
25

func holds the function itself — no parentheses. func(value) calls it inside.

Built-in higher-order functions

Python ships with map(), filter(), and sorted() — all take functions as arguments.

numbers = [1, 2, 3, 4, 5]

doubled  = list(map(lambda x: x * 2, numbers))
evens    = list(filter(lambda x: x % 2 == 0, numbers))

print(doubled)   # [2, 4, 6, 8, 10]
print(evens)     # [2, 4]

map() applies a function to every item. filter() keeps only items where the function returns True.

Returning a function from a function

def multiplier(factor):
    def multiply(x):
        return x * factor
    return multiply

triple = multiplier(3)
print(triple(5))    # 15
print(triple(10))   # 30

Output:

15
30

multiplier(3) returns the inner multiply function with factor locked in as 3. This pattern is called a closure — the inner function remembers the environment it was created in.


Everything in one place — the complete anatomy

def function_name(param1, param2, optional=default, *args, **kwargs) -> return_type:
    """Docstring explaining what this does."""
    # function body
    return value
PartWhat it is
defkeyword that starts the definition
function_namethe name you call it by
param1, param2required parameters
optional=defaultoptional parameter with default value
*argsany number of extra positional arguments
**kwargsany number of extra keyword arguments
-> return_typetype hint for what gets returned
docstringexplains the function
returnsends back the result

All the scenarios — which pattern to use when

Just doing something, no output needed → no return

def log_event(event):
    print(f"[LOG] {event}")

Calculating something and handing it back → return

def calculate_tax(price, rate):
    return price * rate

Same logic, different inputs, reused everywhere → regular function with parameters

def is_valid_port(port):
    return 1 <= port <= 65535

Quick one-off transformation → lambda

clean = lambda s: s.strip().lower()

Unknown number of inputs → *args or **kwargs

def log(*messages):
    for msg in messages:
        print(f"[INFO] {msg}")

Optional config-style inputs → default parameters

def connect(host, port=443, timeout=30):
    ...

Problem that breaks into smaller same-shaped problems → recursion

def count_files(directory):
    ...  # calls itself for each subdirectory

The one-line summary

A function is a named, reusable block of code. def defines it, parameters are its inputs, return is its output. Master parameters, defaults, *args, **kwargs, scope, lambdas, and recursion — and you can build anything. Everything else in Python is built on top of functions.