The built-ins are the last place Python looks, not the first — so a local with the same name wins.
sum([1, 2, 3]) # -> 6 the built-in sum = 0 # a local named sum now exists sum += 5 sum([1, 2, 3]) # TypeError: 'int' object is not callable # The lookup order: Local, Enclosing, Global, Built-in # the built-in is only reached if nothing nearer has that name # And the shadowing lasts for the whole function, not from that line on: # assigning to sum anywhere makes it local everywhere in that scope
- Read the type in the message, not just the words
'int object is not callable' names the type of the thing that was called. So the question is not 'why is this line broken' but 'why is this name an int' — and that points at an assignment, usually a long way above. The same reading works for 'list object is not callable' and 'str object is not callable': in every case a name you expected to be a function is holding data.
- Assigning anywhere makes the name local everywhere
Python decides at compile time that a name assigned anywhere in a function is local to it for the entire function — so moving the sum() call above the assignment does not help, it produces UnboundLocalError instead. Shadowing is a property of the scope, not of the line.
- The fix is a name, not a workaround
You could import builtins and call builtins.sum, and you would be working around a variable name. Renaming the local to total costs nothing, removes the confusion for every future reader, and is the only fix that also prevents the next occurrence. Linters flag this — ruff’s A001 and pylint’s redefined-builtin — which is worth turning on.
Read the error type: an int was called. Which name in this function is an int, and what was it before?
The loop assigns to a name that is also a built-in function, and the expression below it tries to call that built-in. Once a function assigns to a name, that name is local for the whole function.
Rename the accumulator — total is the obvious choice, and it is what the result key is called anyway. While you are there, notice that the loop is now doing what sum(amounts) would do in one call.
def summarise(amounts, weights): if len(amounts) != len(weights): raise ValueError('amounts and weights must be the same length') ____ = sum(amounts) return { 'total': ____, 'weighted': sum(a * w for a, w in zip(amounts, weights)), }
def summarise(amounts, weights):
if len(amounts) != len(weights):
raise ValueError("amounts and weights must be the same length")
return {
"total": sum(amounts),
"weighted": sum(
amount * weight for amount, weight in zip(amounts, weights)
),
}
Once the accumulator is not called sum, it becomes obvious that the loop was reimplementing sum() — so the fix removes four lines rather than renaming them. That is common with shadowing bugs: the variable is usually named after the built-in because it is doing the built-in’s job, and the real fix is to use the built-in.
Without the length check, mismatched inputs produce a weighted total quietly missing its last terms and nothing raises. The guard turns a wrong number into an error — and it has to be explicit, because zip’s default behaviour is to truncate. On Python 3.10 and later, zip(..., strict=True) does it for you.
sum, list, dict, set, str, int, id, type, max, min, filter, next, input, hash and format are all built-ins people reach for as variable names, and every one of them causes this. The convention when the obvious name really is the right one is a trailing underscore — list_, id_ — which reads oddly and is still better than the alternative.
There is no version of this worth keeping — shadowing a built-in is always worth renaming. What is worth knowing is that the same bug appears at module level and is far harder to see: a helper called `filter` in a utilities module shadows the built-in for everything that does `from utils import *`. Turn on the linter rule rather than relying on catching it by eye, and be aware that some of these names are so natural — `id` on a database row, `type` on an event — that the pressure to use them never really goes away.
- Use zip(..., strict=True) instead of the length check and work out what changes about when the error arrives.
- Add an average to the result and decide what it should be for an empty list — there are two defensible answers.
- Deliberately shadow `len` in a small function and see what error you get, then work out why it is a different message from this one.