Decision Table Lookups
Concepts covered: Dictionaries
Dispatch keys on a single value, while plenty of business rules key on 2 or 3. How long a marketplace holds a seller's payout might depend on the seller's country and their risk level. Written as nested ifs, the rule hides its own gaps:
if country == "US":
if risk == "high":
hold_days = 7
else:
hold_days = 2
elif country == "EU":
if risk == "high":
hold_days = 10
else:
hold_days = 3
else:
hold_days = 14Look at the inner else branches. They treat every risk level other than "high" as low, including a new "medium" level and the typo "hgih". Nothing fails, and payouts for both go out after the short hold.
The rule as a table
A decision table gives each combination of inputs its own row next to its outcome. A risk analyst can review it without reading code, and a missing combination shows up as a missing row:
| country | risk | hold days |
|---|---|---|
| US | low | 2 |
| US | high | 7 |
| EU | low | 3 |
| EU | high | 10 |
| anything else | anything else | 14 (default) |
To key a dict on 2 values, put them in a tuple: values in parentheses separated by commas, such as ("EU", "high"). A tuple can't be changed after it is created, which is why Python accepts it as a dict key. 2 tuples are equal when their values match position by position.
HOLD_DAYS = {
("US", "low"): 2,
("US", "high"): 7,
("EU", "low"): 3,
("EU", "high"): 10,
}
DEFAULT_HOLD = 14
country = "EU"
risk = "high"
print(HOLD_DAYS.get((country, risk), DEFAULT_HOLD))
print(HOLD_DAYS.get(("EU", "medium"), DEFAULT_HOLD))
print(HOLD_DAYS.get(("BR", "low"), DEFAULT_HOLD))Output:
10 14 14
The lookup stays a single .get call however many rows the table grows to. When the risk team adds a "medium" level, they add 2 rows and the lookup line doesn't change. Until then, "medium" gets the explicit default instead of falling through to the low-risk hold.
Wildcards and precedence
Rules with many inputs multiply rows, so tables often allow a wildcard: ("BR", "*") means any risk level in Brazil. A key lookup has no notion of which row is more specific, so precedence lives in the code. Try the exact key, then the wildcard, then the default.
key = (country, risk)
if key in HOLD_DAYS:
hold = HOLD_DAYS[key]
elif (country, "*") in HOLD_DAYS:
hold = HOLD_DAYS[(country, "*")]
else:
hold = DEFAULT_HOLDThat chain has 3 branches whatever the table's size, and it is the only place where order matters. The rows themselves can live in configuration like the file below. The risk team edits rows, a loader turns each into a tuple key, and the lookup code ships once.
default_hold_days: 14
hold_days:
- {country: US, risk: low, days: 2}
- {country: US, risk: high, days: 7}
- {country: EU, risk: low, days: 3}
- {country: EU, risk: high, days: 10}
- {country: BR, risk: "*", days: 5}- Do
- Give each combination of inputs its own row, even when outcomes repeat.
- Try the exact key first, wildcards after, the default last.
- Bucket ranges such as amounts into labels before using them in a key.
- Avoid
- Don't let an inner else stand in for values nobody listed.
- Don't rely on row order for precedence; a key lookup ignores it.
Capstone: transitions as a decision table
The pipeline run's if-elif chain is a decision table too: the current state and the event go in, and the next state comes out. Keyed by (state, event), it shrinks handle to a lookup and a None check. That rewrite combines dispatch on tuple keys with the guard reasoning from earlier sections.
TRANSITIONS = {
("queued", "start"): "running",
("running", "succeed"): "succeeded",
("running", "fail"): "failed",
("failed", "retry"): "queued",
}
class PipelineRun:
def __init__(self):
self.state = "queued"
def handle(self, event):
nxt = TRANSITIONS.get((self.state, event))
if nxt is None:
print("rejected:", event, "in", self.state)
else:
print(self.state, "->", nxt)
self.state = nxt
run = PipelineRun()
run.handle("start")
run.handle("fail")
run.handle("succeed")
run.handle("retry")
print(run.state)Output:
queued -> running running -> failed rejected: succeed in failed failed -> queued queued
Adding a cancel event now means adding rows such as ("queued", "cancel"): "cancelled", and handle stays untouched. Keep the nxt is None check. It looks removable, in the same way the None guard in front of a comparison did, and it is the only thing stopping a rejected event from setting the state to None.
This lookup tries the wildcard row before the exact key. What does it print?
RULES = {("BR", "*"): 5, ("BR", "high"): 12}
key = ("BR", "high")
if ("BR", "*") in RULES:
hold = RULES[("BR", "*")]
elif key in RULES:
hold = RULES[key]
else:
hold = 14
print(hold)- 12, because the exact row for BR and high exists
- 5, because the wildcard branch runs first and matches
- 14, because neither branch matches the key exactly
- A KeyError, because "*" is not a risk level
In a rule table a missing row is routine: a new country starts sending payouts before anyone writes its rows. HOLD_DAYS[key] treats that as an error and stops the run with a KeyError, while .get(key, DEFAULT_HOLD) returns the hold the table promises for unlisted pairs. Square brackets belong only after an in test has proved the key is present, as in the precedence chain above.
About this lesson
This section is part of the Control Flow: Advanced lesson on DataDriven, the home of data engineering. Lessons cover SQL, Python, Spark, data modeling and pipeline architecture, and each section pairs its explanation with worked examples and practice you run as you go.