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.12+ 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.12+ | 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.
Known limits and open issues
Where the project is deliberately limited, where it enforces a hard constraint, and what is not yet shipped — one list, grouped by axis (design: intentional trade-off, limit: hard constraint visible in code, open: tracked roadmap item).
- design: Explicit
set_path()required for deep writes — no auto-creation on attribute access (the opposite of addict/munch; typos surface as errors rather than phantom keys). - design: Not thread-safe — concurrent writers must coordinate with an external lock (RLock).
- design:
copy()is deep by default — shallow copy (copy(deep=False)) shares nested references on purpose. - limit: Security limits are always on (nesting depth, array index bounds, key format, value size, circular refs) — not opt-out.
- limit: Path expression grammar covers
a.b.c+arr[0]only — no wildcards, no slicing, no predicates. - limit:
to_json()requires every value to be JSON-serializable — non-JSON values must be converted first. - limit:
set_many()is all-or-nothing — any invalid path rolls back every other update in the batch. - open: No observable roadmap items tracked in code as of the current release.
Anti-patterns — how NOT to use this project
Usage patterns that reliably cause trouble:
- Do not expect addict/munch-style attribute auto-creation — deep writes must go through
set_path(). - Do not share a ZeroDict instance across threads without an external lock.
- Do not call
copy(deep=False)when you plan to mutate nested structures — the shallow copy shares references with the original. - Do not store non-JSON-serializable values (
set, custom classes, rawdatetime) in a ZeroDict you laterto_json(). - Do not use
is Noneto detect a missing path —MissingPath() is Noneis False; use== None,not value, or"key" in zdinstead. - Do not bundle independent updates in
set_many()— a single invalid path rolls back the entire batch. - Do not assume array auto-extension preserves value types — gaps are padded with
None.
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/01_quickstart.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.2.tar.gz.
File metadata
- Download URL: zerodict-0.1.2.tar.gz
- Upload date:
- Size: 402.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b9f110098762cc8190f00618459be7679c7631eadd194fc0585a51b8563b2a5f
|
|
| MD5 |
7122a5f1dab2acb8202d79218496e827
|
|
| BLAKE2b-256 |
278d9fff556dd8ae472deff2cd6e1df3014349cca9719cb0487e3b11c8b8e08c
|
Provenance
The following attestation bundles were made for zerodict-0.1.2.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.2.tar.gz -
Subject digest:
b9f110098762cc8190f00618459be7679c7631eadd194fc0585a51b8563b2a5f - Sigstore transparency entry: 1395334416
- Sigstore integration time:
-
Permalink:
francescofavi/zerodict@2a8e155b0df776919a5a92a7a7f009937ed85070 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/francescofavi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2a8e155b0df776919a5a92a7a7f009937ed85070 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file zerodict-0.1.2-py3-none-any.whl.
File metadata
- Download URL: zerodict-0.1.2-py3-none-any.whl
- Upload date:
- Size: 21.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cf6f899d0a53b4348224479682ba733854e16bf82e6a729a78f9c257a1b69ecf
|
|
| MD5 |
d7d0f8e7f6a7fd1fc7d9ce97f7ce85e9
|
|
| BLAKE2b-256 |
77856a6b1a166575126c922db98f404e76677909e14741193b41d33b547eaf50
|
Provenance
The following attestation bundles were made for zerodict-0.1.2-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.2-py3-none-any.whl -
Subject digest:
cf6f899d0a53b4348224479682ba733854e16bf82e6a729a78f9c257a1b69ecf - Sigstore transparency entry: 1395334451
- Sigstore integration time:
-
Permalink:
francescofavi/zerodict@2a8e155b0df776919a5a92a7a7f009937ed85070 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/francescofavi
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@2a8e155b0df776919a5a92a7a7f009937ed85070 -
Trigger Event:
workflow_dispatch
-
Statement type: