InterviewPrepKit

Home / Learn / Algorithms & Data Structures with Python

Functions, Arguments, and Scope

What a function is and why it matters

A function is a named block of code that you can run again later by using its name. You write the steps once, give them a name, and from then on you “call” that name whenever you want those steps to happen.

Without functions, you would copy and paste the same lines everywhere you need them. That is slow to write and dangerous to change: if you find a mistake, you have to fix every copy. A function gives you one place to write the logic and one place to fix it. It also lets you give a chunk of work a meaningful name, which makes code readable.

Defining a function with def

You create a function with the keyword def (short for “define”). A keyword is a word Python reserves for a special meaning; you cannot use it as an ordinary name.

def greet():
    print("hello")

greet()   # -> hello
greet()   # -> hello

Reading it piece by piece:

  • def tells Python you are defining a function.
  • greet is the name you chose.
  • () is where parameters go (this function has none).
  • The : ends the header line.
  • The indented lines below (print("hello")) are the body: the code that runs when you call the function. Indentation is how Python knows which lines belong to the function.

Writing def greet(): ... only defines the function; it does not run the body. The body runs when you call it by writing its name followed by parentheses: greet(). Here we defined it once and called it twice, so hello prints twice.

Parameters and arguments

Most functions need input to work on. A parameter is a name listed in the function definition that stands for a value the caller will supply. An argument is the actual value you pass in when you call the function. Same slot, two words: “parameter” is the name in the definition, “argument” is the value at the call.

def greet(name):        # name is a parameter
    print("hello " + name)

greet("Sam")            # "Sam" is an argument
# -> hello Sam
greet("Dana")
# -> hello Dana

Inside the body, name refers to whatever argument was passed for that particular call. A function can take several parameters, separated by commas:

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

add(2, 3)   # -> 5

Returning a value with return

Printing shows text on the screen, but usually you want a function to hand a value back to the code that called it so you can use it further. That is what return does: it ends the function and sends a value out.

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

total = add(2, 3)   # add(...) evaluates to 5, stored in total
print(total)        # -> 5
print(add(10, 4))   # -> 14

The difference between print and return matters. print puts characters on the screen and produces no usable value. return gives back a value your program can store in a variable, pass to another function, or combine in an expression. A function that returns gives back a value you can reuse elsewhere, while a function that only prints leaves nothing for the rest of your program to work with.

Once return runs, the function stops immediately. Any lines after it do not execute:

def sign(n):
    if n < 0:
        return "negative"
    return "zero or positive"

print(sign(-4))   # -> negative
print(sign(7))    # -> zero or positive

Returning None

If a function has no return statement, or return with nothing after it, it returns a special value called None. None is Python’s way of saying “no value / nothing here”. It is not the number 0 and not an empty string; it is its own thing.

def shout(text):
    print(text.upper())

result = shout("hi")   # -> HI   (this is printed)
print(result)          # -> None (shout returned nothing)

This is a very common source of confusion for beginners: they call a function that only prints, try to use its “result”, and get None. If you want to use a value, the function must return it.

Positional vs keyword arguments

There are two ways to pass arguments.

Positional: arguments are matched to parameters by their order. The first argument fills the first parameter, and so on.

def power(base, exponent):
    return base ** exponent

print(power(2, 3))   # -> 8   (base=2, exponent=3)
print(power(3, 2))   # -> 9   (base=3, exponent=2)  order changes the meaning

Keyword: you name the parameter at the call site. Then order does not matter, and the call is easier to read.

print(power(base=2, exponent=3))     # -> 8
print(power(exponent=3, base=2))     # -> 8  (same result; names decide, not order)

You can mix them, but all positional arguments must come before any keyword arguments:

print(power(2, exponent=3))   # -> 8   valid
# print(power(base=2, 3))     # SyntaxError: positional argument after keyword

Default arguments

A default argument gives a parameter a fallback value used when the caller does not pass one. This lets callers omit arguments they do not care about.

def greet(name, greeting="hello"):
    return greeting + " " + name

print(greet("Sam"))                 # -> hello Sam        (greeting used its default)
print(greet("Sam", "welcome"))      # -> welcome Sam      (default overridden)
print(greet("Sam", greeting="hi"))  # -> hi Sam

