IT EN

How to defuse an if: nine tools to straighten out arrow code

You inherit shipping.py: an arrow-shaped function with complexity 18. One afternoon, nine tools (guard clauses, dictionaries, bisect, polymorphism, Null Object, match, EAFP) and the discovery that in Python the branchless trick is slower.

Bomb panel with coloured wires labelled if, elif and else, some cut by wire cutters, and a red display showing complexity 18

Monday, 15 September 2025, early afternoon. Your coffee is still hot when you open shipping.py, the module that is yours as of today. The person who wrote it has changed jobs, and what they left you is one function, a comment at the top saying "do not touch" and a geometric shape: the code slides to the right, one indent after another, like an arrow aimed at the edge of the screen.

def shipping_cost(order, express=False):
    if order is not None:
        if order.get("lines"):
            weight = sum(r["weight"] for r in order["lines"])
            if weight <= 30:
                if order["country"] == "IT":
                    carrier = "BRT"
                elif order["country"] == "FR":
                    carrier = "Colissimo"
                elif order["country"] == "DE":
                    carrier = "DHL"
                elif order["country"] == "ES":
                    carrier = "SEUR"
                else:
                    carrier = "UPS"
                if weight <= 2:
                    cost = 4.90
                elif weight <= 5:
                    cost = 6.90
                elif weight <= 10:
                    cost = 9.90
                elif weight <= 20:
                    cost = 14.90
                else:
                    cost = 19.90
                if express:
                    cost = cost * 2
                if order.get("coupon") is not None:
                    if order["coupon"]["kind"] == "percent":
                        cost = cost * (1 - order["coupon"]["value"] / 100)
                    elif order["coupon"]["kind"] == "fixed":
                        cost = max(0, cost - order["coupon"]["value"])
                    elif order["coupon"]["kind"] == "free":
                        cost = 0
                return carrier, round(cost, 2)
            else:
                raise ValueError("too heavy")
        else:
            raise ValueError("empty order")
    else:
        raise ValueError("no order")

You read it twice. It works, to be fair: for three years it has picked the carrier and the cost of every parcel leaving the warehouse. But to find out what happens to a 12 kg French order with a fixed-amount coupon, you have to trace four levels of indentation with your finger. And the errors have ended up at the very bottom, miles away from the ifs that cause them: the raise ValueError("no order") on the last line answers an if that sits 40 lines higher up.

You run radon, which measures the complexity of Python code, and it hands you a grade like a school report:

$ radon cc -s shipping.py
shipping.py
    F 1:0 shipping_cost - C (18)

Eighteen. This afternoon you are going to bring that number down, one tool at a time.

What that 18 measures

The number is called cyclomatic complexity. It was invented by Thomas McCabe, who in 1976 was working for the US Department of Defense and published it in a paper with the not very imaginative title A Complexity Measure. The idea is simple: draw the function as a map of junctions and count the independent roads from the entrance to the exit. In practice it is the number of decisions plus one: every if, every elif, every and and every or adds a fork.

Why should you care? Because every road is a case to test. Eighteen independent roads means you need at least eighteen different tests just to walk each of them once, and nobody ever wrote them. McCabe suggested splitting any function above 10. Twenty years later, in a NIST guide on software testing, he and Arthur Watson wrote that the limit had "significant supporting evidence", though some teams had used 15 successfully. Eighteen is fine under neither.

An Italian against ifs

You are not the first person to dislike that arrow. On 23 November 2007, at the Italian Agile Day, Francesco Cirillo launched the Anti-IF Campaign. Yes, the same Cirillo of the Pomodoro Technique, the one with the tomato-shaped kitchen timer for working in 25-minute blocks. The campaign's website explained why: "Lots of teams want to be agile, but they don't know the basics for cutting down on code complexity." In June 2008 Kent Beck, the father of Extreme Programming, signed up, followed by a long list of developers.

