When no built-in method does what you need, run your own function:
col.map(func) or col.apply(func) calls the function on every value of a column and returns a new column.
lambda writes a small function inline: df["product"].apply(lambda p: p.split()[0]).
df.apply(func, axis=1) calls the function on every row, so it can use several columns: row["units"] * row["unit_price"]. Without axis=1, apply passes whole columns instead, and row["units"] fails with a KeyError.
df.apply(func) on columns is handy for a summary per column, like the range c.max() - c.min().
apply is a loop in disguise. On a large table, the same result written with column arithmetic, np.where or a .str method runs many times faster. Reach for apply when the logic really needs it, and know the vectorized alternative.