Skip to content

My function remembers the last call

  • Python · Foundations
  • 20 min read, about 1 h 15 min with practice
  • Concept
  • Asked often: high
  • Reviewed 2026-11

New to functions? Open a quick recap. It takes about two minutes.

Quick recapFunctions in two minutes· 2 min

def makes a function. Parameters name the values it’s given, and return hands a value back. A parameter with = has a default, used when the caller leaves it out.

def greet(name, greeting="Hello"):
return f"{greeting}, {name}!"
greet("Ana") # 'Hello, Ana!'
greet("Bo", greeting="Hi") # 'Hi, Bo!': passed by keyword

You’re counting votes for a class poll, one day at a time. You write tally. It takes any number of names and counts them in a dict. You can pass your own dict to keep a running total. Leave it out, and each call should start a new one:

def tally(*names, counts={}):
for name in names:
counts[name] = counts.get(name, 0) + 1
return counts
monday = tally("ana", "bo", "ana")
tuesday = tally("bo")
print(tuesday)
print(monday)

Tuesday had one vote, for bo. Yet this prints:

{'ana': 2, 'bo': 2}
{'ana': 2, 'bo': 2}

Tuesday’s tally holds Monday’s votes, and Monday’s tally changed after the fact. Where did the extra votes come from?

  • Input: any number of names, passed one by one: tally("ana", "bo"). Optionally counts, a dict of running totals, passed by keyword: tally("bo", counts=week).
  • Output: the dict of counts. Given counts, add to that very dict, even if it’s empty, and return it. Without it, start from a new, empty dict on every call.
  • The hard part: one call must never see another call’s names, unless the caller passes the same dict on purpose.

Example: tally("ana", "bo", "ana") gives {"ana": 2, "bo": 1}. A second call, tally("bo"), gives {"bo": 1}. And with week = {}, tally("ana", counts=week) changes week to {"ana": 1}.

Hints open one at a time:

Hint 1 of 3

The {} is made once, when def runs. Every call that leaves counts out gets that same dict.

Hint 2 of 3

Use a default that can’t change, like None, and make the new dict inside the function.

Hint 3 of 3

counts = counts or {} looks shorter. What does it do with an empty dict the caller passes?

Python runs a def line once, when it defines the function. That’s when it works out each default value and stores it with the function. A call that leaves the argument out gets that stored object. It doesn’t get a new copy.

For numbers, text and None, that’s fine: they can’t change. A list or dict default can. Change it in one call, and every later call starts from the changed object.

Here are the two calls as plain lines. default stands for the dict stored with the function, and each call’s counts starts out pointing to it. The player stops to ask what happens next. Then type your own lines under it.

One default dict for every call

Names

none yet

Objects

no lists, dicts or tuples yet

Step 1 of 8. Before the first line runs, there are no names and no objects.

default = {}
monday = default
monday['ana'] = 2
monday['bo'] = 1
tuesday = default
tuesday['bo'] += 1
print(monday)

Try your own input

Use numbers, text, lists, dicts and tuples. It knows =, +=, del, print(), comparisons, slices, comprehensions, the common list methods, sorted(), list(), dict(), tuple(), range(), and copy.deepcopy() after import copy.

Monday’s and Tuesday’s tallies are one dict, so both show bo twice.

While you’re here: *args and **kwargs. In def tally(*names, ...), the * collects any number of positional arguments into a tuple. ** collects keyword arguments into a dict. At a call, the same signs spread a list or dict back out:

def show(*args, **kwargs):
return args, kwargs
show(1, 2, x=3) # ((1, 2), {'x': 3})
nums = [1, 2]
opts = {"x": 3}
show(*nums, **opts) # the same: * and ** spread them out

A parameter after *names, like counts, can only be passed by keyword. So tally("ana", "counts") counts a name called counts, and never mixes it up with the dict.

All three rungs look at each name once, so all take O(n) time for n names. What changes is where the dict comes from.