Parameters with defaults must come after parameters without them in the definition, because otherwise Python could not tell which positional argument you meant to skip.

The mutable-default-argument pitfall

This is one of the most famous traps in Python, so it is worth understanding early even though the underlying idea (mutable objects) is subtle.

A value is mutable if it can be changed in place after it is created. A list is mutable: you can append to the same list object. A number or a string is immutable: you cannot change it in place, you can only make a new one.

The default value for a parameter is created once, when the function is defined, not each time it is called. If that default is a mutable object like a list, every call that uses the default shares the same list. Changes pile up across calls:

def add_item(item, bag=[]):     # BUG: the default list is created once and reused
    bag.append(item)
    return bag

print(add_item("a"))   # -> ['a']
print(add_item("b"))   # -> ['a', 'b']   surprise: the "a" is still there
print(add_item("c"))   # -> ['a', 'b', 'c']

Most people expect a fresh empty list each call. The fix is to default to None and build a new list inside the function, where “inside” means it runs on every call:

def add_item(item, bag=None):
    if bag is None:
        bag = []            # a new list each call that did not supply one
    bag.append(item)
    return bag

print(add_item("a"))   # -> ['a']
print(add_item("b"))   # -> ['b']   correct: independent each time

Rule of thumb: never use a mutable object (list, dictionary, set) as a default argument. Default to None and create it inside.

Accepting any number of arguments: *args and **kwargs

Sometimes you do not know in advance how many arguments a caller will pass. Two special syntaxes handle this. The names args and kwargs are only a convention; the * and ** are what matter.

*args collects extra positional arguments into a tuple (an ordered, fixed sequence of values):

def total(*numbers):
    result = 0
    for n in numbers:
        result += n
    return result

print(total(1, 2, 3))      # -> 6
print(total(10, 20))       # -> 30
print(total())             # -> 0

**kwargs collects extra keyword arguments into a dictionary (a set of name-to-value pairs):

def describe(**info):
    for key, value in info.items():
        print(key, "=", value)

describe(color="red", size="M")
# -> color = red
# -> size = M

You will use these mostly when writing flexible helpers or passing arguments through to another function. As a beginner you rarely need them, but you will see them in real code, so recognize the shape.

Scope: where a name is visible

Scope is the region of code where a given name can be seen and used. This matters because two different parts of a program can use the same name for different things without colliding, as long as they are in different scopes.

The key rule: variables created inside a function are local to that function. They exist only while the function runs and are invisible outside it.

def f():
    x = 10          # local to f
    print(x)        # -> 10

f()
# print(x)          # NameError: x is not defined out here

A name defined at the top level of your file (not inside any function) is global: functions can read it.

tax_rate = 0.2      # global

def price_with_tax(price):
    return price + price * tax_rate   # reads the global tax_rate

print(price_with_tax(100))   # -> 120.0

Reading a global vs reassigning it: the global keyword

Reading a global from inside a function works, as shown above. Reassigning one is where beginners get surprised. If you assign to a name inside a function, Python treats that name as local to the function by default, even if a global with the same name exists. So this does not change the global:

count = 0

def bump():
    count = count + 1   # error: Python sees 'count' as local, but it has no value yet

# bump()   # UnboundLocalError: local variable 'count' referenced before assignment

Python saw the assignment count = ... and decided count is a local variable for the whole function. Then count + 1 tries to read that local before it has a value, so it fails.

To say “I really mean the global one,” declare it with the global keyword:

count = 0

def bump():
    global count        # count refers to the module-level variable
    count = count + 1

bump()
bump()
print(count)   # -> 2

Use global sparingly. Functions that quietly change global state are harder to reason about and test. It is usually cleaner to take input as parameters and hand results back with return.

This is the mental model for how Python resolves a name inside a function: it checks the local scope first, then the surrounding global scope.

flowchart TD
    A["Use a name inside a function"] --> B{"Assigned anywhere<br/>in this function?"}
    B -->|Yes, and no 'global'| C["Treat as LOCAL<br/>(must be set before use)"]
    B -->|No| D["Look it up in<br/>the GLOBAL scope"]
    B -->|"Declared 'global'"| E["Use the GLOBAL variable<br/>(assignment updates it)"]