The target was not every single if. Craig Larman, a friend of Cirillo's, made it clear: the campaign "does not mean all ifs are bad". The enemy was mostly the if that checks what kind of thing you are holding to decide what to do with it. Your module has plenty of those.

First of all, the safety net

There is one rule worth more than all the tools you are about to use: you don't reshape code without a net underneath. Before touching a single line, you copy the old function into a separate file and write a test that generates random orders (countries, weights, coupons, empty orders) and compares the old code's result with the new one's:

for _ in range(20_000):
    o = random_order()
    for express in (False, True):
        old = legacy.shipping_cost(o, express=express)
        new = (express_shipping if express else standard_shipping)(o)
        assert old == new, (o, express, old, new)

Twenty thousand orders, each in standard and express flavour: forty thousand comparisons. The new function must give exactly the same results as the old one, exceptions included. Now you can start.

Tool 1: bouncers at the door

The first thing you do is straighten the arrow. The three error conditions (no order, empty order, parcel too heavy) are entry checks: if the order is invalid there is nothing to calculate. So you move them to the top, one after another, each with its raise right underneath, like bouncers at a nightclub door:

def _base_cost(order):
    if not order or not order.get("lines"):
        raise ValueError("empty order")
    weight = sum(r["weight"] for r in order["lines"])
    if weight > MAX_WEIGHT:
        raise ValueError("too heavy")
    # from here on: the order exists, has lines, the weight is fine
    ...

Martin Fowler, in his refactoring catalogue, calls this Replace Nested Conditional with Guard Clauses. Notice one honest detail: the ifs haven't gone anywhere. But they are no longer nested, and everything that follows can take a valid order for granted. It's like reading a book with footnotes instead of a 40-page appendix.

Tool 2: a dictionary instead of a ladder

Then you look at the if/elif ladder for carriers: five rungs to say "if the country is X, use carrier Y". That isn't logic, it's a table dressed up as code. And in Python tables have a name: dictionary.

CARRIERS = {"IT": "BRT", "FR": "Colissimo", "DE": "DHL", "ES": "SEUR"}

carrier = CARRIERS.get(order["country"], "UPS")

One line, and the "every other country" case is the second argument of .get(). Adding Portugal no longer means adding a branch to the code: it means adding an entry to a list.

For Python this is almost dogma. The language never had the switch statement of C and Java, and not by accident. It was proposed in 2001 (PEP 275) and again in 2006 (PEP 3103, written by Guido van Rossum himself). In 2007, during his PyCon keynote, Guido ran a quick poll of the audience. The result: the proposal was rejected because it had "no popular support". For years the official answer to "how do I write a switch in Python?" was: use a dictionary.

Tool 3: the table and bisect

The weight bands are trickier: they are not equalities ("the country is FR") but ranges ("up to 2 kg", "up to 5 kg"). A dictionary won't do. But the standard library has a module that does exactly this, bisect, which finds where a value would go in a sorted list. The official documentation shows it with an example about exam grades:

>>> def grade(score):
...     i = bisect([60, 70, 80, 90], score)
...     return "FDCBA"[i]

Four thresholds, five grades, zero ifs. You do the same with the rates:

from bisect import bisect_left

LIMITS_KG = [2, 5, 10, 20, 30]
RATES   = [4.90, 6.90, 9.90, 14.90, 19.90]
MAX_WEIGHT  = LIMITS_KG[-1]

def rate(weight):
    return RATES[bisect_left(LIMITS_KG, weight)]

There is a trap, and it's exactly the one you were about to fall into. bisect (that is, bisect_right) and bisect_left behave differently when the value lands exactly on a threshold:

>>> from bisect import bisect, bisect_left
>>> bisect_left([2, 5, 10, 20, 30], 2)   # 2 kg: first band, 4.90
0
>>> bisect([2, 5, 10, 20, 30], 2)        # 2 kg: second band, 6.90. Oops.
1