Okay

A dict as the default

The idea: Give counts the default {}, and add each name to it.

On the votes: Monday’s call leaves counts out, so it adds ana twice and bo once to the stored default dict, and returns it. Tuesday’s call leaves counts out too, so it gets the same dict and adds bo again.

def tally(*names: str, counts: dict[str, int] = {}) -> dict[str, int]: # noqa: B006
"""Count each name. Add to counts if given, else to a new dict... or so it seems."""
for name in names:
counts[name] = counts.get(name, 0) + 1
return counts
Time
O(n)
Space
O(k)
Why
One entry for each of the k different names.

Where it breaks: every call that leaves counts out shares one dict, made when def ran. Tuesday’s tally holds Monday’s votes, and monday is tuesday is True. Passing your own dict works, which is why this bug hides until the second call.

Better

None, then counts or {}

The idea: Default counts to None, and write counts = counts or {} to get a new dict when there isn’t one.

The waste in Okay: it reuses one dict for every call, when each call needs its own.

On the votes: Monday’s call gets None, so counts or {} makes a new dict. Tuesday’s call gets another new dict, so it holds only bo. A running total still works: tally("bo", counts={"ana": 2}) adds to the dict it was given.

Highlighted lines are new in this rung.

def tally(*names: str, counts: dict[str, int] | None = None) -> dict[str, int]:
"""Count each name. Add to counts if given, else to a new dict."""
counts = counts or {} # a new dict when counts is None... or empty
for name in names:
counts[name] = counts.get(name, 0) + 1
return counts
Time
O(n)
Space
O(k)
Why
The same work, with a new dict per call.
What changed from the previous rung
def tally(*names: str, counts: dict[str, int] = {}) -> dict[str, int]: # noqa: B006
"""Count each name. Add to counts if given, else to a new dict... or so it seems."""
def tally(*names: str, counts: dict[str, int] | None = None) -> dict[str, int]:
"""Count each name. Add to counts if given, else to a new dict."""
counts = counts or {} # a new dict when counts is None... or empty
for name in names:
counts[name] = counts.get(name, 0) + 1
return counts

Where it breaks: or treats every empty value as missing, not just None. With week = {}, tally("ana", counts=week) throws week away and fills a new dict. week stays empty, and the caller’s running total never starts.

Best

None, then is None

The idea: Default counts to None, and make a new dict only when counts is None.

The waste in Better: it replaces the caller’s dict whenever it’s empty, when only a missing one needs replacing.

counts is None is true only when the caller left counts out. An empty dict they passed is kept and filled. This is the standard way to give a list or dict a default.

Highlighted lines are new in this rung.

def tally(*names: str, counts: dict[str, int] | None = None) -> dict[str, int]:
"""Count each name. Add to counts if given, else to a new dict."""
if counts is None:
counts = {} # made now, on every call that leaves counts out
for name in names:
counts[name] = counts.get(name, 0) + 1
return counts
Time
O(n)
Space
O(k)
Why
The same work; only a missing dict is replaced.
What changed from the previous rung
def tally(*names: str, counts: dict[str, int] | None = None) -> dict[str, int]:
"""Count each name. Add to counts if given, else to a new dict."""
counts = counts or {} # a new dict when counts is None... or empty
if counts is None:
counts = {} # made now, on every call that leaves counts out
for name in names:
counts[name] = counts.get(name, 0) + 1
return counts

When Best isn’t best: changing the caller’s dict is a promise, and every caller must know it. If they don’t need a running total, take no dict at all: return a new one, and let them add it to theirs.

Now write it in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere. Your code is saved in this browser for next time.

Some tests call tally twice, like Monday and Tuesday. Try Okay and Better from the ladder first: each fails a different test.

Count the votes

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: tally("ana", "bo", "ana") → {'ana': 2, 'bo': 1}
  2. Not run yet: tally("ana", "bo", "ana"); tally("bo") → {'bo': 1}
  3. Not run yet: week = {"ana": 2}; tally("bo", counts=week); week → {'ana': 2, 'bo': 1}
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayTimeExtra memoryGoodNot so goodUse it when
Okay: counts={}O(n)O(k)ShortEvery call that leaves it out shares one dictNever for a list or dict
Better: None, then or {}O(n)O(k)A new dict per callThrows away an empty dict the caller passedEmpty and missing really mean the same thing
Best: None, then is NoneO(n)O(k)Only a missing dict is replacedTwo lines instead of oneAny list, dict or set default
  • Any default is made once: def stamp(when=time.time()) stamps every call with the time def ran. Use when=None, then if when is None: when = time.time().
  • The order of parameters: plain ones first, then *args, then keyword-only ones, then **kwargs: def f(a, b=0, *args, c, d=1, **kwargs).
  • * alone: def price(amount, *, currency="$") makes currency keyword-only without collecting extra arguments.
  • A default dict on purpose: some code keeps a dict default as a cache that lasts between calls. It works, but readers take it for this bug. functools.cache says what you mean.
  • Linters catch it: Ruff and flake8-bugbear flag a list or dict default as B006.

Questions to ask first

  • Should the function change a dict I pass in, or return a new one?
  • Can callers pass an empty dict on purpose, or does empty mean “start fresh”?
  • Which options should be keyword-only, so they can’t be mixed up with the names?

A 60-second walkthrough

“Default values are made once, when def runs, not on each call. So a {} default is one dict shared by every call that leaves it out: the second call sees the first call’s votes. I default to None and create the dict inside the function, checking is None, not truthiness, so a caller’s empty dict is still used. *names takes any number of names as a tuple, and putting counts after it makes it keyword-only. It’s one pass, O(n).”

Likely follow-ups

  • Why is a list default a bug? It’s made once and shared, so a change in one call shows in the next.
  • What are *args and **kwargs? A tuple of the extra positional arguments, and a dict of the extra keyword arguments.
  • How do you force keyword arguments? Put them after *args, or after a lone *.
  • What does / do in a signature? Parameters before it are positional-only: def f(a, /, b) can’t be called as f(a=1, b=2).

Three problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

Write largest(*nums, default=None). Return the largest of any number of whole numbers, or default when there are none.

Example: largest(3, 9, 2) gives 9, largest() gives None, and largest(default=0) gives 0. With a list, spread it out: largest(*scores).

Hint 1 of 3

Inside the function, nums is a tuple of everything passed.

Hint 2 of 3

Watch out for all-negative numbers: don’t start the search at 0.

Hint 3 of 3

max() takes a default= of its own, for when there’s nothing to compare.

The largest, with a fallback

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: largest(3, 9, 2) → 9
  2. Not run yet: largest(-5, -2, -9) → -2
  3. Not run yet: largest() → None
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Keep the best so far

The idea: Start from the first number and walk the rest, keeping the largest.

It starts from nums[0], not from 0, so all-negative numbers work. It passes every test, in nine lines.

def largest(*nums: int, default: int | None = None) -> int | None:
"""The largest of nums, or default when there are none. Keep the best seen so far."""
if not nums:
return default
best = nums[0]
for n in nums[1:]:
if n > best:
best = n
return best
Time
O(n)
Space
O(1)
Why
One pass over the numbers.
Better

max(), after a check

The idea: Return default when nums is empty, else max(nums).

The waste in Okay: it writes out the loop that max() already does.

max(()) raises ValueError on an empty tuple, so the check comes first.

Highlighted lines are new in this rung.

def largest(*nums: int, default: int | None = None) -> int | None:
"""The largest of nums, or default when there are none."""
if not nums:
return default
return max(nums) # nums is a tuple, and max() takes any iterable
Time
O(n)
Space
O(1)
Why
max() makes the same pass, in C.
What changed from the previous rung
def largest(*nums: int, default: int | None = None) -> int | None:
"""The largest of nums, or default when there are none. Keep the best seen so far."""
"""The largest of nums, or default when there are none."""
if not nums:
return default
best = nums[0]
for n in nums[1:]:
if n > best:
best = n
return best
return max(nums) # nums is a tuple, and max() takes any iterable
Best

max() with its own default

The idea: max(nums, default=default) handles the empty case itself.

The waste in Better: it checks for an empty tuple that max() can handle by itself.

max()’s default is keyword-only too: in its signature, max(iterable, *, default, key=None), it comes after a lone *.

Highlighted lines are new in this rung.

def largest(*nums: int, default: int | None = None) -> int | None:
"""The largest of nums, or default when there are none."""
return max(nums, default=default) # max() has its own keyword-only default
Time
O(n)
Space
O(1)
Why
The same pass, in one line.
What changed from the previous rung
def largest(*nums: int, default: int | None = None) -> int | None:
"""The largest of nums, or default when there are none."""
if not nums:
return default
return max(nums) # nums is a tuple, and max() takes any iterable
return max(nums, default=default) # max() has its own keyword-only default

When Best isn’t best: if the numbers come as a list, take the list: largest(scores) reads better than spreading it with largest(*scores), and avoids building a tuple of a million items.

Write make_url(base, *parts, **query). Join base and each part with /. If there’s a query, add ? and each key=value, joined with &. Values may be text or whole numbers. A part or value may hold spaces, &, = or /, which must be escaped so the link still works.

Example: make_url("https://shop.example", "items", "42", color="red") gives "https://shop.example/items/42?color=red". With no query, there’s no ?.

Hint 1 of 3

parts is a tuple and query is a dict, in the order they were passed.

Hint 2 of 3

"/".join([base, *parts]) joins them in one go.

Hint 3 of 3

urllib.parse has urlencode() for the query and quote() for each part. Both escape the special characters.

Build a link with a query

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: make_url("https://shop.example", "items", "42", color="red") → 'https://shop.example/items/42?color=red'
  2. Not run yet: make_url("https://shop.example", "items") → 'https://shop.example/items'
  3. Not run yet: make_url("https://shop.example", q="pen", page="2") → 'https://shop.example?q=pen&page=2'
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Add the pieces one by one

The idea: Add / and each part, then ?, then key=value& for each pair, and cut the last character.

Cutting the last character drops the extra &, or the ? when there’s no query.

def make_url(base: str, *parts: str, **query: str | int) -> str:
"""base, then each part after a /, then ?key=value pairs joined with &."""
url = base
for part in parts:
url += "/" + part
url += "?"
for key, value in query.items():
url += key + "=" + value + "&"
return url[:-1] # drop the last & (or the ? when there's no query)
Time
O(L)
Space
O(L)
Why
L is the length of the link. Each += may copy the text so far, but links are short.

Where it breaks: key + "=" + value fails when a value is a number: page=2 raises TypeError. And nothing is escaped.

Better

join and an f-string

The idea: Join base and the parts with /, and add ? and the key=value pairs only when there’s a query.

The waste in Okay: it adds a ? and an & it then has to take back off. And it builds text with +, which needs every value to be text.

The f-string turns numbers into text by itself.

Highlighted lines are new in this rung.

def make_url(base: str, *parts: str, **query: str | int) -> str:
"""base, then each part after a /, then ?key=value pairs joined with &."""
url = "/".join([base, *parts])
if query:
url += "?" + "&".join(f"{key}={value}" for key, value in query.items())
return url
Time
O(L)
Space
O(L)
Why
Each piece is joined once.
What changed from the previous rung
def make_url(base: str, *parts: str, **query: str | int) -> str:
"""base, then each part after a /, then ?key=value pairs joined with &."""
url = base
for part in parts:
url += "/" + part
url += "?"
for key, value in query.items():
url += key + "=" + value + "&"
return url[:-1] # drop the last & (or the ? when there's no query)
url = "/".join([base, *parts])
if query:
url += "?" + "&".join(f"{key}={value}" for key, value in query.items())
return url

