Every line below is the same logic said twice — once per row, then once per column. The column version is what the refactor is made of.
import pandas as pd
import numpy as np
frame = pd.DataFrame({'kg': [2.0, 5.0], 'far': [False, True]})
# Arithmetic: no loop needed, the operator already means 'every row'.
frame['kg'] * 2.5 # -> 5.0, 12.5
# A conditional per row -> np.where(condition, if_true, if_false)
np.where(frame['far'], 5.0, 0.0) # -> array([0., 5.])
# A comparison gives a boolean column, which multiplies as 1 and 0:
frame['far'] * 5.0 # -> 0.0, 5.0 (same answer)
# min and max per row -> clip, which takes both at once
pd.Series([1.0, 99.0]).clip(3.0, 60.0) # -> 3.0, 60.0
# Rounding a column, then leaving pandas behind:
pd.Series([1.005, 2.675]).round(2).tolist() # -> [1.0, 2.68]
# What iterrows costs, beyond the loop: each row is a new Series, and a
# Series holds one dtype — so a row of mixed columns becomes object, and
# every number in it is boxed on the way out.
for _, row in frame.iterrows():
print(type(row['kg']).__name__) # -> float, float ... via object
- np.where is the per-row if/else, said once
np.where(condition, a, b) evaluates the condition for every row and picks from a or b elementwise, where a and b can themselves be columns. That covers almost every if/else inside a row loop. The one thing to watch is that both branches are computed for every row — so np.where(x != 0, y / x, 0) still divides by zero and still warns, even though the result is discarded.
- A boolean column is already 1 and 0
frame['express'] * 2 is 2 where True and 0 where False, and (1 + frame['express']) is the multiplier this exercise needs — 2 for express, 1 otherwise. Arithmetic on booleans is not a trick; it is the same thing sum() relies on when it counts a condition. Whether it reads better than np.where depends on the expression, and it is worth having both.
- clip is min and max in one call, and it is the readable one
clip(lower, upper) applies a floor and a ceiling to a whole column, which is what max(3.0, min(60.0, cost)) says per row — and clip says it in an order a reader does not have to unpick. The nested min/max form is a small puzzle every time you meet it, because the floor is on the outside. Worth knowing which way round they go, too: Series.clip(60, 3) — bounds reversed — leaves the column completely unchanged, while np.clip(array, 60, 3) applies them in order and flattens everything to 3.
Take the loop body one line at a time and ask what it would be for a whole column. The first line is already a column expression — frame['weight_kg'] * BASE_PER_KG needs no change at all.
The two ifs become: a surcharge of np.where(frame['destination'] != 'domestic', OVERSEAS_SURCHARGE, 0.0), and a multiplier of np.where(frame['express'], 2, 1). Both are columns, and they combine with the base cost by ordinary arithmetic.
max(MINIMUM, min(CAP, cost)) for a whole column is .clip(MINIMUM, CAP). Then .round(2) rounds the column and .tolist() hands back plain Python floats. Reach for .to_numpy() anywhere in that chain and the float64s come with it.
import pandas as pd import numpy as np def shipping_costs(frame): base = frame['weight_kg'] * BASE_PER_KG surcharge = np.____(frame['destination'] != 'domestic', OVERSEAS_SURCHARGE, 0.0) multiplier = np.where(frame['express'], 2, 1) costs = (base + surcharge) * multiplier return costs.____(MINIMUM, CAP).____(2).____()
import pandas as pd
import numpy as np
BASE_PER_KG = 2.50
OVERSEAS_SURCHARGE = 5.00
MINIMUM = 3.00
CAP = 60.00
def shipping_costs(frame):
base = frame["weight_kg"] * BASE_PER_KG
# The two ifs, as columns: np.where picks branch-per-row, elementwise.
far = np.where(frame["destination"] != "domestic", OVERSEAS_SURCHARGE, 0.0)
multiplier = np.where(frame["express"], 2, 1)
# Brackets before the doubling: express pays double the surcharge too.
costs = (base + far) * multiplier
# clip is the floor and the ceiling in one, applied last.
return costs.clip(MINIMUM, CAP).round(2).tolist()
Read the two versions side by side and they are line-for-line the same calculation in the same order — base, surcharge, multiplier, bounds, round. That is what a good vectorisation looks like: not a clever one-liner, but the same steps with each one applied to a column instead of a value. When the rewrite ends up shaped differently from the original, that is usually a sign the logic changed with it.
It drops the index, which matters if you are about to combine it with a differently-indexed object — but adding an array to a Series lines up by position and keeps the Series' index, which is what happens above. When you do want the index kept explicitly, Series.where(cond, other) is the pandas version: same idea, opposite argument order, and it keeps only where the condition is *true*.
list(series) and .tolist() both hand back plain Python floats — pandas boxes its scalars on the way out of a Series. What leaks numpy’s float64 is dropping to the array first: list(series.to_numpy()), or iterating .values, gives you the stored values untouched. Say .tolist() and the question never arises; it is the same boundary conversion as int() on a count, spelled for a column.
Every version below is correct and verified against the same cases by the build. They are here because knowing which one to reach for is the part that takes judgement, and the trade-off is stated rather than left for you to guess.
import pandas as pd
BASE_PER_KG = 2.50
OVERSEAS_SURCHARGE = 5.00
MINIMUM = 3.00
CAP = 60.00
def shipping_costs(frame):
base = frame['weight_kg'] * BASE_PER_KG
surcharge = (frame['destination'] != 'domestic') * OVERSEAS_SURCHARGE
multiplier = 1 + frame['express']
costs = (base + surcharge) * multiplier
return costs.clip(MINIMUM, CAP).round(2).tolist()
A boolean column is 1 and 0, so the surcharge is a multiplication and the multiplier is 1 plus the flag. It is shorter and it needs no numpy import — against which, np.where names the two branches where this makes the reader work out that (1 + express) means 'double if express'. Worth knowing, worth using sparingly.
Vectorising is not always the right answer. When the per-row logic genuinely cannot be expressed in column operations — a call to an external service, a recursive rule where row n depends on your own row n-1, a per-row branch over ten cases with different lookups — a loop is honest and a chain of nested np.where is not. In that case prefer itertuples() over iterrows(): it does not build a Series per row and it is several times faster for the same loop. And at ten rows, none of this is about speed: the reason to prefer the column form here is that the whole calculation is visible in six lines instead of hidden inside an iteration.
- Add a per-destination rate from a dict and use map() on the column instead of np.where.
- Return the surcharge and the multiplier alongside the cost, so a customer can be shown the breakdown — without putting any of them into the caller’s frame.
- Time both versions on a hundred thousand rows, and then on ten, and see where the crossover is.