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
TrueorFalsein a condition (e.g.,if x == True:rather thanif 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
eliflines 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 withself.module.params.get('key'). Using.get()returnsNonefor a missing key instead of raising aKeyError, 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 repeatedand 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
elifblock, 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
actionsdictionary. 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 actionscheck. 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 anelif.
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
actionsis extracted to a class-level constant, the mapping itself can be verified directly in unit tests without invokingrun(). - Functions are stored as references, not calls (
self.present_validatenotself.present_validate()). The()that actually invokes the function is on thefunc()line inside theifblock. 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.