Where it breaks: q="red & blue" gives ?q=red & blue. A browser reads that as q=red and a second key, blue. Spaces and / in a part break the path the same way.

Best

Let urllib escape it

The idea: quote() each part and urlencode() the query, so special characters become %26-style escapes.

The waste in Better: it trusts every value to be plain letters and numbers.

urlencode(query) takes the **query dict as it is. It writes a space as + and & as %26. quote(part, safe="") writes a space as %20 and / as %2F.

Highlighted lines are new in this rung.

from urllib.parse import quote, urlencode
def make_url(base: str, *parts: str, **query: str | int) -> str:
"""base, then each part after a /, then the query, with special characters escaped."""
url = "/".join([base, *(quote(part, safe="") for part in parts)])
if query:
url += "?" + urlencode(query) # escapes spaces, & and = in values
return url
Time
O(L)
Space
O(L)
Why
The same joins, plus escaping.
What changed from the previous rung
from urllib.parse import quote, urlencode
def make_url(base: str, *parts: str, **query: str | int) -> str:
"""base, then each part after a /, then ?key=value pairs joined with &."""
url = "/".join([base, *parts])
"""base, then each part after a /, then the query, with special characters escaped."""
url = "/".join([base, *(quote(part, safe="") for part in parts)])
if query:
url += "?" + "&".join(f"{key}={value}" for key, value in query.items())
url += "?" + urlencode(query) # escapes spaces, & and = in values
return url

When Best isn’t best: in a real app, use your web framework’s URL builder. It also handles a base that already has a query or a trailing /. With only the standard library, take the base apart with urllib.parse.urlsplit, add your path and query, and put it back together with urlunsplit.

Write merge(*layers, **overrides). Each layer is a dict of settings: default settings first, then the user’s, and so on. Later layers win, and overrides win last. When two layers both hold a dict under the same key, merge those dicts too, at any depth. Return a new dict, and never change a layer: the caller keeps using them.

Example: merge({"theme": "light", "db": {"host": "localhost", "port": 5432}}, {"db": {"port": 6543}}, theme="dark") gives {"theme": "dark", "db": {"host": "localhost", "port": 6543}}.

Hint 1 of 3

Start from a new empty dict, not from the first layer: dict.update() changes the dict it’s called on.

Hint 2 of 3

update() replaces a nested dict whole, so {"db": {"port": 6543}} loses the host.

Hint 3 of 3

When both values are dicts, merge them by calling merge on them. Copy a dict you put in, so the result never shares it with a layer.

Merge settings in layers

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: merge({"a": 1, "b": 2}, {"b": 3}, c=4) → {'a': 1, 'b': 3, 'c': 4}
  2. Not run yet: merge(first, second), and first must not change → {'a': 1, 'b': 2}
  3. Not run yet: merge({"theme": "light", "db": {"host": "localhost", "port": 5432}}, {"db": {"port": 6543}}, theme="dark") → {'theme': 'dark', 'db': {'host': 'localhost', 'port': 6543}}
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test
  8. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Update the first layer

The idea: Take the first layer and update() it with each later layer, then with overrides.

*layers and **overrides take the layers and the keyword settings as they come.

def merge(*layers: dict, **overrides: object) -> dict:
"""Merge settings: later layers win, then overrides. Update the first layer."""
if not layers:
return dict(overrides)
merged = layers[0]
for layer in layers[1:]:
merged.update(layer)
merged.update(overrides)
return merged
Time
O(s)
Space
O(1)
Why
s is the number of settings in all the layers. Nothing new is made.

Where it breaks: update() changes the dict it’s called on, so the caller’s default settings now hold the user’s. And a nested dict is replaced whole: the user’s {"db": {"port": 6543}} wipes out the host.

