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 = 14

Look 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:

countryriskhold days
USlow2
UShigh7
EUlow3
EUhigh10
anything elseanything else14 (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_HOLD

That 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)

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.