Docstrings: documenting a function

A docstring is a string written as the very first line inside a function’s body. Its job is to explain, in plain words, what the function does. Tools and editors can display it, and you can read it at runtime.

def area(width, height):
    """Return the area of a rectangle given its width and height."""
    return width * height

print(area(3, 4))       # -> 12
print(area.__doc__)     # -> Return the area of a rectangle given its width and height.

Triple quotes ("""...""") let the text span multiple lines. Writing a one-line docstring that says what goes in and what comes out is a good habit and costs almost nothing.

Functions are values you can pass around

A function is itself a value, like a number or a string. You can store a function in a variable, put it in a list, and pass it as an argument to another function. Note the difference between the function itself (f, no parentheses) and calling it (f(), which runs it).

def double(n):
    return n * 2

f = double          # store the function itself (no parentheses, not calling it)
print(f(5))         # -> 10   calling through the new name

def apply_twice(func, value):
    """Call func on value, then on the result."""
    return func(func(value))

print(apply_twice(double, 3))   # -> 12   (double(3)=6, double(6)=12)

apply_twice does not know or care what func does; it just calls whatever function you hand it. Passing behavior around like this is the basis of many useful tools (sorting with a custom key, filtering, callbacks). A function passed into another function is often called a callback.

A small worked example

Putting the pieces together: parameters, a default, a docstring, a return, and local variables.

def summarize(numbers, label="data"):
    """Return a short summary string for a list of numbers.

    numbers: a list of numbers to summarize
    label:   a name to include in the summary (defaults to "data")
    """
    if not numbers:
        return label + ": empty"
    count = len(numbers)              # local
    total = sum(numbers)              # local
    average = total / count           # local
    return label + ": " + str(count) + " values, avg " + str(average)

print(summarize([2, 4, 6]))                 # -> data: 3 values, avg 4.0
print(summarize([10, 20], label="scores"))  # -> scores: 2 values, avg 15.0
print(summarize([]))                        # -> data: empty

count, total, and average exist only while summarize runs; they vanish when it returns. Each call gets its own fresh set of these locals.

A note on cost

Calling a function has a small, fixed overhead in both time and space: Python sets up a small record (a stack frame) to hold that call’s local variables, which is O(1) time and O(1) space per call. The real cost is whatever the body does. In summarize, sum(numbers) and len(numbers) walk the list, so the function is O(n) time in the number of elements, and O(1) extra space (it stores a few scalars, not a copy of the list). When you reason about the cost of a function, look at what its body does, not at the act of calling it.

Common pitfalls

  • Confusing print and return. A function that only prints returns None. If you need to use the value elsewhere, return it.
  • Forgetting the parentheses to call. greet is the function value; greet() runs it. x = greet stores the function; x = greet() stores its return value.
  • Mutable default arguments. def f(bag=[]) shares one list across all calls. Default to None and create the list inside.
  • Reassigning a global without declaring it. Assigning to a name inside a function makes it local for the whole function, which can cause UnboundLocalError. Use global only when you truly intend to change the global.
  • Code after return in the same branch. It never runs. return exits the function immediately.
  • Order of arguments. With positional arguments, order is the meaning. power(2, 3) and power(3, 2) are different. Use keyword arguments when the order is easy to get wrong.

Practice

  1. Write a function clamp(value, low, high) that returns value if it is between low and high, otherwise the nearer bound. Example: clamp(12, 0, 10) returns 10, clamp(-3, 0, 10) returns 0, clamp(5, 0, 10) returns 5.

  2. Write a function collect(item, into=None) that appends item to the list into and returns the list, creating a new empty list when into is not given. Call it several times with no second argument and confirm each call starts from an empty list (this is the mutable-default trap; do it the correct way).

  3. Write a function apply_to_all(func, items) that takes a function and a list, and returns a new list with func applied to each element. Test it by passing in a small function that squares a number, so apply_to_all(square, [1, 2, 3]) returns [1, 4, 9].

Report a bug