Better

A new dict

The idea: Start from a new empty dict, and update() it with each layer, then with overrides.

The waste in Okay: it reuses the caller’s first layer as the result.

(*layers, overrides) is a tuple of every layer, with the overrides last, so one loop covers them all.

Highlighted lines are new in this rung.

def merge(*layers: dict, **overrides: object) -> dict:
"""Merge settings into a new dict: later layers win, then overrides."""
merged: dict = {}
for layer in (*layers, overrides):
merged.update(layer)
return merged
Time
O(s)
Space
O(s)was O(1)
Why
One new dict that holds every top-level setting.
What changed from the previous rung
def merge(*layers: dict, **overrides: object) -> dict:
"""Merge settings: later layers win, then overrides. Update the first layer."""
if not layers:
return dict(overrides)
merged = layers[0]
for layer in layers[1:]:
"""Merge settings into a new dict: later layers win, then overrides."""
merged: dict = {}
for layer in (*layers, overrides):
merged.update(layer)
merged.update(overrides)
return merged

Where it breaks: nested dicts. The db setting is replaced, not merged, and the result’s db is the user’s own dict, so changing it changes theirs.

Best

Merge every level

The idea: When both values are dicts, merge them with merge itself; copy any dict you put in.

The waste in Better: it stops at the top level, where the settings that need merging are one level down.

merge(old, value) merges two nested dicts, and merge(value) makes a fresh copy of one. So the result shares no dict with any layer.

Highlighted lines are new in this rung.

def merge(*layers: dict, **overrides: object) -> dict:
"""Merge settings into a new dict, at every level: later layers win, then overrides."""
merged: dict = {}
for layer in (*layers, overrides):
for key, value in layer.items():
old = merged.get(key)
if isinstance(old, dict) and isinstance(value, dict):
merged[key] = merge(old, value) # both are dicts: merge them too
elif isinstance(value, dict):
merged[key] = merge(value) # a new copy, so the caller's dict stays theirs
else:
merged[key] = value
return merged
Time
O(s)
Space
O(s)
Why
Every setting at every level is visited once, and every dict in the result is new.
What changed from the previous rung
def merge(*layers: dict, **overrides: object) -> dict:
"""Merge settings into a new dict: later layers win, then overrides."""
"""Merge settings into a new dict, at every level: later layers win, then overrides."""
merged: dict = {}
for layer in (*layers, overrides):
merged.update(layer)
for key, value in layer.items():
old = merged.get(key)
if isinstance(old, dict) and isinstance(value, dict):
merged[key] = merge(old, value) # both are dicts: merge them too
elif isinstance(value, dict):
merged[key] = merge(value) # a new copy, so the caller's dict stays theirs
else:
merged[key] = value
return merged

When Best isn’t best: settings often hold lists, and this copies none of them. If a list should be merged too, or replaced, decide and say so. For big configs, a library such as pydantic-settings reads, merges and checks settings for you.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    def add(item, items=[]):
        items.append(item)
        return items
    
    add("pen")
    print(add("book"))
  2. Spot the bugA caller passes counts={} to start a running tally, but their dict stays empty. Why?
    def tally(*names, counts=None):
        counts = counts or {}
        for name in names:
            counts[name] = counts.get(name, 0) + 1
        return counts
  3. Predict the outputWhat does this print?
    def show(*args, **kwargs):
        return args, kwargs
    
    nums = [1, 2]
    print(show(*nums, x=3))
  4. Multiple choiceIn def f(a, *rest, b=0), how can a caller set b?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • Defaults are made once, when def runs. Never use a list, dict or set as a default.
  • Use items=None, then if items is None: items = []. Check is None, not or.
  • *args is a tuple of extra positional arguments; **kwargs is a dict of extra keyword arguments.
  • A parameter after *args or a lone * is keyword-only. One before / is positional-only.
  • At a call, f(*items, **options) spreads a list and a dict out into arguments.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started