Convenient manipulation of nested dict structures with dot notation, path API, and atomic updates
Project description
ZeroDict
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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e39d4444be100cef6325ea921ed8e241c2283424450dac0b9f30b99cdd3090bd
|
|
| MD5 |
d8f42ee8b63a6007a2f98f2c3574c845
|
|
| BLAKE2b-256 |
b678581fd8377739f9ecb13030ecaddb237f99bc94fe991e9f7fbc715dd2137b
|
Provenance
The following attestation bundles were made for zerodict-0.1.1.tar.gz:
Publisher:
publish.yml on francescofavi/zerodict
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zerodict-0.1.1.tar.gz -
Subject digest:
e39d4444be100cef6325ea921ed8e241c2283424450dac0b9f30b99cdd3090bd - Sigstore transparency entry: 1195685209
- Sigstore integration time:
-
Permalink:
francescofavi/zerodict@1490cbba6e52b5282d683a95c8969a9fd85048d1 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/francescofavi
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1490cbba6e52b5282d683a95c8969a9fd85048d1 -
Trigger Event:
workflow_dispatch
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dca8b37169df41ece5c3cf70121684f127e6f5cfa93ba391a5357b378755465d
|
|
| MD5 |
370147a08a16783ffe5174d5a7d7f9df
|
|
| BLAKE2b-256 |
276c3b9ef32a973102123198fdfaf5d63f624e47c068c0a2280c6def0bd716f3
|
Provenance
The following attestation bundles were made for zerodict-0.1.1-py3-none-any.whl:
Publisher:
publish.yml on francescofavi/zerodict
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
zerodict-0.1.1-py3-none-any.whl -
Subject digest:
dca8b37169df41ece5c3cf70121684f127e6f5cfa93ba391a5357b378755465d - Sigstore transparency entry: 1195685215
- Sigstore integration time:
-
Permalink:
francescofavi/zerodict@1490cbba6e52b5282d683a95c8969a9fd85048d1 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/francescofavi
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@1490cbba6e52b5282d683a95c8969a9fd85048d1 -
Trigger Event:
workflow_dispatch
-
Statement type: