Skip to main content

Convenient manipulation of nested dict structures with dot notation, path API, and atomic updates

Project description

ZeroDict

CI PyPI version Python versions License: MIT Status Typed Dependencies Code style: ruff

Safe, explicit, and powerful manipulation of nested dict structures in Python.

ZeroDict is a zero-dependency dict wrapper for Python 3.11+ that makes working with nested data structures safe, explicit, and predictable. It eliminates the fragility of standard dicts without introducing the hidden side effects common in similar libraries.


The Problem

Nested dictionaries are everywhere in Python: configuration files, API responses, data pipelines, document stores. Yet Python's built-in dict makes working with nested structures surprisingly painful.

Reading is fragile. Accessing a missing key crashes your program:

config = {"database": {"host": "localhost"}}

config["cache"]["ttl"]  # KeyError: 'cache'

The standard workaround is .get() chains, which are verbose and hard to read:

config.get("cache", {}).get("ttl")                          # one level
config.get("api", {}).get("endpoints", {}).get("users")     # two levels
config.get("a", {}).get("b", {}).get("c", {}).get("d")      # this gets old fast

Every nested access requires defensive code. Miss one .get() and you're back to KeyError.

Writing is error-prone. Creating nested structures requires manual initialization of every intermediate level:

config["cache"] = {}
config["cache"]["redis"] = {}
config["cache"]["redis"]["host"] = "localhost"
config["cache"]["redis"]["port"] = 6379

Four lines to set two values. And if any intermediate key already exists with a non-dict value, you silently overwrite it.

Existing alternatives trade one problem for another. Libraries like addict, munch, and easydict solve the reading problem but introduce a worse one: they auto-create nested dicts on any attribute access, including writes. A simple typo silently creates garbage data:

from addict import Dict
d = Dict()
d.databse.host = "localhost"  # Typo! 'databse' now exists as a real key
# No error, no warning. The bug hides until production.

This is particularly dangerous in configuration management and data pipelines, where a silent typo can propagate through your entire system before anyone notices.


The Solution

ZeroDict solves both problems with a clear design principle: safe reads, explicit writes.

  • Reading a missing path never crashes and never modifies your data. You get a falsy sentinel back, and your dict stays untouched.
  • Writing a deep path requires an explicit call (set_path), so typos are caught immediately instead of creating phantom keys.
from zerodict import ZeroDict

config = ZeroDict({"database": {"host": "localhost"}})

# Reading: safe, no crashes, no side effects
print(config.database.host)       # "localhost"
print(config.cache.ttl)           # None-like sentinel (no KeyError!)
print(config.a.b.c.d.e)           # Still safe, still no crash

# Writing: explicit intent required for deep paths
config.set_path("cache.redis.host", "localhost")   # Creates entire path in one call
config.set_path("cache.redis.port", 6379)

# Typos are caught, not hidden
config.databse.host = "localhost"  # AttributeError: 'databse' doesn't exist

This is not just syntactic sugar. It is a fundamentally different approach: reads are permissive (your code doesn't crash on optional fields), writes are strict (your code doesn't silently create wrong data).


What ZeroDict Gives You

Path API with dot notation and array support. Navigate and manipulate nested structures with string paths instead of chained bracket access:

config.set_path("api.endpoints.users", "/v1/users")
config.set_path("servers[0].host", "prod-1")
value = config.get_path("api.endpoints.users")
config.delete_path("servers[0].host")

Atomic batch updates. Update multiple paths at once with all-or-nothing semantics. If any path fails validation, all changes are rolled back automatically:

config.set_many({
    "db.host": "localhost",
    "db.port": 5432,
    "cache.ttl": 3600,
})
# All succeed, or none do. No partial state.

Change tracking. Compare two structures and get a precise list of what changed:

changes = original.diff(modified)
# [{"op": "replace", "path": "price", "before": 100, "after": 120},
#  {"op": "add", "path": "discount", "after": 10}]

Security by default. Built-in protections prevent abuse when handling untrusted data: nesting depth limits, array index bounds, key format validation, value size caps, and circular reference detection. These are not optional add-ons; they are always active.

Zero dependencies. ZeroDict uses only the Python standard library. No transitive dependency tree, no version conflicts, no supply chain risk. It installs in milliseconds and adds nothing to your dependency audit.

Full dict compatibility. ZeroDict supports the standard dict interface (len, in, keys, values, items, get, pop, update, iteration). You can use it anywhere a dict-like object is expected, and convert back to a plain dict at any time with to_dict().


Who Should Use ZeroDict

ZeroDict is designed for any scenario where you work with nested dictionaries and want predictable, safe behavior:

  • Configuration management: load config files (JSON, YAML parsed to dict) and access nested settings without defensive .get() chains. Batch-update multiple settings atomically.
  • API response processing: access deeply nested fields in API responses without worrying about optional or missing keys. No more response.get("data", {}).get("user", {}).get("email").
  • Data transformation pipelines: reshape nested structures with path-based set/get/delete/move operations. Track changes with diff().
  • Prototyping and exploration: quickly build and manipulate nested data in notebooks or scripts without boilerplate.
  • Any application handling untrusted nested data: the built-in security limits protect against pathological inputs without additional code.

Comparison with Other Libraries

Feature ZeroDict addict python-box munch easydict
Safe reading (no KeyError) yes yes yes yes yes
Explicit deep writes yes no no no no
Path API (a.b.c, arr[0]) yes no no no no
Atomic batch updates yes no no no no
Move/rename fields yes no no no no
Deep diff tracking yes no no no no
Security limits yes no no no no
Type hints Full Partial Partial No No
Circular ref protection yes no yes no no

vs addict/munch/easydict: Auto-create nested dicts on write, which means typos silently create garbage keys. ZeroDict requires explicit set_path() for deep writes.

vs python-box: Feature-rich but complex. ZeroDict is focused: safe access + path manipulation + atomic operations, with clear semantics and no magic.

vs plain dicts: ZeroDict adds safety, path API, and atomic operations while maintaining full dict compatibility.


Installation

Requirements: Python 3.11+ | No external dependencies (standard library only)

pip install zerodict

Or with uv:

uv add zerodict

Quick Start

from zerodict import ZeroDict

# Create from dict
config = ZeroDict({
    "database": {
        "host": "localhost",
        "port": 5432
    }
})

# Safe reading (no KeyError)
print(config.database.host)  # "localhost"
print(config.cache)          # None-like (no crash!)

# Simple first-level writes
config.new_field = "value"

# Explicit deep writes via Path API
config.set_path("api.endpoints.users", "/api/v1/users")
config.set_path("api.timeout", 30)

# Array support
config.set_path("servers[0].name", "prod-1")
config.set_path("servers[0].ip", "192.168.1.100")

# Atomic batch updates (all-or-nothing)
config.set_many({
    "cache.enabled": True,
    "cache.ttl": 3600,
    "logging.level": "INFO"
})

# Export to dict or JSON
print(config.to_json())
data = config.to_dict()

Core Features

Safe Reading with Dot Notation

data = ZeroDict({"user": {"name": "John"}})

# Existing paths work as expected
print(data.user.name)      # "John"

# Missing paths return None-like sentinel (no KeyError)
print(data.user.email)     # None
print(data.missing.deep)   # None

# Check existence
if "email" in data.user:
    print("Has email")

Path API for Deep Creation

config = ZeroDict({})

# Create nested structures in one call
config.set_path("database.credentials.username", "admin")
config.set_path("database.pool.min_size", 5)

# Read with paths
value = config.get_path("database.credentials.username")

# With defaults
ttl = config.get_path("cache.ttl", default=3600)

# Delete with paths
config.delete_path("database.pool.min_size")

Array Manipulation

data = ZeroDict({})

# Create and access array elements
data.set_path("items[0].id", 1)
data.set_path("items[0].name", "Item 1")
data.set_path("items[1].id", 2)

# Arrays auto-extend with None padding
data.set_path("arr[5]", "value")  # Creates arr with 6 elements

# Read array elements
first_name = data.get_path("items[0].name")

Atomic Batch Updates

All updates succeed or all fail (automatic rollback on error):

config = ZeroDict({"balance": 1000})

try:
    config.set_many({
        "balance": 900,
        "transactions[0].amount": -100,
        "invalid..path": "this will fail"  # Invalid path causes rollback
    })
except ValueError:
    # ALL changes rolled back
    print(config.balance)  # 1000 (unchanged)

Move/Rename Fields

Atomically relocate or rename fields:

config = ZeroDict({
    "temp_data": {"user_id": 123, "session": "abc"},
    "permanent": {}
})

