Nothing broken here. It is the arithmetic that makes the next tab worth fixing, and the reason the fix is not premature optimisation.
# For each of n orders, walk m users: n * m comparisons # # 50 orders x 50 users = 2,500 instant # 5,000 orders x 5,000 users = 25,000,000 about 11 seconds # 50,000 orders x 50,000 users = 2,500,000,000 about 20 minutes # # Build a dict once, then look up: n + m operations # # 50,000 orders + 50,000 users = 100,000 still instant
- The cost is multiplied, not added
Two loops side by side cost n + m. One loop inside another costs n × m. Ten times the data makes the first ten times slower and the second a hundred times slower. That gap is why this survives testing and then falls over — small data hides the shape of the curve entirely.
- A dict lookup does not search
Reading users[7] does not look through the users. It computes where the answer must be and goes there, so it costs the same on any size of dictionary. Building the dict costs one pass over the users, and every lookup afterwards is free by comparison.
- Build the index once, outside the loop
The whole fix is deciding where the dict gets built. Inside the loop it is rebuilt for every order and you have gained nothing at all — you have moved the scan, not removed it. Outside, it is built once. Where a piece of setup lives is often the entire difference between fast and slow.
The inner loop is asking one question over and over: what is the name for this id? Nothing about the answer changes between orders. What could you prepare once, before the outer loop starts?
Turn the users list into a dict mapping id to name. A dict comprehension does it in one line, and after that the inner loop is just a subscript.
names = {user['id']: user['name'] for user in users} — built once, before the loop. Then for each order, names[order['user_id']] both finds the name and raises KeyError for a missing one, so the explicit check disappears too.
def attach_names(orders, users): names = {____['id']: ____['name'] for user in ____} return [{**order, 'name': names[order['____']]} for order in orders]
def attach_names(orders, users):
names = {user["id"]: user["name"] for user in users}
return [{**order, "name": names[order["user_id"]]} for order in orders]
The starter needed a sentinel, a check and a raise to report a missing user. Subscripting the dict does all three: names[missing] raises KeyError with the offending id already in the message. When a refactor deletes error-handling code and the behaviour stays the same, you have usually found the right shape rather than a shortcut.
It copies the order’s fields into a fresh dict and adds one more, which is why the caller’s data survives. order['name'] = ... would be shorter and would edit a dict somebody else still holds — the same class of bug as every mutation case on this site.
Optimising before measuring is a real trap, and this is not an instance of it. The dict version is the same length, arguably clearer, and removes a whole failure mode. When the better-scaling version is also the simpler one, there is no trade to weigh — take it and move on.
If users only ever holds two or three entries and always will, the nested loop is fine and building an index is noise. The judgement is about growth, not size today: ask whether anything stops this list getting long. And if both lists come from a database, neither version belongs in Python at all — that is a JOIN, and the database will do it faster than any loop you write.
- Handle a missing user by falling back to a placeholder name instead of raising, and decide which behaviour a reporting job wants versus a billing job.
- Index by something that is not unique — several users per company — and notice how the dict has to become a dict of lists, which is the group-by exercise wearing a hat.
- Time both versions on 5,000 orders and 5,000 users in the editor. Seeing the number yourself is worth more than being told it.