Nested Dictionaries

Python’s built-in dict can hold any value — including another dict. This natural nesting has no dedicated type in the standard library, which makes common tasks (checking whether a key exists anywhere in the hierarchy, counting its occurrences, or converting the whole structure to JSON) unnecessarily verbose.

ndict-tools fills this gap by providing three ready-to-use classes built on top of collections.defaultdict.

What is a nested dictionary?

A nested dictionary is a dictionary whose values may themselves be dictionaries, to any depth:

data = {
    "project": {
        "name": "ndict-tools",
        "version": "1.1.0",
        "authors": {
            "lead": "biface",
        },
    },
    "license": "MIT",
}

Accessing data["project"]["authors"]["lead"] requires knowing the exact path in advance. With ndict-tools, you can also write:

from ndict_tools import NestedDictionary

nd = NestedDictionary(data)
nd[["project", "authors", "lead"]]   # 'biface'
nd[["license"]]                       # 'MIT'

The list notation is a hierarchical key: each element is a level in the nesting.

The three public classes

All three classes accept the same construction patterns — a plain dict, a zip, or a list of (key, value) pairs:

from ndict_tools import NestedDictionary

# From a plain dict
nd = NestedDictionary({"a": {"b": 1}, "c": 2})

# From a zip
nd = NestedDictionary(zip(["a", "b"], [{"x": 1}, 2]))

# From a list of pairs
nd = NestedDictionary([("a", {"b": 1}), ("c", 2)])

The difference between the three classes lies in how they respond to an unknown key.

NestedDictionary — lenient by default

Accessing a missing key silently creates a new, empty NestedDictionary at that location. This mirrors the behaviour of collections.defaultdict.

from ndict_tools import NestedDictionary

nd = NestedDictionary({"a": 1})
nd["missing"]   # returns an empty NestedDictionary, does not raise
nd["missing"]["deeper"] = 42  # works — intermediate levels are created

StrictNestedDictionary — raises on missing keys

Accessing a missing key raises a KeyError, just like a plain dict. Use this class when unknown keys should never go unnoticed.

from ndict_tools import StrictNestedDictionary

nd = StrictNestedDictionary({"a": 1})
nd["missing"]   # raises KeyError

SmoothNestedDictionary — always returns a nested dict

Accessing a missing key returns a new, empty SmoothNestedDictionary. Unlike NestedDictionary, every returned object is guaranteed to be a SmoothNestedDictionary, making deep chaining safe regardless of depth.

from ndict_tools import SmoothNestedDictionary

nd = SmoothNestedDictionary({"a": 1})
result = nd["missing"]["even"]["deeper"]  # returns empty SmoothNestedDictionary

Hierarchical key access

Any NestedDictionary (and its subclasses) accepts a Python list as a key. Reading and writing both work:

from ndict_tools import NestedDictionary

nd = NestedDictionary({"a": {"b": {"c": 42}}})

# Reading
nd[["a", "b", "c"]]       # 42
nd[["a", "b"]]            # NestedDictionary({'c': 42})

# Writing — intermediate levels are created automatically
nd[["x", "y", "z"]] = 99
nd["x"]["y"]["z"]          # 99

# Deleting — empty parents are cleaned up automatically
del nd[["x", "y", "z"]]
"x" in nd                  # False

Constructing from an existing dict

The from_dict() class method provides an alternative constructor that recursively converts a plain dictionary.

Because a plain dict carries no information about how missing keys should behave or how the structure should be printed, you must supply that configuration explicitly via default_setup:

  • indent — number of spaces used when printing the dictionary (0 disables indentation).

  • default_factory — the class instantiated for missing keys (set to NestedDictionary for lenient behaviour, None for strict behaviour).

from ndict_tools import NestedDictionary

plain = {"region": {"country": {"city": "Paris"}}}
nd = NestedDictionary.from_dict(
    plain,
    default_setup={"indent": 2, "default_factory": NestedDictionary},
)
nd[["region", "country", "city"]]   # 'Paris'

Searching across all levels

Because all keys are tracked in the hierarchy, you can search without knowing the depth:

from ndict_tools import NestedDictionary

nd = NestedDictionary({
    "europe": {"france": {"capital": "Paris"}},
    "asia":   {"japan":  {"capital": "Tokyo"}},
})

nd.is_key("capital")      # True — exists somewhere
nd.occurrences("capital") # 2   — appears twice
nd.key_list("capital")    # [('europe', 'france', 'capital'),
                          #  ('asia', 'japan', 'capital')]
nd.items_list("capital")  # ['Paris', 'Tokyo']

Serialisation

A NestedDictionary can be saved to and restored from JSON or pickle without any extra setup:

from ndict_tools import NestedDictionary

nd = NestedDictionary({"a": {"b": 1}})

# JSON round-trip
nd.to_json("/tmp/nd.json")
nd2 = NestedDictionary.from_json(
    "/tmp/nd.json",
    default_setup={"indent": 0, "default_factory": NestedDictionary},
)

# Pickle round-trip (SHA-256 sidecar written automatically)
nd.to_pickle("/tmp/nd.pkl")
nd3 = NestedDictionary.from_pickle("/tmp/nd.pkl")