Your app reads its settings from three places. Defaults sit in the code, a few
come from environment variables, and the command line can override anything.
You could merge them into one dict. A collections.ChainMap keeps the layers
separate instead, and searches them in order each time you look up a key.
from collections import ChainMap
defaults: dict[str, object] = {"port": 8000, "host": "localhost"}
options: dict[str, object] = {"port": 9000}
config = ChainMap(options, defaults)
assert config.get("port") == 9000
assert config["host"] == "localhost"
options["port"] = 9100
assert config.get("port") == 9100
Put the highest-priority layer first
A lookup checks config.maps, the list of layers, from first to last, and
returns the first match. That is the reverse of a merge such as
{**defaults, **options}, where the last value wins.
A ChainMap holds references to your dicts and copies nothing. So a change to
a layer shows up in the chain at once, as the last line above shows.
Because the layers stay separate, you can also find out which layer a value
came from. Loop over config.maps and stop at the first layer that contains
the key. config[key] alone gives you the value but not the layer. If you log
where each setting came from, keep a label with each layer, and leave secret
values out of the log.
Writes and deletes change only the first layer
from collections import ChainMap
defaults: dict[str, object] = {"port": 8000}
options: dict[str, object] = {"port": 9000}
config = ChainMap(options, defaults)
config["port"] = 9200
assert options["port"] == 9200
assert defaults["port"] == 8000
del config["port"]
assert config["port"] == 8000
Setting or deleting a key through the chain changes the first layer only.
Deleting an override uncovers the value underneath. Deleting a key that exists
only in a later layer raises KeyError, because del looks only in the first
layer.
The later layers are not protected. You can still change them through your
own references to them, or through config.maps.
Check your understanding How can an application hide a value that a lower layer sets? Show answer Hide answer
Decide on a rule and apply it in your own code. For example, an override can
hold a special marker value that your code reads as "disabled". ChainMap has
no such rule built in. Its del removes the key from the first layer only,
not from every layer.
Add a temporary layer
from collections import ChainMap
defaults: dict[str, object] = {"port": 8000}
config: ChainMap[str, object] = ChainMap({}, defaults)
inner = config.new_child()
inner["port"] = 7000
assert inner["port"] == 7000
assert config["port"] == 8000
assert inner.parents["port"] == 8000
The new_child() method returns a new chain with a fresh empty dict in front
of the existing layers. The parents property returns a new chain without the
first layer. Neither one changes the original chain, and all of them share the
layers underneath. That makes new_child handy for a short-lived override,
such as the settings for one request.
Choose a live view or a snapshot
Use ChainMap when changes to the layers should show up at once, or when you
need to inspect the layers one by one. Use a merged dict when you want a
snapshot that later changes do not affect. A lookup in a chain may check
several dicts. A lookup in a merged dict checks one.
A merge copies the entries, not the values in them. If a value is a list, the source and the merged dict share that one list.
Check your understanding Does ChainMap check or convert configuration values? Show answer Hide answer
No. A ChainMap[str, object] has string keys and values of any type, and your
code has to check each value before it uses it. Parse and validate the
settings yourself. Then pass the rest of your code a typed settings object, if
that makes it simpler.
Going further
When you loop over a ChainMap, it lists the keys by scanning the layers from
last to first. So a key's position in that loop does not tell you which layer
won. The
ChainMap documentation
has more examples, including one that mimics nested scopes.