ChainMap

ChainMap

A Practical Guide

Official Definition: dict-like class for creating a single view of multiple mappings.

Added to Python 3.3's collections module, ChainMap isn't just another dict-like class - it's an elegant solution that transforms multiple dictionaries into a single, cohesive view.

We can think of ChainMap as a stack of dictionaries where you can look up values starting from the top dictionary and working your way down until you find that you are looking for. It is particularly useful when there are multiple sources of configuration or setting and they need to follow a priority order.

Important: ChainMap looks up keys in order, so put your highest-priority dictionary first in the chain. Any changes automatically apply to the top-level dictionary, though you can modify other layers if needed - we'll explore this in detail when we dive into maps.

Example: Basic usage

# Configuration dictionaries
user_settings = {
'theme':'dark',
'language':'es',
'font_size':14
}

environment_settings = {
'theme':'light',
'language':'en',
'font_size':30,
'timeout':30,
}

default_settings = {
'theme':'light',
'language':'en',
'font_size':12,
'timeout':60,
'debug':False
}

from collections import ChainMap

# Create a ChainMap with priority: user > environment > default settings
# Note : Creating an empty chain map without arguments will store an empty dictionary
config = ChainMap(user_settings, environment_settings, default_settings)

# Dictionary Lookup
config['theme'] # Output : dark
config['timeout'] # Output: 30
config['debug'] # OUtput : False

We created three dictionary configurations each showing user settings, environment settings and default settings. The idea is that, user is required to configure system settings in the form of dictionary.If the configuration is missing in the user settings, the lookup will fallback to environment settings and then to the default settings at last.

As we have configured the ChainMap in exactly this order. The usual lookup can be performed. If the key doesn’t exist, then you get the KeyError. For duplicate keys, you get the first occurrence of the target key.

Scenario #1: Search for theme settings
The theme key is present in the user settings, so the dictionary returns dark as the value. Lookup completed.

Scenario #2: Search for timeout settings
The lookup first checks the user settings dictionary, but the key is not found there. It then falls back to the environment settings dictionary, where the key is found, and the value 30 is returned.

Scenario #3: Search for debug settings
The lookup first checks the user settings dictionary, where the key is not found. It then moves to the environment settings dictionary, where the key is also not found. Finally, the lookup falls back to the default settings dictionary. Here, the key is found, and the value False is returned.

The diagram below explains the same scenarios:

Another example: Multi-level Cache

class MultiLevelCache:
    def __init__(self):
        self.memory_cache = {}  # Fastest, but limited size
        self.disk_cache = {}    # Slower, but larger
        self.remote_cache = {}  # Slowest, but distributed
        self.cache_chain = ChainMap(
            self.memory_cache,
            self.disk_cache,
            self.remote_cache
        )

    def get(self, key):
        return self.cache_chain.get(key)

    def set(self, key, value):
        self.memory_cache[key] = value # Store in fastest cache

# Usage
cache = MultiLevelCache()
cache.set('user_id', 123)
value = cache.get('user_id')

We created three cache layers, each representing memory cache, disk cache, and remote cache. The idea is that the system stores and retrieves data using a multi-level cache structure. If a value is not found in the memory cache, the lookup falls back to the disk cache and then to the remote cache at last. The ChainMap is used to seamlessly connect these caches for efficient lookup.

Mutation Operations - Add, Update, Delete and Pop Key-Value Pairs.

By default, ChainMap only affects the first dictionary in the priority chain when you try to perform a mutation operation. If the key you are trying to change doesn’t exist in the first dictionary but does exist in other dictionaries in the chain, it will add a new key to the first dictionary and assign a value to it.

user_settings = {
'theme':'dark',
'language':'es',
'font_size':14
}

environment_settings = {
'theme':'light',
'language':'en',
'font_size':30,
'timeout':30,
}

config = ChainMap(user_settings,environment_settings)
print(config)
# Output: ChainMap({'theme': 'dark', 'language': 'es', 'font_size': 14}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

# Add a new key-value pair to the first dictionary in the chain
config['debug'] = True
print(config)
# Output : ChainMap({'theme': 'dark', 'language': 'es', 'font_size': 14, 'debug': True}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

# Updates always create/modify keys in the first dictionary, even if the key exists in lower-priority dictionaries
config['timeout'] = 20
print(config)
# Output : ChainMap({'theme': 'dark', 'language': 'es', 'font_size': 14, 'debug': True, 'timeout': 20}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

# Deletes key from first dictionary only - raises KeyError if key not found there
del config['theme']
print(config)
# Output: ChainMap({'language': 'es', 'font_size': 14, 'debug': True, 'timeout': 20}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

