How to package reusable logic into a function, pass data in and out of it correctly, and where this exact skill shows up in every real pipeline you'll build.
You need to calculate GST (18% tax) on a product's price in FIVE different places in your code. Without a function, you'd copy-paste the same calculation five times — and if the tax rate ever changes, you'd have to hunt down and fix all five copies.
A Function is a reusable block of code that performs a specific task, which you can call by name as many times as you need — instead of rewriting the same logic over and over.
Functions are the #1 tool for avoiding repeated code (a principle called "DRY — Don't Repeat Yourself"). Every real project you'll work on — data cleaning scripts, ETL pipelines, APIs — is organized almost entirely as a collection of small, focused functions. Writing good functions is one of the clearest signals of a developer who thinks beyond "just make it work."
You want a piece of code that greets a user by name — reusable for every single user who logs in, without rewriting the greeting logic each time.
Simple Answer: Use def to DEFINE (create) a function once. Then CALL it by name, as many times as you want, anywhere later in your code.
def greet_user(name): print(f"Welcome, {name}!") # now call it as many times as you want: greet_user("Sai") greet_user("Priya")
Every ETL script you build will be broken into functions like extract_data(), clean_data(), load_data() — this exact define-then-call pattern is literally how real pipelines are structured, just at a bigger scale.
def function_name(): defines a function — nothing runs yet, it just gets savedfunction_name() actually RUNS (calls) itAn interviewer asks "what's the difference between a parameter and an argument?" and most freshers freeze — even though they've been using both correctly without realizing they're technically different words.
Simple Answer: A Parameter is the placeholder NAME listed when you DEFINE the function. An Argument is the ACTUAL VALUE you pass in when you CALL the function.
def calculate_total(price, quantity): # price, quantity = PARAMETERS return price * quantity calculate_total(100, 3) # 100, 3 = ARGUMENTS
This is genuinely a common interview vocabulary check — not because it changes how you code, but because using the correct terms shows you can communicate precisely with other engineers, which matters more in team settings than people expect.
Most orders in your store ship within India, but occasionally one ships internationally. Forcing every single function call to specify the country, even when it's almost always the same, is unnecessary repetition.
Simple Answer: You can give a parameter a DEFAULT value. If the caller doesn't provide that argument, Python automatically uses the default instead.
def ship_order(order_id, country="India"): print(f"Shipping order {order_id} to {country}") ship_order(101) # uses default: "India" ship_order(102, "USA") # overrides the default
Default parameters make functions much easier to use — you'll see this constantly in real libraries, like PySpark's df.write.mode("overwrite")-style options, where most parameters already have sensible defaults so you only specify what's actually different from the norm.
def f(x, y=10):You want a function that adds up prices — sometimes 2 items, sometimes 5, sometimes 10. Writing separate functions for each possible number of items is clearly not practical.
Simple Answer: *args lets a function accept ANY number of extra positional values, automatically collected into a tuple inside the function.
def total_price(*prices): return sum(prices) print(total_price(100, 200)) # 300 print(total_price(50, 75, 25, 10)) # 160
You'll see *args in real library code whenever a function needs to accept a flexible, unknown number of inputs — useful for writing your own reusable utility functions in data pipelines.
*args = "accept any number of extra positional values"args behaves like a tuple* is what actually mattersYou're building a function to create a user profile, but different users provide different optional details — some give a phone number, some give a city, some give both. You don't want to force every possible field as a required parameter.
Simple Answer: **kwargs lets a function accept ANY number of extra NAMED (keyword) values, automatically collected into a dictionary inside the function.
def create_profile(name, **details): print(f"Name: {name}") for key, value in details.items(): print(f"{key}: {value}") create_profile("Sai", city="Hyderabad", phone="9876543210") # Name: Sai # city: Hyderabad # phone: 9876543210
This exact pattern shows up in almost every real Python library you'll use — Pandas, PySpark, and API frameworks all accept flexible optional settings via **kwargs, so understanding it helps you read and use documentation for tools you haven't seen before.
**kwargs = "accept any number of extra named values"kwargs behaves like a dictionary*args = extra positional values, **kwargs = extra named values — don't mix them upA function calculates a discount, but just PRINTS the result instead of RETURNING it. You try to use that calculated value later in your code — and get None instead, because printing and returning are NOT the same thing.
Simple Answer: return sends a value BACK to wherever the function was called, so you can store it or use it further. Without a return, a function automatically gives back None, even if it printed something on screen.
def calculate_discount(price): return price * 0.9 # 10% off, sent back to the caller final_price = calculate_discount(1000) print(final_price) # 900.0 - usable, because it was RETURNED def calculate_discount_wrong(price): print(price * 0.9) # just displays it, doesn't return it result = calculate_discount_wrong(1000) print(result) # None - the value is lost, only printed once
This print-vs-return confusion is one of the most common early bugs — a function LOOKS like it worked (you see output on screen), but the actual value is unusable in the rest of your program. Getting this right is essential the moment your functions need to work together, like in a multi-step ETL pipeline.
print() → shows something on screen, for humans to readreturn → sends a value BACK, for your CODE to actually usereturn statement → the function gives back None automaticallyYou create a variable INSIDE a function, then try to use it OUTSIDE the function — and Python says it doesn't exist. The variable was never "gone," it just never existed outside that function in the first place.
Simple Answer: A LOCAL variable only exists INSIDE the function where it was created — it disappears once the function finishes running. A GLOBAL variable is created outside any function, and can be READ (though not easily changed) from anywhere.
tax_rate = 0.18 # global variable - exists everywhere def calculate_tax(price): tax_amount = price * tax_rate # tax_amount is LOCAL to this function return tax_amount print(calculate_tax(1000)) # 180.0 - works fine print(tax_amount) # ERROR - tax_amount doesn't exist outside the function
Understanding scope prevents a whole category of confusing bugs — especially in larger scripts where you might accidentally expect a variable from one function to "leak" into another. This becomes even more important once you write functions that run independently, like PySpark UDFs.
You need a tiny function just to sort a list of products by price — used exactly ONCE, right there in the sorting line. Writing a full def function, giving it a name, just for this one-time use feels like overkill.
Simple Answer: A lambda is a small, anonymous (unnamed) function written in a single line — perfect for short, throwaway logic used once, often passed directly into another function.
products = [{"name": "Shoes", "price": 2000}, {"name": "Shirt", "price": 800}]
# regular function way
def get_price(item):
return item["price"]
sorted_products = sorted(products, key=get_price)
# same result, using a lambda instead - shorter, used only once
sorted_products = sorted(products, key=lambda item: item["price"])
Lambdas show up constantly in Pandas (df['col'].apply(lambda x: x * 2)) and are used for quick sorting/filtering logic. That said, in PySpark, you'll typically prefer built-in functions over lambdas/UDFs for performance reasons — a concept you'll cover in more depth later.
lambda arguments: expression — no def, no name, no return keyword neededdef function insteadUse this table for a fast recall of every function concept covered today.
| Concept | Syntax | What It Does | Common Real-World Use |
|---|---|---|---|
| def | def name(): |
Defines a reusable function | Every function you'll ever write |
| Parameter | def f(x): |
Placeholder name in the definition | Describes what input the function expects |
| Argument | f(5) |
Actual value passed when calling | The real data sent into a function |
| Default Value | def f(x=10): |
Fallback value if none is given | Optional settings, common defaults |
| *args | def f(*args): |
Accepts any number of positional values | Flexible utility functions |
| **kwargs | def f(**kwargs): |
Accepts any number of named values | Optional settings in libraries |
| return | return value |
Sends a value back to the caller | Using a calculated result elsewhere |
| Local Variable | created inside a function | Only exists within that function | Temporary calculations |
| Global Variable | created outside any function | Readable from anywhere | Shared constants (like a tax rate) |
| lambda | lambda x: x*2 |
Small, anonymous, one-line function | Quick sort/filter keys, Pandas .apply() |
A fresher in an interview is asked: "how would you design a function that needs a required name, an optional country, and also needs to accept any number of extra product IDs and any number of extra settings?" This requires combining ALL FOUR parameter styles correctly in one function.
| Feature | Positional | Default | *args | **kwargs |
|---|---|---|---|---|
| Syntax | def f(x): |
def f(x=5): |
def f(*x): |
def f(**x): |
| Required? | Yes | No (has a fallback) | No (can be zero values) | No (can be zero values) |
| How Many Values? | Exactly one per parameter | Exactly one (or the default) | Any number (unnamed) | Any number (named) |
| Stored As (Inside Function) | A single value | A single value | A tuple | A dictionary |
| Typical Use Case | Must-have inputs | Optional settings with a common default | Unknown count of similar values | Unknown set of optional named settings |
def place_order(customer_name, country="India", *product_ids, **extra_settings): print(f"Customer: {customer_name}, Country: {country}") print(f"Products: {product_ids}") print(f"Extra Settings: {extra_settings}") place_order("Sai", "USA", 101, 102, 103, gift_wrap=True, express=True)
Real, professional libraries (Pandas, PySpark, requests) combine all four of these styles in their functions constantly. Recognizing which style is being used just by reading a function signature is a genuine, practical skill for understanding documentation and other people's code quickly.
A function packages reusable logic behind a name, so you write it once and call it anywhere. Parameters describe what goes in, return sends a result back out, and *args/**kwargs give you flexibility when the number of inputs isn't fixed. Scope keeps your variables organized and predictable, and lambdas give you a shortcut for tiny, one-off logic. Together, these concepts are how every real Python script — from a 10-line utility to a production ETL pipeline — is actually structured.