Nothing broken. Just the fact that makes the loop below quietly expensive.
parts = ''
for word in ['a', 'b', 'c']:
parts += word # a new string every time:
# '' + 'a' -> 'a'
# 'a' + 'b' -> 'ab' ('a' discarded)
# 'ab' + 'c' -> 'abc' ('ab' discarded)
print('-'.join(['a', 'b', 'c'])) # -> 'a-b-c'
# join measures everything once, allocates once, fills once.
- Strings are immutable, so nothing is appended
There is no way to add a character to an existing Python string — every += creates a new one holding a copy of everything so far. Copying an n-character string n times is the same quadratic curve as scanning a list inside a loop, arriving in different clothes.
- join separates *between*, which is why the fix-up disappears
The separator goes between the pieces, never after the last one, so there is no trailing & to remove and no special case for the empty list — ''.join([]) is just ''. Whenever you find yourself trimming a character off the end of something you built, that is the signal.
- The separator owns the join, not the list
It reads backwards the first few times: '&'.join(parts), not parts.join('&'). It is a string method because the separator is the string and the argument can be any iterable of strings — a list, a generator, a comprehension.
You are producing one small string per parameter and then putting a character between them. Those are two separate jobs, and Python has a method for the second one.
Make a list of 'key=value' strings first — a comprehension over params.items() does it — then join that list with '&'.
'&'.join(f'{key}={value}' for key, value in params.items()). The f-string converts both sides to text for you, the generator produces the pieces, and join puts the separator only between them. One return, no loop, no guard.
def build_query(params): return '____'.____(f'{key}={value}' for key, value in params.____())
def build_query(params):
return "&".join(f"{key}={value}" for key, value in params.items())
The quadratic copying, the trailing separator, and the guard that existed only to remove it — all gone, not fixed. That is the difference between refactoring and patching: the good version does not handle those cases better, it stops having them.
Anything inside {} in an f-string is converted to text automatically, which is why str(key) and str(params[key]) are not needed. Fewer conversions written by hand means fewer places to forget one.
for key in params then params[key] performs a lookup you already had the answer to. items() hands you both halves at once — clearer, and one less operation per parameter. Small, and it is the same instinct as the lookup exercise: do not re-derive what you are already holding.
Do not write this at all for real URLs. urllib.parse.urlencode handles the encoding this exercise skips — spaces, ampersands inside values, non-ASCII characters, and repeated keys — and getting that wrong is how you end up with a broken link or, in the wrong context, an injection. Also note += in a loop is genuinely fine for a handful of pieces; CPython even optimises some cases. Reach for join because it is clearer and has no trailing-separator bug, and treat the speed as a bonus.
- Swap in urllib.parse.urlencode and compare the output for a value containing a space and an ampersand. Decide which differences matter.
- Support a list value so {'tag': ['a', 'b']} becomes 'tag=a&tag=b', and note where the single join stops being enough.
- Sort the keys behind an option, then argue with yourself about whether a stable URL for caching is worth losing the caller’s intended order.