# Move entire subtree
config.move("temp_data", "permanent.user_session")
# Result: {"permanent": {"user_session": {"user_id": 123, "session": "abc"}}}

Change Tracking

Track what changed between dict states:

original = ZeroDict({"price": 100, "stock": 50})
modified = ZeroDict({"price": 120, "stock": 45, "discount": 10})

changes = original.diff(modified)
for change in changes:
    print(f"{change['op']}: {change['path']}")
    # replace: price
    # replace: stock
    # add: discount

Strict Mode

Control error handling per-operation:

data = ZeroDict({"user": {"name": "John"}})

# Default: returns None for missing paths
value = data.get_path("user.email")  # None

# Strict mode: raises exceptions
try:
    value = data.get_path("user.email", strict=True)
except KeyError:
    print("Path not found!")

Serialization

ed = ZeroDict({"name": "Alice", "age": 30})

# JSON round-trip
json_str = ed.to_json()
loaded = ZeroDict.from_json(json_str)

# Plain dict round-trip
plain = ed.to_dict()
back = ZeroDict.from_dict(plain)

Advanced Usage

Distinguishing None from Missing Keys

ed = ZeroDict({"a": None})

# Both appear None-like
ed.a        # None (key exists with None value)
ed.missing  # MissingPath (key doesn't exist)

# Use 'in' to distinguish
"a" in ed        # True
"missing" in ed  # False

Deep vs Shallow Copy

original = ZeroDict({"nested": {"value": 1}})

# Deep copy (default): independent
copied = original.copy()
copied.nested.value = 999
assert original.nested.value == 1  # Unchanged

# Shallow copy: shared references
shallow = original.copy(deep=False)
shallow.nested.value = 888
assert original.nested.value == 888  # Shared!

Thread Safety

ZeroDict is NOT thread-safe. For concurrent access:

import threading

zd = ZeroDict({})
lock = threading.RLock()

with lock:
    zd.set_path("counter", zd.get_path("counter", default=0) + 1)

Async code (asyncio) does not require locks unless using actual threads.


Development Setup

git clone https://github.com/francescofavi/zerodict.git
cd zerodict
uv sync

Running Tests

uv run pytest

Running Examples

uv run python examples/demo.py

See Development for full setup instructions, pre-commit hooks, and commit conventions.


Further Documentation

  • API Reference - Complete reference for all public APIs, parameters, and advanced usage patterns
  • Architecture - Internal module structure, responsibilities, boundaries, and data flow
  • Anti-Patterns - Common mistakes and how to avoid them
  • Development - Setup for contributors, running tests, and running examples

Contributing

This repository is maintained as a personal portfolio project. Pull requests are generally not accepted, but exceptional contributions may be considered.

For bug reports and feature requests, please use GitHub Issues.

License

MIT License - Copyright (c) 2025 Francesco Favi

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

zerodict-0.1.1.tar.gz (40.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

zerodict-0.1.1-py3-none-any.whl (20.4 kB view details)

Uploaded Python 3

File details

Details for the file zerodict-0.1.1.tar.gz.

File metadata

  • Download URL: zerodict-0.1.1.tar.gz
  • Upload date:
  • Size: 40.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for zerodict-0.1.1.tar.gz
Algorithm Hash digest
SHA256 e39d4444be100cef6325ea921ed8e241c2283424450dac0b9f30b99cdd3090bd
MD5 d8f42ee8b63a6007a2f98f2c3574c845
BLAKE2b-256 b678581fd8377739f9ecb13030ecaddb237f99bc94fe991e9f7fbc715dd2137b

See more details on using hashes here.

Provenance

The following attestation bundles were made for zerodict-0.1.1.tar.gz:

Publisher: publish.yml on francescofavi/zerodict

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file zerodict-0.1.1-py3-none-any.whl.

File metadata

  • Download URL: zerodict-0.1.1-py3-none-any.whl
  • Upload date:
  • Size: 20.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for zerodict-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 dca8b37169df41ece5c3cf70121684f127e6f5cfa93ba391a5357b378755465d
MD5 370147a08a16783ffe5174d5a7d7f9df
BLAKE2b-256 276c3b9ef32a973102123198fdfaf5d63f624e47c068c0a2280c6def0bd716f3

See more details on using hashes here.

Provenance

The following attestation bundles were made for zerodict-0.1.1-py3-none-any.whl:

Publisher: publish.yml on francescofavi/zerodict

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page