# Removes and returns the value of a key from the first dictionary
language_popped = config.pop('language')
print(language_popped)
# Output : es

# Removes all key-value pairs from the first dictionary, leaving it empty
config.clear()
print(config)
# Output: ChainMap({}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

Additional Features

.maps

it is a list - regular python list, that contains all the underlying dictionaries defined in the ChainMap in search order. It's particularly useful when you need to:

  1. Inspect the actual dictionaries in your chain

  2. Modify the chain structure

  3. Access the raw data sources

Since this list is a regular Python list, you can add or remove dictionaries, iterate over them, change the order of mappings, and more:

user_settings = {
'theme':'dark',
'language':'es',
'font_size':14
}

environment_settings = {
'theme':'light',
'language':'en',
'font_size':30,
'timeout':30,
}

config = ChainMap(user_settings,environment_settings)
chain = config.maps
print(chain)
# Output : [{'theme': 'dark', 'language1': 'es', 'font_size': 14}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30}]

# Access specific maps using their index
print(config.maps[0]) # First dictionary (user_settings)
print(config.maps[-1]) # Last dictionary (environment_settings)

# Modify the ChainMap structure
modify_chain = ChainMap(*config.maps[1:])
print(modify_chain) # ignore user_settings

# Append a new dictionary to the end of .maps
config.maps.append({'theme':'light','language':'np-NP',})
print(config)
# Output : ChainMap({'theme': 'dark', 'language': 'es', 'font_size': 14}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30}, {'theme': 'light', 'language': 'np-NP'})

# Remove the dictionary at index 2 from .maps
del config.maps[2]

# Loop through all mappings in the ChainMap
for config_dict in config.maps:
  print(config_dict)
  # Output : {'theme': 'dark', 'language': 'es', 'font_size': 14}
  # Output : {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30}

The main benefit of .maps is that it keeps live references to the original dictionaries. This means:

user_settings = {
'theme':'dark',
'language':'es',
'font_size':14
}

environment_settings = {
'theme':'light',
'language':'en',
'font_size':30,
'timeout':30,
}

config = ChainMap(user_settings,environment_settings)

# Updates to original dictionaries are automatically reflected in the ChainMap
user_settings['theme'] = 'blue'
print(config['theme'])  # Outputs: 'blue'

# And vice versa - Changes made through .maps also update the original dictionaries
settings.maps[0]['theme'] = 'red'
print(user_settings['theme'])  # Outputs: 'red'

# Reverse the order of dictionaries
# Reversing the list mappings allows you to reverse the search order
config.maps.reverse()

.new_child
It adds a new dictionary as the first map in the original ChainMap, giving it the highest priority in the chain, and then returns a new instance of ChainMap with this updated value.

user_settings = {
'theme':'dark',
'language':'es',
'font_size':14
}

environment_settings = {
'theme':'light',
'language':'en',
'font_size':30,
'timeout':30,
}

# Original ChainMap
config = ChainMap(user_settings,environment_settings)

# Create a new ChainMap instance with an empty dictionary at the front of the chain
temp_config = config.new_child()
print(temp_config)
# Output : ChainMap({}, {'theme': 'dark', 'language': 'es', 'font_size': 14}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

# Create a new ChainMap instance based on the original, with a new child dictionary containing initial values
new_settings = {'theme':'light','language':'np-NP'}
temp_config = config.new_child(new_settings)
print(temp_config)
# Output : ChainMap({'theme': 'light', 'language': 'np-NP'},{'theme': 'dark', 'language': 'es', 'font_size': 14}, {'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

.parents
It creates a new ChainMap containing all maps except the first one, which had the highest priority before being removed. This feature is useful for ignoring the first map when searching for keys in a ChainMap.

In a way, .parents does the opposite of .new_child(). In both cases, you get a new ChainMap:

  • .parents: removes a dictionary from the start of the list

  • .new_child(): adds a new dictionary to the start of the list.

user_settings = {
'theme':'dark',
'language':'es',
'font_size':14
}

environment_settings = {
'theme':'light',
'language':'en',
'font_size':30,
'timeout':30,
}

config = ChainMap(user_settings,environment_settings)
parent_config = config.parents
print(parent_config)
# Output: ChainMap({'theme': 'light', 'language': 'en', 'font_size': 30, 'timeout': 30})

Conclusion:

ChainMap allows you to access multiple dictionaries as one, which is particularly useful for handling layered configurations like user settings, environment settings, and default settings. Its three key attributes make it a powerful tool for managing complex configuration scenarios without copying data:
.maps - for accessing the underlying dictionaries,
.new_child() - for modifying the chain structure, and
.parents - for accessing fallback values.

References: