Python nested unpacking can change a dictionary before raising ValueError

Python can write to a dictionary before a nested unpacking assignment raises ValueError: not enough values to unpack. In this settings reload, the input supplies an environment and a host but leaves out the port. The assignment changes the environment before it reaches the incomplete (host, port) pair.

settings = {"environment": "prod", "host": "db.prod", "port": 5432}
incoming = ("staging", ("db.staging",))

try:
    settings["environment"], (
        settings["host"],
        settings["port"],
    ) = incoming
except ValueError as error:
    print(f"Reload rejected: {error}")

print(settings)

On CPython 3.12.3, this prints

Reload rejected: not enough values to unpack (expected 2, got 1)
{'environment': 'staging', 'host': 'db.prod', 'port': 5432}

The app now says it’s in staging while its connection settings still point to production.

Python unpacks one level at a time

A target is a place an assignment writes to. Here the first target is settings["environment"] and the second is a group containing two more dictionary entries. Python evaluates the right-hand side first, then works through the targets from left to right. At each level of this assignment, the group needs one item per target. Python language reference on assignment statements

  1. The outer tuple has two items for the two outer targets, so Python can start assigning them.
  2. Python writes "staging" to settings["environment"].
  3. It then tries to unpack ("db.staging",) into the host and port targets. That tuple has one item, so Python raises ValueError before writing either entry in that group. The exception handler runs with the environment write already in place.

With incoming = ("staging",), the outer tuple is one item short. CPython prints the same error and leaves the dictionary intact. In the first example, the outer tuple has both items, so the first write happens before Python checks the inner tuple.

Unpack into local variables before writing to the dictionary

If a rejected reload should keep the previous settings in use, unpack into local variables first. Run the update only after both levels unpack successfully.

def reload_settings(settings, incoming):
    try:
        environment, (host, port) = incoming
    except (TypeError, ValueError) as error:
        print(f"Reload rejected: {error}")
        return False

    settings.update(environment=environment, host=host, port=port)
    return True


original = {"environment": "prod", "host": "db.prod", "port": 5432}
settings = original.copy()

assert reload_settings(settings, ("staging", ("db.staging",))) is False
assert settings == original

assert reload_settings(settings, ("staging", ("db.staging", 5433))) is True
assert settings == {"environment": "staging", "host": "db.staging", "port": 5433}

The first call can still assign environment before it hits the missing port. That assignment now belongs to the function’s local variables. The handler returns before the function reaches settings.update(). The handler also catches TypeError if a group receives a value Python can’t unpack, such as None in place of the host and port pair.

Direct unpacking works well with a known input shape. Local variables also leave room for checks beyond the item count, such as requiring an integer port in a valid range.

A test for rejected settings should compare the entire dictionary after the error. Checking only for ValueError misses the write to environment.

For the basic syntax, see Unlocking the Power of Lists and Tuples.