Skip to content

Refactoring Python if/elif Chains with Tuple Comparisons and Dispatch Tables

Long if/elif chains that test multiple conditions together are a common pattern in Python modules, particularly in Ansible modules that branch on a state parameter paired with a secondary flag. They work, but as the number of combinations grows, the code becomes harder to read and harder to extend without introducing bugs.

This post shows two practical refactoring options that make multi-condition branching cleaner, more readable, and easier to scale.

The Starting Point

Consider an Ansible module class that manages a resource lifecycle. The run() method dispatches to different helper functions based on two parameters: state (what lifecycle action to take, typically present or absent) and validate (whether to perform a dry run or actually apply changes).

A straightforward first pass looks like this:

def run(self):
    if self.module.params['state'] == 'present' and self.module.params['validate'] == True:
        result = self.present_validate()
        self.module.exit_json(changed=False, meta=result)
    elif self.module.params['state'] == 'present' and self.module.params['validate'] == False:
        result = self.present_execute()
        self.module.exit_json(changed=True, meta=result)
    elif self.module.params['state'] == 'absent' and self.module.params['validate'] == False:
        result = self.absent_execute()
        self.module.exit_json(changed=True, meta=result)
    else:
        self.module.fail_json(msg="Not Valid Action")

This code is correct, but it has a few issues worth noting before refactoring:

  • It repeats self.module.params[...] in every branch, mixing dispatch logic with parameter lookups.
  • Comparing a boolean variable to True or False in a condition (e.g., if x == True: rather than if x:) is non-idiomatic Python. Neither option below addresses this directly, but once parameters are extracted into local variables, fixing this is straightforward.
  • Adding a fourth or fifth combination means adding more elif lines that are easy to misread or get wrong.

Option A: Tuple Comparison

The first improvement is to extract the parameters once into local variables and then compare them as a tuple. Each branch becomes a single, readable condition.

def run(self):
    state = self.module.params.get('state')
    validate = self.module.params.get('validate')

    if (state, validate) == ('present', True):
        result = self.present_validate()
        self.module.exit_json(changed=False, meta=result)
    elif (state, validate) == ('present', False):
        result = self.present_execute()
        self.module.exit_json(changed=True, meta=result)
    elif (state, validate) == ('absent', False):
        result = self.absent_execute()
        self.module.exit_json(changed=True, meta=result)
    else:
        self.module.fail_json(msg="Not Valid Action")

What changed:

  • self.module.params['key'] is replaced with self.module.params.get('key'). Using .get() returns None for a missing key instead of raising a KeyError, which is the safer, more defensive approach when the key may not be present.
  • The two conditions in each branch are combined into a single tuple comparison: (state, validate) == ('present', True). This is immediately readable as a pair and removes the repeated and self.module.params[...] in every branch.

Trade-offs:

  • Parameters are looked up once at the top rather than within each branch. Since Python dictionary access is O(1), even the original repeated lookups were negligible; this change improves readability, not performance.
  • Readability improves noticeably. The intent of each branch is visible at a glance.
  • Adding a new combination still requires a new elif block, which is the main limitation of this approach.

Option B: Dispatch Table

The second option eliminates the if/elif chain entirely, replacing it with a dictionary that maps each (state, validate) pair to its handler function and the changed flag value.

def run(self):
    state = self.module.params.get('state')
    validate = self.module.params.get('validate')

    actions = {
        ('present', True):  (self.present_validate,  False),
        ('present', False): (self.present_execute,   True),
        ('absent', False):  (self.absent_execute,    True),
    }

    key = (state, validate)
    if key in actions:
        func, changed = actions[key]
        result = func()
        self.module.exit_json(changed=changed, meta=result)
    else:
        self.module.fail_json(msg="Not Valid Action")

What changed:

  • The dispatch logic lives in the actions dictionary. Each key is a (state, validate) tuple and each value is a (handler_function, changed_flag) tuple.
  • The execution path shrinks to a single if key in actions check. Every combination follows the same code path; only the dictionary entry differs.
  • Adding a new combination is a one-line change to actions, with no risk of accidentally duplicating or incorrectly ordering an elif.

Trade-offs:

  • The pattern is slightly more abstract on first read. Developers unfamiliar with dispatch tables may need a moment to orient themselves.
  • For three or four combinations, Option A and Option B are both reasonable. As the number of combinations grows beyond that, the dispatch table scales significantly better.
  • Because each combination maps to its own handler, the handler functions can be tested in isolation. If actions is extracted to a class-level constant, the mapping itself can be verified directly in unit tests without invoking run().
  • Functions are stored as references, not calls (self.present_validate not self.present_validate()). The () that actually invokes the function is on the func() line inside the if block. This is a common source of confusion when first writing dispatch tables.

Choosing Between the Two

Option A (Tuple Comparison) Option B (Dispatch Table)
Readability High, especially for 2-4 branches High once the pattern is familiar
Adding new combinations New elif block required One new dictionary entry
Runtime performance Equivalent Equivalent
Best suited for Small, stable branch sets Larger or growing branch sets

Both options are improvements over the original. If the set of (state, validate) combinations is unlikely to grow and the team prefers a more traditional Python style, Option A is the right call. If the module is likely to gain more states or flags over time, Option B scales naturally and keeps the dispatch logic in one place.

Summary

Repeated dictionary lookups in if/elif conditions are easy to fix: extract the values once, then compare them as tuples. When the number of branches grows or is expected to grow, a dispatch table makes each combination a data entry rather than a code path, which is easier to read, easier to test (handler functions can be verified in isolation), and easier to extend without introducing subtle bugs.