The old code said weight <= 2, so 2 kg belongs to the first band and you need bisect_left. With the other one, every parcel weighing exactly 2, 5, 10 or 20 kilos would be charged one band too many. The 40,000-order test catches it on the first run, because the random weights include the exact ones. This is why you write the net first.

The reward is big, though: the rates are now data. They can live in a config file or in a database table. When the price list changes (and it will), you won't touch the logic.

Tool 4: let the objects decide

You get to the coupons, and here is exactly the kind of if Cirillo's campaign was born to fight: "if the coupon is a percentage do this, if it's fixed do that, if it's free do something else". Every time someone invents a new kind of coupon, someone else has to find every place in the code with that if ladder and add a rung. Miss one, and the bug is served.

The alternative is for each kind of coupon to know what to do on its own. Fowler calls it Replace Conditional with Polymorphism:

from dataclasses import dataclass

@dataclass
class Percent:
    value: float
    def apply(self, cost): return cost * (1 - self.value / 100)

@dataclass
class Fixed:
    value: float
    def apply(self, cost): return max(0, cost - self.value)

class Free:
    def apply(self, cost): return 0

Three classes, each with an apply method. Whoever uses the coupon no longer asks "what kind are you?": it calls coupon.apply(cost) and that's it. Python doesn't even need a common base class, as long as the method is there (that's duck typing: if it quacks like a duck, Python treats it as a duck). A new kind of coupon tomorrow will be a new class, without touching the ones that already work.

Tool 5: the object that does nothing

One coupon if is left: the one checking whether there is a coupon at all (is not None). That if has a distinguished history. In 1965 Tony Hoare, designing the ALGOL W language, introduced the null reference "simply because it was so easy to implement". In 2009, at a conference in London, he publicly called it "my billion-dollar mistake": ever since, every reference that might be null forces a check every time you use it. Forget it, and the program falls over.

The cure is a little zen: a coupon that does nothing.

class NoCoupon:
    def apply(self, cost): return cost

It's called a Null Object, and Fowler catalogues it as Introduce Special Case. When the order has no coupon, instead of None you get a NoCoupon, which returns the cost unchanged. The rest of the code no longer has to ask anything. In a small way, Python already does this every time you write dict.get(key, default) or use a defaultdict: the "it isn't there" case becomes an ordinary value.

Tool 6: match, but at the border

Now you need somewhere to turn the raw coupon (a dictionary coming from the order's JSON) into one of the objects above. It's the perfect job for match, the structural pattern matching that arrived with Python 3.10 in October 2021 (PEP 634):

COUPON = {"percent": Percent, "fixed": Fixed,
          "free": lambda value=0: Free()}

def coupon_from(data):
    match data:
        case None:
            return NoCoupon()
        case {"kind": kind, **rest}:
            return COUPON[kind](**rest)

Two cases: either there is nothing, and you return the coupon that does nothing, or there is a dictionary with a kind key, and the rest of the keys go to the right constructor. Let's be clear: match is not a way to avoid decisions. It's another way to write them, and radon knows it: it gives coupon_from a 3. But now the decision is made once, at the border, where data enters the program. From there on, the code works with objects that know what to do and never has to ask anyone anything.

This is the most important rule of the whole afternoon: an if on the kind of a thing is fine once, when you create it. Repeat it every time you use it, and that's where the arrow is born.

Tool 7: lose the flag

You still have the express=False parameter. You search the project for callers and find this:

shipping_cost(order, True)   # True what?

True what? To find out you have to open the function. A boolean parameter that changes behaviour is a function containing two functions, with an if inside choosing which one to run. Fowler calls it Remove Flag Argument: give each behaviour its own name.

def _finish(order, carrier, cost):
    coupon = coupon_from(order.get("coupon"))
    return carrier, round(coupon.apply(cost), 2)

def standard_shipping(order):
    carrier, cost = _base_cost(order)
    return _finish(order, carrier, cost)

def express_shipping(order):
    carrier, cost = _base_cost(order)
    return _finish(order, carrier, cost * 2)

Now the calling code reads express_shipping(order). Nobody has to wonder what True means anymore.

Tool 8: ask forgiveness, not permission

While you're there, in the same file you find a small stock function that does an if before reading a dictionary. Python's official glossary has two acronyms for these two styles. LBYL, Look Before You Leap, is the style full of ifs that check before acting. EAFP, Easier to Ask for Forgiveness than Permission, acts and handles the exception if something goes wrong.

# LBYL: look before you leap
if sku in stock:
    return stock[sku]
return 0

# EAFP: leap, and apologise if you fall
try:
    return stock[sku]
except KeyError:
    return 0

# or, shorter still
return stock.get(sku, 0)

The glossary adds a detail few people remember: with multiple threads, LBYL can be plain wrong. Between checking if sku in stock and reading stock[sku], another thread might have removed that key. With EAFP, the check and the read are a single operation. One warning, though: exceptions are for exceptional cases, like a missing key or a missing file. Don't use them for business rules. We already covered that in another article.

Tool 9: the branchless trick (that doesn't work in Python)

You have run into a Stack Overflow question from 2012 more than once, and it has become legendary: why is a C++ loop over a sorted array much faster than the same loop over the same array unsorted? The answer is branch prediction. To avoid wasting time, the processor tries to guess in advance which way each if will go. With sorted data it almost always guesses right; with shuffled data it gets it wrong about half the time, and every miss costs plenty of clock cycles, because the work done in advance has to be thrown away. The cure, in C, is branchless code: no jumps, just arithmetic.

In Python booleans are numbers too (True + True is 2), so you can write the trick:

def with_if(data):
    t = 0
    for x in data:
        if x >= 128:
            t += x
    return t

def without_if(data):
    t = 0
    for x in data:
        t += x * (x >= 128)   # True is 1, False is 0
    return t

You get curious and measure, with a million numbers between 0 and 255 on Python 3.13. The figures are the best time out of seven runs:

                 random    sorted
with if          32.4 ms   28.1 ms
without if       42.0 ms   35.6 ms

Two surprises. The version without the if is about 30% slower: the multiplication and the boolean conversion cost more than the jump you were trying to avoid. And the sorted array is faster even in the version without the if, so the difference doesn't come (only) from your if. It comes from the interpreter, which makes thousands of choices of its own to run each of your lines, and probably from how the data sits in memory. Moral: in Python, branchless tricks are folklore. You remove ifs for the people reading the code, not for the processor. And if you really need speed, loops move into NumPy or compiled code.

The if that stays

It's half past six. The test runs: forty thousand comparisons, all identical. You run radon again:

$ radon cc -s shipping.py
shipping.py
    F 41:0 _base_cost - A (5)
    F 29:0 coupon_from - A (3)
    C 11:0 Percent - A (2)
    ...
    F 54:0 standard_shipping - A (1)
    F 59:0 express_shipping - A (1)

The function scoring 18 no longer exists. In its place there are small functions, the most complex scoring 5, each readable at a glance. The file, though, is longer than before: from 41 lines to about sixty. If someone at your company measures productivity in lines of code, you did a great job today, for all the wrong reasons.

And some ifs stayed, on purpose. The one in _base_cost that rejects parcels over 30 kilos isn't a design flaw: it's a real-world rule, and that if is the clearest way to write it. It doesn't need to become a class hierarchy. In the same way, an if/else with two cases doesn't become more elegant as a dictionary with two keys. And polymorphism has a cost: logic that used to live in one function is now spread across several classes.

In the end, the difference comes down to this. An if that expresses a domain rule ("we don't ship over 30 kg") is documentation that runs. An if that asks an object "what kind are you?" for the tenth time in ten different files is a design problem, and sooner or later somebody will pay for it.

You commit, and delete the "do not touch" comment. It took you an afternoon: in Cirillo's units, nine pomodoros, breaks included.

Sources