{"schemaVersion":"1.0","type":"TechArticle","types":["Article","TechArticle"],"slug":"functions-in-depth-scope-closures-and-passing-functions-around-lbukq","url":"https://zyvop.com/functions-in-depth-scope-closures-and-passing-functions-around-lbukq","title":"Functions in Depth: Scope, Closures, and Passing Functions Around","subtitle":"Go beyond the basics: default argument traps, args and kwargs, scope, lambdas, and closures, and why passing functions around sits at the heart of AI code.","tldr":"Functions are more powerful than they look. Learn how scope works, why default lists cause bugs, how lambdas and closures work, and how functions can be passed around like any value, then build a reusable retry helper for flaky API calls.","keywords":["Programming","Python","Functions","AI Engineer","Closures","AI","Zero to Top AI Engineer (2026): Full Series Plan"],"entities":["Pradeep Kumar","Programming","Python","Functions","AI Engineer","Closures","AI","Zero to Top AI Engineer (2026): Full Series Plan","ZyVOP"],"keyTakeaways":["In Post 4 you wrote simple functions that take an input and return an output.","This post goes into the parts that confuse people later, and the parts that show up everywhere in AI code.","This one is core, so I wouldn't skip it, even if you've written Python before."],"headings":["A quick recap, plus a trap","*args and **kwargs: any number of arguments","Scope: where a variable lives","Functions are just values","Higher-order functions","Lambdas: tiny throwaway functions","Closures: functions that remember","Habits for writing good functions","Mini project: a retry helper","Make it yours","Practice","What to take away"],"outboundLinks":[],"contentText":"In Post 4 you wrote simple functions that take an input and return an output. That's the easy part. This post goes into the parts that confuse people later, and the parts that show up everywhere in AI code. This one is core, so I wouldn't skip it, even if you've written Python before. Functions that take other functions as input are all over the place in modern AI work. Callbacks, decorators, and the way a model asks your code to run a tool all depend on the ideas below. A quick recap, plus a trap You can pass arguments by position or by name: def describe(name, age=30): return f\"{name} is {age}\" print(describe(\"Asha\")) # Asha is 30 print(describe(\"Asha\", 25)) # Asha is 25 print(describe(age=41, name=\"Ravi\")) # Ravi is 41Passing by name is clearer, and you'll do it a lot with AI libraries, where a call might have a dozen options. Now the trap. Look at this function: def add_item(item, items=[]): items.append(item) return items print(add_item(\"a\")) # ['a'] print(add_item(\"b\")) # ['a', 'b'] &lt;- wait, what?The second call should have returned ['b']. It didn't, because Python makes the default list once, when the function is defined, and then reuses that same list on every call. Changes pile up. The fix is to use None as the default and make the list inside: def add_item(item, items=None): if items is None: items = [] items.append(item) return itemsRule of thumb: never use a list or dictionary as a default value. *args and **kwargs: any number of arguments Sometimes you don't know how many arguments a function will get. A star in front of a parameter name collects extras. def total(*numbers): return sum(numbers) print(total(1, 2, 3)) # 6 print(total()) # 0Inside the function, numbers is a tuple holding everything that was passed in. Two stars collect named arguments into a dictionary: def show(**options): for key, value in options.items(): print(key, value) show(model=\"x\", temperature=0.2) # model x # temperature 0.2The names are just convention: *args and **kwargs are what most people write. The stars are what matter. The stars also work in reverse, when you call a function. They spread out a collection into separate arguments: numbers = (1, 2, 3) print(total(*numbers)) # same as total(1, 2, 3) config = {\"model\": \"y\", \"temperature\": 0.5} show(**config) # same as show(model=\"y\", temperature=0.5)Why does this matter for AI work? You'll often write a function that wraps another one and just passes everything along. A wrapper around an API call, for example, shouldn't have to list every option the API supports. It collects them with **kwargs and hands them over. Scope: where a variable lives A variable made inside a function belongs to that function only. def local_only(): secret = 42 local_only() print(secret) # NameError: name 'secret' is not definedPython looks for names in a set order: first inside the current function (local), then in any function wrapped around it, then at the top of your file (global), and finally in Python's built-in names like len and print. Now a puzzle that catches nearly everyone once: counter = 0 def increase(): counter = counter + 1 increase()UnboundLocalError: cannot access local variable 'counter' where it is not associated with a valueYou'd expect that to work, since counter clearly exists above. The problem is that when you assign to a name inside a function, Python decides that name is local to the function. So on the right-hand side it looks for a local counter that doesn't exist yet. You can fix it with the global keyword, but I'd avoid that. Code that secretly changes outside variables gets very hard to follow. The cleaner way is to pass things in and return things out: def increase(counter): return counter + 1 counter = increase(counter)Functions are just values This is the big idea of the post. In Python, a function is an object like any other. You can put it in a variable, a list, or a dictionary, and pass it to another function. def shout(text): return text.upper() def whisper(text): return text.lower() styles = {\"shout\": shout, \"whisper\": whisper} print(styles[\"shout\"](\"hello\")) # HELLO print(styles[\"whisper\"](\"HELLO\")) # helloNotice there are no brackets after shout and whisper when they go into the dictionary. Writing shout means \"the function itself.\" Writing shout(\"hi\") means \"run it now.\" That dictionary trick comes back later. When a language model decides it wants to use a tool, it sends back the name of the tool as text. Your code looks that name up in a dictionary like this one and runs the matching function. That's all tool calling is, at its core. Higher-order functions A function that takes another function as input, or returns one, is called a higher-order function. It sounds fancy, but you've already used one. def apply_to_all(func, items): return [func(item) for item in items] print(apply_to_all(shout, [\"a\", \"b\"])) # ['A', 'B']Python's built-in sorted() is one too. It takes an optional key that tells it how to sort: words = [\"banana\", \"fig\", \"cherry\"] print(sorted(words, key=len)) # ['fig', 'banana', 'cherry']Here len is passed as a function. Python calls it on each word and sorts by the results: 6, 3, 6. That also explains the line from Post 5: sorted(counts, key=counts.get) sorts the words by looking up each one's count. Lambdas: tiny throwaway functions Sometimes you need a small function just once, and giving it a name feels like overkill. A lambda is a function written in one line, with no name: square = lambda x: x * x print(square(4)) # 16The part before the colon is the input, and the part after is the result. Lambdas shine as quick arguments: words = [\"banana\", \"fig\", \"cherry\"] print(sorted(words, key=lambda w: len(w))) # ['fig', 'banana', 'cherry']Two other built-ins that take functions are filter (keep the items that pass a test) and map (transform every item): numbers = [1, 2, 3, 4, 5, 6] print(list(filter(lambda n: n % 2 == 0, numbers))) # [2, 4, 6] print(list(map(lambda n: n * n, numbers))) # [1, 4, 9, 16, 25, 36]A word of advice. Keep lambdas short. If you need more than one simple expression, or if you'd like to give it a good name, write a regular def. Also, in the next post you'll meet comprehensions, which are often a nicer way to do what map and filter do. Closures: functions that remember Here's one that makes people pause the first time. You can define a function inside another function, and the inner one can use the outer one's variables, even after the outer function has finished. def make_multiplier(factor): def multiply(number): return number * factor return multiply double = make_multiplier(2) triple = make_multiplier(3) print(double(5)) # 10 print(triple(5)) # 15When make_multiplier(2) finishes, you'd think factor disappears. It doesn't. The inner function carries it along. That's called a closure, and double and triple each remember their own factor. It's useful any time you want to build a configured function. A more AI-flavored example: def make_prompt_builder(role): def build(question): return f\"You are a {role}. Answer this question: {question}\" return build teacher = make_prompt_builder(\"patient teacher\") print(teacher(\"What is a closure?\")) # You are a patient teacher. Answer this question: What is a closure?You set the role once, and then reuse the builder as many times as you like. A closure can also hold state that changes over time. For that you need the nonlocal keyword, which says \"I mean the variable from the outer function, not a new local one\": def make_counter(): count = 0 def increment(): nonlocal count count += 1 return count return increment counter = make_counter() print(counter(), counter(), counter()) # 1 2 3Closures are the idea behind decorators, which is the topic of the next-but-one post. So if this section felt abstract, read it again slowly before moving on. Habits for writing good functions One job per function. If you need \"and\" to describe what it does, it's probably two functions. Pick a clear name. calculate_cost beats process. Add a docstring. A short description right under the def line: def calculate_cost(tokens, price_per_million): \"\"\"Return the cost in dollars for a number of tokens.\"\"\" return tokens / 1_000_000 * price_per_millionYour editor will show it when you hover over the function, and your future self will thank you. Mini project: a retry helper API calls fail. The network drops, a server is busy, you hit a rate limit. Real AI apps need to try again, and you don't want to copy the same retry code around every place you call a model. This is the perfect job for a higher-order function. Create retry.py: import random import time def retry(func, *args, attempts=3, delay=0.5, **kwargs): for attempt in range(1, attempts + 1): try: return func(*args, **kwargs) except Exception as error: print(f\"Attempt {attempt} failed: {error}\") if attempt == attempts: raise time.sleep(delay) def flaky_greeting(name): if random.random() &lt; 0.6: raise RuntimeError(\"Service busy\") return f\"Hello, {name}!\" print(retry(flaky_greeting, \"Asha\", attempts=5))Run it a few times. Since the failures are random, the output changes. You might see something like this: Attempt 1 failed: Service busy Attempt 2 failed: Service busy Hello, Asha!Occasionally all five attempts fail and you'll see the error crash the program. That's expected. When the last attempt fails, the function re-raises the error instead of hiding it. Here's what's going on. retry takes any function, plus whatever arguments that function needs, collected with *args and **kwargs. It calls the function inside a try block. A try/except means \"try this, and if it crashes, do this instead.\" (We'll cover it properly in Post 11.) Because retry doesn't care what the function does, the same helper works for any function that might fail. One detail: attempts and delay come after *args. That means they can only be given by name, like attempts=5. It stops you from mixing them up with the arguments for your own function. Make it yours Make the delay double after every failed attempt (this is called exponential backoff, and real APIs expect it). Only retry on certain errors, and let others crash right away. Print how long all the attempts took in total. Practice About 20 minutes of real effort on each before you ask for help. Write a function that takes any number of numbers and returns their average. Write make_greeter(greeting) that returns a function. make_greeter(\"Namaste\")(\"Asha\") should give \"Namaste, Asha!\". Sort a list of names by their last letter, using a lambda. Write your own apply_to_all(func, items) without using map or a list comprehension. Fix this so repeated calls don't share a list: def collect(value, bucket={}): bucket[value] = True return bucketThen ask an AI tutor to review your answers, and don't let it rewrite them. What to take away Arguments can be passed by position or by name, and *args and **kwargs collect the extras. Never use a list or dictionary as a default value. A variable made inside a function stays inside it. Functions are values, so you can store them, pass them, and return them. Lambdas are small one-line functions, best for short jobs. A closure is a function that remembers variables from where it was created. Previous: Post 5, Built-in Data Structures Next in the series: Post 7, Iterators, Generators &amp; Comprehensions, the tools behind data pipelines and streaming model output.","contentHash":"sha256:5cab13d8fab3e58c8f024dc06f90a515a4f1a166db23cc1025795f9b6d172e2f","authorName":"Pradeep Kumar","authorUrl":"https://zyvop.com/author/pradeep","authorSameAs":[],"category":"AI","tags":["Programming","Python","Functions","AI Engineer","Closures"],"audience":"Software engineers and developers building applications with AI","tone":"Practical and evidence-based engineering guidance","readingTimeMinutes":9,"wordCount":1896,"faqs":null,"primaryTopic":"AI","publishedAt":"2026-10-14T12:20:00.086Z","updatedAt":"2026-10-13T09:20:49.004Z","canonicalUrl":"https://zyvop.com/functions-in-depth-scope-closures-and-passing-functions-around-lbukq"}