Dotted notation for safe nested data traversal with optional chaining, pattern matching, and transforms
Project description
Dotted
Sometimes you want to fetch data from a deeply nested data structure. Dotted notation helps you do that.
Installation
pip install dotted-notation
Since this package includes the dq command-line tool, several data formats are supported:
| Format | -i/-o |
Status |
|---|---|---|
| JSON | json |
included |
| JSONL | jsonl |
included |
| Python | py |
included |
| Python lines | pyl |
included |
| CSV | csv |
included |
| YAML | yaml |
optional |
| TOML | toml |
optional |
To install optional format support:
pip install dotted-notation[all]
Or pick only what you need:
pip install dotted-notation[yaml,toml]
Table of Contents
- Safe Traversal (Optional Chaining)
- Why Dotted?
- Breaking Changes
- API
- Paths
- Typing & Quoting
- Patterns
- Substitutions and References
- Type Restrictions
- Recursive Traversal
- Grouping
- Operators
- Filters
- The key-value filter
- The key-value first filter
- Conjunction vs disjunction
- Grouping with parentheses
- Filter negation and not-equals
- Comparison operators
- Boolean and None filter values
- Value guard
- Guard transforms
- Container filter values
- Type prefixes
- String glob patterns
- Bytes glob patterns
- Value groups
- Dotted filter keys
- Slice notation in filter keys
- Transforms
- Constants and Exceptions
- CLI (
dq) - FAQ
Safe Traversal (Optional Chaining)
Like JavaScript's optional chaining operator (?.), dotted safely handles missing paths.
If any part of the path doesn't exist, get returns None (or a specified default)
instead of raising an exception:
>>> import dotted
>>> d = {'a': {'b': 1}}
>>> dotted.get(d, 'a.b.c.d.e') is None # path doesn't exist
True
>>> dotted.get(d, 'a.b.c.d.e', 'default') # with default
'default'
>>> dotted.get(d, 'x.y.z', 42) # missing from the start
42
This makes dotted ideal for safely navigating deeply nested or uncertain data structures without defensive coding or try/except blocks.
Why Dotted?
Several Python libraries handle nested data access. Here's how dotted compares:
| Feature | dotted | glom | jmespath | pydash |
|---|---|---|---|---|
| Safe traversal (no exceptions) | ✅ | ✅ | ✅ | ✅ |
| Familiar dot notation | ✅ | ❌ (custom spec) | ❌ (JSON syntax) | ✅ |
| Pattern matching (wildcards) | ✅ | ✅ | ❌ | ❌ |
| Regex patterns | ✅ | ❌ | ❌ | ❌ |
| In-place mutation | ✅ | ✅ | ❌ (read-only) | ✅ |
Attribute access (@attr) |
✅ | ✅ | ❌ | ✅ |
| Transforms/coercion | ✅ | ✅ | ✅ | ❌ |
| Slicing | ✅ | ❌ | ✅ | ❌ |
| Filters | ✅ | ❌ | ✅ | ❌ |
Comparison operators (<, >, <=, >=) |
✅ | ❌ | ❌ | ❌ |
| AND/OR/NOT filters | ✅ | ❌ | ✅ | ❌ |
Grouping (a,b), (.a,.b) |
✅ | ❌ | ❌ | ❌ |
Recursive patterns (**, *key, *([*]), *(@*)) |
✅ | ✅ | ❌ | ❌ |
Depth slicing (**:-1, **:2) |
✅ | ❌ | ❌ | ❌ |
| NOP (~) match but don't update | ✅ | ❌ | ❌ | ❌ |
| Cut (#) and soft cut (##) in disjunction | ✅ | ❌ | ❌ | ❌ |
Container filter values ([1, ...], {k: v}) |
✅ | ❌ | ❌ | ❌ |
String/bytes glob patterns ("pre"..."suf") |
✅ | ❌ | ❌ | ❌ |
Value groups ((val1, val2)) |
✅ | ❌ | ❌ | ❌ |
Bytes literal support (b"...") |
✅ | ❌ | ❌ | ❌ |
| Zero dependencies | ❌ (pyparsing) | ❌ | ✅ | ❌ |
Choose dotted if you want:
- Intuitive
a.b[0].csyntax that looks like Python - Pattern matching with wildcards (
*) and regex (/pattern/) - Both read and write operations on nested structures
- Transforms to coerce types inline (
path|int,path|str:fmt) - Recursive patterns with
*key(dict keys),*([*])(slots),*(@*)(attrs), and*(expr)(mixed), with depth slicing - Path grouping
(a,b).c/prefix(.a,.b)for multi-access - Cut (
#) in disjunction—first matching branch wins; e.g.(a#, b)oremails[(*&email="x"#, +)]for "update if exists, else append" - Soft cut (
##) in disjunction—suppress later branches only for overlapping paths; e.g.(*(*#, [*]:!(str, bytes)):-2(.*, [])##, (*, []))for "recurse into containers, fall back for the rest" - NOP (
~) to match without updating—e.g.(name.~first#, name.first)for conditional updates - String/bytes glob patterns—match by prefix, suffix, or substring:
*="user_"...,*=b"header"...b"footer" - Value groups—disjunction over filter values:
*=(1, 2, 3),[*&status=("active", "pending")] - Strict mode—enforce type separation:
[0]only matches lists,.keyonly matches dicts
Breaking Changes
v0.40.0
unpack()now returns adict: Previously returned a tuple of(path, value)pairs. Now returns{path: value, ...}directly. Replacedict(dotted.unpack(d))withdotted.unpack(d).
v0.39.0
normalize()removed: Usequote()instead, which is now idempotent.matchmodule renamed tomatchers: Update imports fromfrom dotted.match import ...tofrom dotted.matchers import ....$-prefixed keys now canonicalize to single-quoted form:quote('$0')returns'$0'instead of\$0. The\$form is still accepted as input.
v0.38.0
<and>are now reserved characters: These are used for comparison operators in filters and value guards (<,>,<=,>=). If you have keys containing literal<or>, quote them:"my<key>".
v0.35.0
- Recursive traversal now walks into
strandbytes: Previously, recursive patterns like*(*#, [*])hardcoded a skip for strings and bytes. This is now controlled by type restrictions instead. If you use custom recursive accessor groups with[*], add:!(str, bytes)to prevent character decomposition:*(*#, [*]:!(str, bytes)). The default**(key-only recursion) is unaffected — it never entered strings because strings have no dict keys.
v0.31.0
{and}are now reserved characters: Curly braces are used for container filter values (dict and set patterns). If you have keys containing literal{or}, quote them:"my{key}".
v0.30.0
update_ifpred now gates on incoming value, not current value: Previously,update_if(obj, key, val)checkedpred(current_value_at_path). Now it checkspred(val). Default pred changed fromlambda val: val is Nonetolambda val: val is not None— meaning None values are skipped. Use path expressions with NOP (~) and cut (#) for conditional updates based on existing values.remove_ifpred now gates on key, not current value: Previously,remove_if(obj, key)checkedpred(current_value_at_path). Now it checkspred(key). Default pred islambda key: key is not None— meaning None keys are skipped.update_if_multi/remove_if_multi: Same pred changes as their single counterparts.
v0.28.0
[*=value]on primitive lists no longer works — use[*]=value(value guard) instead.[*=value]is a SliceFilter that tests keys of dict-like items; primitives have no keys, so it now correctly returns[].[!*=value]on primitive lists no longer works — use[*]!=valueinstead.*&*=valueno longer matches primitives — use*=value(value guard) instead.- Existing
[*=value]on dicts/objects is unchanged. - Existing
&filter behavior on dict-like nodes is unchanged.
v0.13.0
- Filter conjunction operator changed from
.to&: The conjunction operator for chaining multiple filters has changed. Previously,*.id=1.name="alice"was used for conjunctive (AND) filtering. Now use*&id=1&name="alice". This change enables support for dotted paths within filter keys (e.g.,items[user.id=1]to filter on nested fields).
API
Probably the easiest thing to do is pydoc the api layer.
$ pydoc dotted
Parsed dotted paths are LRU-cached (after the first parse of a given path string), so repeated use of the same path string is cheap.
Get
See the Paths, Patterns, and Operators sections below for the full notation.
>>> import dotted
>>> dotted.get({'a': {'b': {'c': {'d': 'nested'}}}}, 'a.b.c.d')
'nested'
Update
Update will mutate the object if it can. It always returns the changed object though. If it's not mutable, then get via the return.
>>> import dotted
>>> l = []
>>> t = ()
>>> dotted.update(l, '[0]', 'hello')
['hello']
>>> l
['hello']
>>> dotted.update(t, '[0]', 'hello')
('hello',)
>>> t
()
Update via pattern
You can update all fields that match pattern given by either a wildcard OR regex.
>>> import dotted
>>> d = {'a': 'hello', 'b': 'bye'}
>>> dotted.update(d, '*', 'me')
{'a': 'me', 'b': 'me'}
Immutable updates
Use mutable=False to prevent mutation of the original object:
>>> import dotted
>>> data = {'a': 1, 'b': 2}
>>> result = dotted.update(data, 'a', 99, mutable=False)
>>> data
{'a': 1, 'b': 2}
>>> result
{'a': 99, 'b': 2}
This works for remove as well:
>>> data = {'a': 1, 'b': 2}
>>> result = dotted.remove(data, 'a', mutable=False)
>>> data
{'a': 1, 'b': 2}
>>> result
{'b': 2}
When mutable=False is specified and the root object is mutable, copy.deepcopy()
is called first. This ensures no mutation occurs even when updating through nested
immutable containers (e.g., a tuple inside a dict).
Update if
update_if updates only when pred(val) is true. Default pred is
lambda val: val is not None, so None values are skipped. Use pred=None
for unconditional update (same as update):
>>> import dotted
>>> dotted.update_if({}, 'a', 1)
{'a': 1}
>>> dotted.update_if({}, 'a', None)
{}
>>> dotted.update_if({}, 'a', '', pred=bool)
{}
Use update_if_multi for batch updates with per-item (key, val) or (key, val, pred).
Update with NOP (~)
The NOP operator ~ means "match but don't update." Use it when some matches should
be left unchanged. Combine with cut (#) for conditional updates:
>>> import dotted
>>> data = {'name': {'first': 'hello'}}
>>> dotted.update(data, '(name.~first#, name.first)', 'world') # first exists, NOP + cut
{'name': {'first': 'hello'}}
>>> data = {'name': {}}
>>> dotted.update(data, '(name.~first#, name.first)', 'world') # first missing, update
{'name': {'first': 'world'}}
Remove
You can remove a field or do so only if it matches value. For example,
>>> import dotted
>>> d = {'a': 'hello', 'b': 'bye'}
>>> dotted.remove(d, 'b')
{'a': 'hello'}
>>> dotted.remove(d, 'a', 'bye')
{'a': 'hello'}
Remove via pattern
Similar to update, all patterns that match will be removed. If you provide a value as well, only the matched patterns that also match the value will be removed.
Remove if
remove_if removes only when pred(key) is true. Default pred is
lambda key: key is not None, so None keys are skipped. Use pred=None
for unconditional remove (same as remove):
>>> import dotted
>>> dotted.remove_if({'a': 1, 'b': 2}, 'a')
{'b': 2}
>>> dotted.remove_if({'a': 1}, None)
{'a': 1}
Use remove_if_multi for batch removal with per-item (key, val, pred).
Match
Use to match a dotted-style pattern to a field. Partial matching is on by default. You can match via wildcard OR via regex. Here's a regex example:
>>> import dotted
>>> dotted.match('/a.+/', 'abced.b')
'abced.b'
>>> dotted.match('/a.+/', 'abced.b', partial=False)
With the groups=True parameter, you'll see how it was matched:
>>> import dotted
>>> dotted.match('hello.*', 'hello.there.bye', groups=True)
('hello.there.bye', ('hello', 'there.bye'))
In the above example, hello matched to hello and * matched to there.bye (partial
matching is enabled by default).
Use groups=GroupMode.patterns to capture only pattern segments (wildcards, regex, etc.),
excluding literals:
>>> from dotted import GroupMode
>>> dotted.match('a.*.b', 'a.hello.b', groups=GroupMode.patterns, partial=False)
('a.hello.b', ('hello',))
>>> dotted.match('hello.*', 'hello.there.bye', groups=GroupMode.patterns)
('hello.there.bye', ('there.bye',))
Replace
Substitute placeholders in a template path with bound values. Template paths are validated at parse time — structural errors are caught immediately, not at runtime.
Positional ($N) placeholders resolve against a list or tuple:
>>> import dotted
>>> dotted.replace('people.$1.$2', ('users', 'alice', 'age'))
'people.alice.age'
Named ($(name)) placeholders resolve against a dict:
>>> dotted.replace('$(table).$(key)', {'table': 'users', 'key': 'alice'})
'users.alice'
Placeholders support transforms inside the parenthesized form:
>>> dotted.replace('$(0|uppercase)', ['hello'])
'HELLO'
Combine with match to remap paths — capture groups from one pattern and substitute
into another:
>>> key, groups = dotted.match('users.*.*', 'users.alice.age', groups=True)
>>> dotted.replace('people.$0.$1', groups[1:])
'people.alice.age'
Placeholders work in key, slot, and attr positions:
>>> dotted.replace('items[$0]', ('3',))
'items[3]'
>>> dotted.replace('$0@$1', ('obj', 'name'))
'obj@name'
By default, missing bindings raise an error. Pass partial=True to leave
unresolved placeholders as-is — the output is still a valid template:
>>> dotted.replace('$0.$1.$2', ('a', 'b'), partial=True)
'a.b.$2'
>>> dotted.replace('$(a).$(b)', {'a': 'x'}, partial=True)
'x.$(b)'
See Substitution for is_template, escaping, and more.
Translate
Translate a path using a pattern map (first exact match wins). Each key in the map
is a match pattern; the value is a template with $N placeholders that refer to
captured pattern segments only (wildcards, regex — not literals):
>>> from dotted.api import translate
>>> translate('a.hello.b', {'a.*.b': '$0.there'})
'hello.there'
Here * captures hello as $0. Literal segments a and b are not numbered.
With multiple wildcards, $N indices follow wildcard order:
>>> translate('a.X.b.Y.c', {'a.*.b.*.c': '$1.$0'})
'Y.X'
The pattern map can be a dict or an iterable of (pattern, template) tuples.
Returns None if no pattern matches:
>>> translate('no.match', {'a.*': '$0'}) is None
True
translate_multi applies the pattern map to multiple paths, yielding
(original, translated) tuples (translated is None when no pattern matches):
>>> from dotted.api import translate_multi
>>> list(translate_multi(['a.hello.b', 'x.y'], {'a.*.b': '$0.there'}))
[('a.hello.b', 'hello.there'), ('x.y', None)]
Expand
You may wish to expand all fields that match a pattern in an object.
>>> import dotted
>>> d = {'hello': {'there': [1, 2, 3]}, 'bye': 7}
>>> dotted.expand(d, '*')
('hello', 'bye')
>>> dotted.expand(d, '*.*')
('hello.there',)
>>> dotted.expand(d, '*.*[*]')
('hello.there[0]', 'hello.there[1]', 'hello.there[2]')
>>> dotted.expand(d, '*.*[1:]')
('hello.there[1:]',)
Overlaps
Test whether two dotted paths overlap—i.e. one is a prefix of the other, or they
are identical. Used internally by soft cut (##) to
decide which later-branch results to suppress.
>>> import dotted
>>> dotted.overlaps('a', 'a.b.c')
True
>>> dotted.overlaps('a.b.c', 'a')
True
>>> dotted.overlaps('a.b', 'a.b')
True
>>> dotted.overlaps('a.b', 'a.c')
False
Has
Check if a key or pattern exists in an object.
>>> import dotted
>>> d = {'a': {'b': 1}}
>>> dotted.has(d, 'a.b')
True
>>> dotted.has(d, 'a.c')
False
>>> dotted.has(d, 'a.*')
True
Mutable
Check if update(obj, key, val) would mutate obj in place. Returns False for
empty paths (root replacement) or when the object or any container in the path
is immutable.
>>> import dotted
>>> dotted.mutable({'a': 1}, 'a')
True
>>> dotted.mutable({'a': 1}, '') # empty path
False
>>> dotted.mutable((1, 2), '[0]') # tuple is immutable
False
>>> dotted.mutable({'a': (1, 2)}, 'a[0]') # nested tuple (dict is mutable)
True
This is useful when you need to know whether to use the return value:
>>> data = {'a': 1}
>>> if dotted.mutable(data, 'a'):
... _ = dotted.update(data, 'a', 2) # mutates in place
... else:
... data = dotted.update(data, 'a', 2) # use return value
Setdefault
Set a value only if the key doesn't already exist. Creates nested structures as needed.
>>> import dotted
>>> d = {'a': 1}
>>> dotted.setdefault(d, 'a', 999) # key exists, no change; returns value
1
>>> dotted.setdefault(d, 'b', 2) # key missing, sets value; returns it
2
>>> dotted.setdefault({}, 'a.b.c', 7) # creates nested structure; returns value
7
Pluck
Extract (key, value) pairs from an object matching a pattern.
>>> import dotted
>>> d = {'a': 1, 'b': 2, 'nested': {'x': 10}}
>>> dotted.pluck(d, 'a')
('a', 1)
>>> dotted.pluck(d, '*')
(('a', 1), ('b', 2), ('nested', {'x': 10}))
>>> dotted.pluck(d, 'nested.*')
(('nested.x', 10),)
Walk
Like pluck but as a lazy generator — yields (path, value) pairs without
materializing the full result. Useful for streaming or large structures.
>>> import dotted
>>> d = {'a': {'b': 1, 'c': 2}, 'x': 3}
>>> list(dotted.walk(d, 'a.*'))
[('a.b', 1), ('a.c', 2)]
>>> for path, val in dotted.walk(d, '*'):
... print(f'{path} = {val}')
a = {'b': 1, 'c': 2}
x = 3
Unpack
Recursively unpack a nested structure into a flat dict mapping dotted paths to
leaf values — its dotted normal form. The result can be replayed with pack to
reconstruct the original object.
>>> import dotted
>>> d = {'a': {'b': [1, 2, 3]}, 'x': {'y': {'z': [4, 5]}}, 'extra': 'stuff'}
>>> dotted.unpack(d)
{'a.b': [1, 2, 3], 'x.y.z': [4, 5], 'extra': 'stuff'}
>>> dotted.pack(dotted.unpack(d)) == d
True
Pass attrs= to also descend into object attributes. The Attrs enum controls
which attributes are included:
-
Attrs.standard— non-dunder attributes -
Attrs.special— dunder attributes (__name__, etc.)class Pt: ... def init(self, x, y): ... self.x = x ... self.y = y dotted.unpack({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard]) {'point@x': 3, 'point@y': 4}
Pass both to include all attributes: attrs=[Attrs.standard, Attrs.special].
Pack
Build a new object from key-value pairs. The inverse of unpack — infers the root
container type automatically.
>>> import dotted
>>> dotted.pack([('a.b', 1), ('a.c', 2)])
{'a': {'b': 1, 'c': 2}}
>>> dotted.pack([('[0]', 'a'), ('[1]', 'b')])
['a', 'b']
Accepts dicts as well as lists of pairs:
>>> dotted.pack({'x.y': 10, 'x.z': 20})
{'x': {'y': 10, 'z': 20}}
Roundtrips with unpack:
>>> d = {'a': {'b': [1, 2, 3]}, 'x': 'hello'}
>>> dotted.pack(dotted.unpack(d)) == d
True
Keys, Values, Items
keys(), values(), and items() mirror the dict interface over dotted
normal form. All three call unpack() internally.
>>> d = {'a': {'b': 1}, 'x': {'y': 2}, 'z': 3}
>>> dotted.keys(d)
dict_keys(['a.b', 'x.y', 'z'])
>>> dotted.values(d)
dict_values([1, 2, 3])
>>> dotted.items(d)
dict_items([('a.b', 1), ('x.y', 2), ('z', 3)])
keys() and items() support set operations:
>>> dotted.keys({'a': 1, 'b': 2}) & dotted.keys({'b': 3, 'c': 4})
{'b'}
All three accept attrs= (same as unpack):
>>> dotted.keys({'point': Pt(3, 4)}, attrs=[dotted.Attrs.standard])
dict_keys(['point@x', 'point@y'])
Build
Create a default nested structure for a dotted key.
>>> import dotted
>>> dotted.build({}, 'a.b.c')
{'a': {'b': {'c': None}}}
>>> dotted.build({}, 'items[]')
{'items': []}
>>> dotted.build({}, 'items[0]')
{'items': [None]}
Apply
Apply transforms to values in an object in-place.
>>> import dotted
>>> d = {'price': '99.99', 'quantity': '5'}
>>> dotted.apply(d, 'price|float')
{'price': 99.99, 'quantity': '5'}
>>> dotted.apply(d, '*|int')
{'price': 99, 'quantity': 5}
Assemble
Build a dotted notation string from a list of path segments.
>>> import dotted
>>> dotted.assemble(['a', 'b', 'c'])
'a.b.c'
>>> dotted.assemble(['items', '[0]', 'name'])
'items[0].name'
>>> dotted.assemble([7, 'hello'])
'7.hello'
Quote
Quote a key for use in a dotted path. Idempotent: quote(quote(x)) == quote(x).
Wraps in single quotes if the key contains reserved characters or whitespace.
>>> import dotted
>>> dotted.quote('hello')
'hello'
>>> dotted.quote('has.dot')
"'has.dot'"
>>> dotted.quote('has space')
"'has space'"
>>> dotted.quote(7)
'7'
>>> dotted.quote('7')
'7'
Dotted normal form paths round-trip correctly:
>>> d = {'7': 'seven', 'a.b': 'dotted', 'hello': 'world'}
>>> flat = dotted.unpack(d)
>>> dotted.pack(flat) == d
True
AUTO
All write operations (update, update_multi, build, setdefault, etc.) accept
AUTO as the base object. Instead of passing {} or [], let dotted infer
the root container type from the first key — dict keys produce {}, slot keys
produce [].
>>> import dotted
>>> dotted.update(dotted.AUTO, 'a.b', 1)
{'a': {'b': 1}}
>>> dotted.update(dotted.AUTO, '[0]', 'hello')
['hello']
>>> dotted.update_multi(dotted.AUTO, [('a', 1), ('b', 2)])
{'a': 1, 'b': 2}
Multi Operations
Most operations have *_multi variants for batch processing:
Note: get_multi returns a generator (not a list or tuple). That distinguishes it from a pattern get, which returns a tuple of matches. It also keeps input and output in the same style when you pass an iterator or generator of paths—lazy in, lazy out.
>>> import dotted
>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> list(dotted.get_multi(d, ['a', 'b']))
[1, 2]
>>> dotted.update_multi({}, [('a.b', 1), ('c.d', 2)])
{'a': {'b': 1}, 'c': {'d': 2}}
>>> dotted.remove_multi(d, ['a', 'c'])
{'b': 2}
>>> d = {'a': 1}; list(dotted.setdefault_multi(d, [('a', 999), ('b', 2)]))
[1, 2]
>>> d
{'a': 1, 'b': 2}
>>> dotted.update_if_multi({}, [('a', 1), ('b', None), ('c', 3)]) # skips None vals
{'a': 1, 'c': 3}
>>> dotted.remove_if_multi({'a': 1, 'b': 2}, ['a', None, 'b']) # skips None keys
{}
Available multi operations: get_multi, update_multi, update_if_multi, remove_multi,
remove_if_multi, setdefault_multi, match_multi, expand_multi, apply_multi,
build_multi, pluck_multi, walk_multi, assemble_multi.
Strict Mode
By default, dotted is flexible about accessor types: bracket notation [0] falls through
to dict keys on dicts, and dot notation .0 coerces numeric keys to list indices on lists.
This makes casual access forgiving, but sometimes you want type-separated access where
slots only match lists and keys only match dicts.
Pass strict=True to enforce this separation:
>>> import dotted
>>> d = {0: 'by-key', 'a': [10, 20]}
>>> # Non-strict (default): [0] falls through to dict key 0
>>> dotted.get(d, '[0]')
'by-key'
>>> # Strict: [0] only matches lists, returns None on dict
>>> dotted.get(d, '[0]', strict=True) is None
True
>>> # Non-strict: .0 coerces to list index
>>> dotted.get(d, 'a.0')
10
>>> # Strict: .0 only matches dict keys, returns None on list
>>> dotted.get(d, 'a.0', strict=True) is None
True
Strict mode applies to all operations — get, update, remove, has, expand,
pluck, and their _multi variants. Mismatches silently return empty results, consistent
with dotted's safe traversal model:
>>> dotted.has(d, '[0]', strict=True)
False
>>> dotted.update(d, '[0]', 'new', strict=True) # no-op on dict
{0: 'by-key', 'a': [10, 20]}
>>> dotted.remove(d, '[0]', strict=True) # no-op on dict
{0: 'by-key', 'a': [10, 20]}
Use strict mode when your data mixes integer dict keys and list indices, and you need to distinguish between them.
Paths
Dotted notation shares similarities with python. A dot . field expects to see a
dictionary-like object (using keys and __getitem__ internally). A bracket []
field is biased towards sequences (like lists or strs) but can also act on dicts. A
attr @ field uses getattr/setattr/delattr.
Key fields
A key field is expressed as a or part of a dotted expression, such as a.b. The
grammar parser is permissive for what can be in a key field. Pretty much any non-reserved
char will match. Note that key fields will only work on objects that have a keys
method. Basically, they work with dictionary or dictionary-like objects.
>>> import dotted
>>> dotted.get({'a': {'b': 'hello'}}, 'a.b')
'hello'
If the key field starts with a space or -, you should either quote it OR you may use
a \ as the first char.
Bracketed fields
You may also use bracket notation, such as a[0] which does a __getitem__ at key 0.
The parser prefers numeric types over string types (if you wish to look up a non-numeric
field using brackets be sure to quote it). Bracketed fields will work with pretty much
any object that can be looked up via __getitem__.
>>> import dotted
>>> dotted.get({'a': ['first', 'second', 'third']}, 'a[0]')
'first'
>>> dotted.get({'a': {'b': 'hello'}}, 'a["b"]')
'hello'
Attr fields
An attr field is expressed by prefixing with @. This will fetch data at that attribute.
You may wonder why have this when you can just as easily use standard python to access.
Two important reasons: nested expressions and patterns.
>>> import dotted, types
>>> ns = types.SimpleNamespace()
>>> ns.hello = {'me': 'goodbye'}
>>> dotted.get(ns, '@hello.me')
'goodbye'
Slicing
Dotted slicing works like python slicing and all that entails.
>>> import dotted
>>> d = {'hi': {'there': [1, 2, 3]}, 'bye': {'there': [4, 5, 6]}}
>>> dotted.get(d, 'hi.there[::2]')
[1, 3]
>>> dotted.get(d, '*.there[1:]')
([2, 3], [5, 6])
Dot notation for sequence indexing
Numeric path segments work as indices when accessing sequences (lists, tuples, strings):
>>> import dotted
>>> data = {'items': [10, 20, 30]}
>>> dotted.get(data, 'items.0')
10
>>> dotted.get(data, 'items.-1') # negative index
30
This is equivalent to bracket notation for existing sequences:
>>> dotted.get(data, 'items[0]') # same result
10
Chaining works naturally:
>>> data = {'users': [{'name': 'alice'}, {'name': 'bob'}]}
>>> dotted.get(data, 'users.0.name')
'alice'
Updates and removes also work:
>>> _ = dotted.update(data, 'users.0.name', 'ALICE')
>>> dotted.get(data, 'users.0.name')
'ALICE'
Note: When creating structures, use bracket notation for lists:
>>> dotted.build({}, 'items.0') # dot notation creates dict
{'items': {0: None}}
>>> dotted.build({}, 'items[0]') # bracket notation creates list
{'items': [None]}
Empty path (root access)
An empty string '' refers to the root of the data structure itself:
>>> import dotted
>>> data = {'a': 1, 'b': 2}
>>> dotted.get(data, '')
{'a': 1, 'b': 2}
Unlike normal paths which mutate in place, update with an empty path is non-mutating
since Python cannot rebind the caller's variable:
>>> data = {'a': 1, 'b': 2}
>>> result = dotted.update(data, '', {'replaced': True})
>>> result
{'replaced': True}
>>> data
{'a': 1, 'b': 2}
Compare with a normal path which mutates:
>>> data = {'a': 1, 'b': 2}
>>> dotted.update(data, 'a', 99)
{'a': 99, 'b': 2}
>>> data
{'a': 99, 'b': 2}
Other empty path operations:
>>> data = {'a': 1, 'b': 2}
>>> dotted.remove(data, '') is None
True
>>> dotted.expand(data, '')
('',)
>>> dotted.pluck(data, '')
('', {'a': 1, 'b': 2})
Typing & Quoting
Numeric types
The parser will attempt to interpret a field numerically if it can, such as field.1
will interpret the 1 part numerically.
>>> import dotted
>>> dotted.get({'7': 'me', 7: 'you'}, '7')
'you'
Quoting
Sometimes you need to quote a field which you can do by just putting the field in quotes.
>>> import dotted
>>> dotted.get({'has . in it': 7}, '"has . in it"')
7
The numericize # operator
Non-integer numeric fields may be interpreted incorrectly if they have decimal point. To
solve, use the numerize operator # at the front of a quoted field, such as #'123.45'.
This will coerce to a numeric type (e.g. float).
>>> import dotted
>>> d = {'a': {1.2: 'hello', 1: {2: 'fooled you'}}}
>>> dotted.get(d, 'a.1.2')
'fooled you'
>>> dotted.get(d, 'a.#"1.2"')
'hello'
Container types
Container literals express list, dict, set, tuple, and frozenset values inline. They appear in two contexts: as filter values / value guards (with pattern support) and as transform arguments (concrete values only).
| Syntax | Type | Notes |
|---|---|---|
[1, 2, 3] |
list or tuple | Unprefixed matches both |
l[1, 2, 3] |
list | Strict: list only |
t[1, 2, 3] |
tuple | Strict: tuple only |
{"a": 1} |
dict | Unprefixed matches dict-like |
d{"a": 1} |
dict | Strict: dict only (isinstance) |
{1, 2, 3} |
set or frozenset | Unprefixed matches both |
s{1, 2, 3} |
set | Strict: set only |
fs{1, 2, 3} |
frozenset | Strict: frozenset only |
Empty containers: [] (empty list/tuple), {} (empty dict), s{} (empty set),
fs{} (empty frozenset), l[], t[], d{}.
Without a type prefix, brackets match loosely: [] matches any
list or tuple, {v, v} matches any set or frozenset, {} matches dict (following
Python convention where {} is a dict literal, not a set).
In filter context, containers support patterns (*, ..., /regex/) inside — see
Container filter values. In transform argument context,
only concrete scalar values are allowed.
Bytes literals
Prefix a quoted string with b to create a bytes literal: b"hello" or b'hello'.
Bytes literals produce bytes values and only match bytes — never str:
>>> import dotted
>>> d = {'a': b'hello', 'b': b'world', 'c': 'hello'}
>>> dotted.get(d, '*=b"hello"')
(b'hello',)
Use in filters:
>>> data = [{'data': b'yes'}, {'data': b'no'}, {'data': 'yes'}]
>>> dotted.get(data, '[*&data=b"yes"]')
({'data': b'yes'},)
Note that b"hello" does not match the string 'hello' — types must match exactly.
Patterns
You may use dotted for pattern matching. You can match to wildcards or regular expressions. You'll note that patterns always return a tuple of matches.
>>> import dotted
>>> d = {'hi': {'there': [1, 2, 3]}, 'bye': {'there': [4, 5, 6]}}
>>> dotted.get(d, '*.there[2]')
(3, 6)
>>> dotted.get(d, '/h.*/.*')
([1, 2, 3],)
Dotted will return all values that match the pattern(s).
Wildcards
The wildcard pattern is *. It will match anything.
Regular expressions
The regex pattern is enclosed in slashes: /regex/. Note that if the field is a non-str,
the regex pattern will internally match to its str representation.
The match-first operator
You can also postfix any pattern with a ?. This will return only
the first match.
>>> import dotted
>>> d = {'hi': {'there': [1, 2, 3]}, 'bye': {'there': [4, 5, 6]}}
>>> dotted.get(d, '*?.there[2]')
(3,)
Slicing vs Patterns
Slicing a sequence produces a sequence and a filter on a sequence is a special type of slice operation. Whereas, patterns iterate through items:
>>> import dotted
>>> data = [{'name': 'alice'}, {'name': 'bob'}, {'name': 'alice'}]
>>> dotted.get(data, '[1:3]')
[{'name': 'bob'}, {'name': 'alice'}]
>>> dotted.get(data, '[name="alice"]')
[{'name': 'alice'}, {'name': 'alice'}]
>>> dotted.get(data, '[*]')
({'name': 'alice'}, {'name': 'bob'}, {'name': 'alice'})
Chaining after a slice accesses the result itself, not the items within it:
>>> dotted.get(data, '[1:3].name') is None # accessing .name on the list
True
>>> dotted.get(data, '[name="alice"].name') is None # also accessing .name on the list
True
>>> dotted.get(data, '[].name') is None # .name on a raw list
True
To chain through the items, use a pattern instead:
>>> dotted.get(data, '[*].name')
('alice', 'bob', 'alice')
>>> dotted.get(data, '[*&name="alice"]')
({'name': 'alice'}, {'name': 'alice'})
Substitutions and References
All $-prefixed syntax falls into two categories: substitutions (resolved
at replace time) and references (resolved during traversal).
| Syntax | Type | Resolved against |
|---|---|---|
$0, $1 |
Positional substitution | replace() bindings (list/tuple) |
$(name) |
Named substitution | replace() bindings (dict) |
$(0), $(name) |
Substitution with parens | replace() bindings via __getitem__ |
$(name|int) |
Substitution with transform | replace() bindings, then transform |
$$(path) |
Reference | Root object during traversal |
$$(^path) |
Relative reference | Current node during traversal |
$$(^^path) |
Relative reference | Parent node during traversal |
Substitution
Substitution references turn a path into a template. There are two forms:
- Positional (
$0,$1, …) — resolved against a list or tuple - Named (
$(name),$(key), …) — resolved against a dict
The replace function resolves them:
>>> dotted.replace('people.$0.$1', ('alice', 'age'))
'people.alice.age'
>>> dotted.replace('$(table).$(field)', {'table': 'users', 'field': 'email'})
'users.email'
The parenthesized form $(N) adapts to the binding type — it uses __getitem__,
so $(0) works as a positional index against a list or as a numeric key against
a dict:
>>> dotted.replace('$(0)', {0: 'zero'})
'zero'
Use is_template to test whether a path contains substitution references:
>>> dotted.is_template('a.$0')
True
>>> dotted.is_template('a.$(name)')
True
>>> dotted.is_template('a.b')
False
Substitution transforms
Substitutions support per-substitution transforms using the | separator inside
the parenthesized form. The transform is applied to the resolved value before it
is spliced into the path:
>>> dotted.replace('$(name|uppercase)', {'name': 'hello'})
'HELLO'
>>> dotted.replace('$(0|str)', [42])
'42'
Multiple transforms chain left to right:
>>> dotted.replace('$(name|strip|lowercase)', {'name': ' HELLO '})
'hello'
All built-in transforms are available. The bare $N form
does not support transforms — use $(N|transform) instead.
See Replace and Translate for full API details.
Template bindings
All traversal APIs — get, update, remove, has, walk, expand, pluck,
apply, build, mutable, setdefault, and their multi variants — accept a
bindings= keyword argument to resolve template paths inline:
>>> data = {'users': {'alice': 42}}
>>> dotted.get(data, 'users.$(name)', bindings={'name': 'alice'})
42
>>> dotted.update(data, 'users.$(name)', 99, bindings={'name': 'alice'})
{'users': {'alice': 99}}
If a template path reaches a traversal API without bindings, a TypeError is
raised — unresolved templates cannot be traversed:
>>> dotted.get({}, 'a.$(name)')
Traceback (most recent call last):
...
TypeError: unresolved template path; call replace() first or pass bindings=
The parse() function also supports bindings= and partial=:
>>> ops = dotted.parse('$0.b', bindings=['x'], partial=False)
>>> dotted.is_template(ops)
False
When partial=True (the default for parse()), unresolved substitutions are
allowed through. When partial=False (the default for traversal APIs),
unresolved templates raise TypeError.
References
A reference resolves a dotted path against the root object during traversal
and uses the result as a literal key. The syntax is $$(path):
>>> data = {'config': {'field': 'name'}, 'name': 'Alice'}
>>> dotted.get(data, '$$(config.field)')
'Alice'
Here $$(config.field) resolves to 'name', then looks up data['name'].
References work with all access types and in any position:
>>> data = {'meta': {'key': 'users'}, 'users': [{'name': 'Alice'}]}
>>> dotted.get(data, '$$(meta.key)[0].name')
'Alice'
>>> data = {'meta': {'idx': 1}, 'items': ['a', 'b', 'c']}
>>> dotted.get(data, 'items[$$(meta.idx)]')
'b'
References are not templates — they resolve during traversal, not via
replace. They also work with update and remove:
>>> data = {'config': {'field': 'name'}, 'name': 'Alice'}
>>> dotted.update(data, '$$(config.field)', 'Bob')
{'config': {'field': 'name'}, 'name': 'Bob'}
References combine naturally with patterns. The reference resolves to a concrete key while the rest of the path can still be a pattern:
>>> data = {
... 'config': {'key': 'name'},
... 'users': [{'name': 'Alice', 'age': 30}, {'name': 'Bob', 'age': 25}],
... }
>>> dotted.get(data, 'users[*].$$(config.key)')
('Alice', 'Bob')
Here $$(config.key) resolves to 'name', making the effective path
users[*].name.
The reference path itself can be a pattern. When it resolves to multiple values, each is used as a separate key:
>>> data = {
... 'config': {'a': {'field': 'name'}, 'b': {'field': 'age'}},
... 'name': 'Alice',
... 'age': 30,
... }
>>> dotted.get(data, '$$(config.*.field)')
('Alice', 30)
Here config.*.field resolves to ('name', 'age'), so the reference
looks up both data['name'] and data['age'].
The resolved value is used as a literal key — it is not re-parsed as a dotted path. If the reference path is not found, the reference matches nothing (same as a missing key):
>>> dotted.get(data, '$$(missing.path)', default='fallback')
'fallback'
Relative References
By default, references resolve against the root object. Prefix the path
with ^ to resolve against the current node instead:
>>> data = {'a': {'field': 'name', 'name': 'Alice'}}
>>> dotted.get(data, 'a.$$(^field)')
'Alice'
Here $$(^field) looks up field in the current node (data['a']), gets
'name', then looks up data['a']['name'].
Use ^^ for the parent node, ^^^ for the grandparent, and so on:
>>> data = {'field': 'x', 'a': {'x': 1, 'y': 2}}
>>> dotted.update(data, 'a.$$(^^field)', 99)
{'field': 'x', 'a': {'x': 99, 'y': 2}}
Relative references combine with patterns:
>>> data = {
... 'a': {
... 'config': {'x': {'field': 'name'}, 'y': {'field': 'age'}},
... 'name': 'Alice',
... 'age': 30,
... },
... }
>>> dotted.get(data, 'a.$$(^config.*.field)')
('Alice', 30)
Escaping
If your data has keys that start with $, prefix with backslash to suppress
substitution. This works for both positional and named forms:
>>> dotted.get({'$0': 'hello'}, '\\$0')
'hello'
>>> dotted.get({'$(name)': 'val'}, '\\$(name)')
'val'
Quoting also works — a quoted string is always literal:
>>> dotted.get({'$0': 'hello'}, "'$0'")
'hello'
quote and unpack emit single-quoted forms for $-prefixed keys:
>>> dotted.quote('$0')
"'$0'"
To use a literal $$ as a key, escape with backslash (\$$) or quote it
('$$(…)').
Type Restrictions
A type restriction constrains a path segment so it only applies when the
current node is a specific type. Append :type after any accessor — key,
slot, or attr:
>>> import dotted
>>> d = {'a': {'b': 1}, 'c': [1, 2], 'd': 'hello'}
>>> dotted.get(d, '*:dict.*')
(1,)
Here *:dict matches all keys but only descends into values that are dicts.
The list and string are skipped.
Supported types: str, bytes, int, float, dict, list, tuple,
set, frozenset, bool.
Positive restrictions
:type allows only that type. :(type1, type2) allows any of the listed types:
>>> d = {'x': {'y': 1}}
>>> dotted.get(d, 'x:dict.y')
1
>>> dotted.get(d, 'x:list', 'missing')
'missing'
Negative restrictions
:!type excludes a type. :!(type1, type2) excludes multiple types:
>>> d = {'a': 'hello', 'b': {'c': 1}}
>>> dotted.get(d, '*:!str.*')
(1,)
Slot and attr restrictions
Type restrictions work on slot and attr accessors too:
>>> dotted.get([10, 20], '[0]:list')
10
>>> dotted.get((10, 20), '[0]:list', 'missing')
'missing'
>>> dotted.get((10, 20), '[0]:tuple')
10
Use :!(str, bytes) on slot wildcards to prevent string and bytes decomposition:
>>> d = {'s': 'abc', 'b': b'xyz', 'l': [1, 2]}
>>> dotted.get(d, '*[*]:!(str, bytes)')
(1, 2)
Combining with filters
Type restrictions compose with filters. The restriction goes before the filter:
>>> d = {'a': {'x': 1, 'y': 2}, 'b': {'x': 3, 'y': 4}}
>>> dotted.get(d, '*:dict&x=1.*')
(1, 2)
Recursive traversal with type restrictions
Type restrictions are especially useful in recursive accessor groups to control which containers are traversed:
>>> d = {'a': {'b': [1, 2]}, 'c': 'hello'}
>>> dotted.get(d, '*(*#, [*]:!(str, bytes))')
({'b': [1, 2]}, [1, 2], 1, 2, 'hello')
Without :!(str, bytes), the [*] slot accessor would decompose 'hello'
into individual characters.
A type restriction can also be applied to an entire group, which distributes it to every accessor in the group:
>>> d = {'a': {'b': [1, 2]}, 'c': 'hello'}
>>> dotted.get(d, '*(*#, [*]):!(str, bytes)')
({'b': [1, 2]}, [1, 2], 1, 2, 'hello')
The ** shorthand accepts a type restriction too. It can be combined with a
depth slice — the type restriction comes first:
>>> d = {'a': {'b': [1, 2, 3]}, 'x': {'y': {'z': 3}}}
>>> dotted.get(d, '**:!(str, bytes):-2')
({'b': [1, 2, 3]}, {'z': 3})
Recursive Traversal
The recursive operator * traverses nested data structures by following path
segments that match a pattern at successive levels.
The recursive operator *
*pattern recurses into values whose path segments match the pattern. It follows
chains of matching segments — at each level, if a segment matches, its value is
yielded and the traversal continues into that value:
>>> import dotted
>>> d = {'b': {'b': {'c': 1}}}
>>> dotted.get(d, '*b')
({'b': {'c': 1}}, {'c': 1})
>>> dotted.get(d, '*b.c')
(1,)
The chain stops when the key no longer matches:
>>> d = {'a': {'b': {'c': 1}}}
>>> dotted.get(d, '*b')
()
The inner pattern can be any key pattern — a literal key, a wildcard, or a regex:
>>> d = {'x1': {'x2': 1}, 'y': 2}
>>> dotted.get(d, '*/x.*/')
({'x2': 1}, 1)
Recursive patterns
Recursive pattern matching works across different accessor types:
*<pat> for keys, *([<pat>]) for slots, and *(@<pat>) for attrs.
The accessor determines which children are followed at each level.
*key — recursive dict keys. Since bare * means .* (dict key
access), ** recursively matches all dict keys. Any key pattern works —
*/regex/ recursively matches keys against a regex, *literal follows
only keys named literal:
>>> d = {'a': {'b': {'c': 1}}, 'x': {'y': 2}}
>>> dotted.get(d, '**')
({'b': {'c': 1}}, {'c': 1}, 1, {'y': 2}, 2)
Use ** with continuation to find a key at any depth:
>>> dotted.get(d, '**.c')
(1,)
Use **? to get only the first match:
>>> dotted.get(d, '**?')
({'b': {'c': 1}},)
*([slot]) — recursive slots. Recurses through lists, dicts, and
anything with __getitem__. Since slots fall through to dict keys, this
also matches dicts. Any slot pattern works — *([*]) matches all slots,
*([/regex/]) matches slots by regex:
>>> d = {'a': [{'b': 1}, {'c': 2}]}
>>> dotted.get(d, '*([*])')
([{'b': 1}, {'c': 2}], {'b': 1}, 1, {'c': 2}, 2)
*(@attr) — recursive attrs. Recurses through object attributes. Any
attr pattern works — *(@*) matches all attrs, *(@/regex/) matches attrs
by regex:
>>> dotted.get(data, '*(@*)') # doctest: +SKIP
To recurse through multiple accessor types at once, use *(expr) with a
comma-separated list of access ops. Use # (cut) after an accessor to
prevent double-matching — this is needed because [*] falls through to
dict keys:
>>> d = {'a': [{'b': 1}, {'c': 2}]}
>>> dotted.get(d, '*(*#, [*])')
([{'b': 1}, {'c': 2}], {'b': 1}, 1, {'c': 2}, 2)
>>> dotted.get(data, '*(*#, [*], @*)') # doctest: +SKIP
Depth slicing
Control which depths are visited using slice notation: **:start, **:start:stop,
or **:::step. Note the leading : — depth slicing looks a little different from
regular Python slicing since it follows the ** operator. Depth 0 is the values of
the first-level path segments.
>>> d = {'a': {'x': 1}, 'b': {'y': {'z': 2}}}
>>> dotted.get(d, '**:0')
({'x': 1}, {'y': {'z': 2}})
>>> dotted.get(d, '**:1')
(1, {'z': 2})
Use negative indices to count from the leaf. **:-1 returns leaves only,
**:-2 returns the penultimate level:
>>> dotted.get(d, '**:-1')
(1, 2)
Range slicing works like Python slices: **:start:stop and **:::step:
>>> dotted.get(d, '**:0:1')
({'x': 1}, 1, {'y': {'z': 2}}, {'z': 2})
Depth slicing also works with accessor groups:
>>> d = {'a': {'b': [1, 2, 3]}, 'extra': 'stuff'}
>>> dotted.get(d, '*(*#, [*]:!(str, bytes)):-2')
([1, 2, 3],)
Recursive patterns with value guard
Combine recursive patterns with value guards to find specific values at any depth. All comparison operators are supported:
>>> d = {'a': {'b': 7, 'c': 3}, 'd': {'e': 7}}
>>> dotted.get(d, '**=7')
(7, 7)
>>> dotted.get(d, '**!=7')
({'b': 7, 'c': 3}, 3, {'e': 7})
>>> dotted.get(d, '**>5')
(7, 7)
Recursive update and remove
Recursive patterns work with update and remove:
>>> d = {'a': {'b': 7, 'c': 3}, 'd': 7}
>>> dotted.update(d, '**=7', 99)
{'a': {'b': 99, 'c': 3}, 'd': 99}
>>> d = {'a': {'b': 7, 'c': 3}, 'd': 7}
>>> dotted.remove(d, '**=7')
{'a': {'c': 3}}
Recursive pattern matching
Recursive patterns work with match. ** matches any key path, *key matches
chains of a specific key:
>>> dotted.match('**.c', 'a.b.c')
'a.b.c'
>>> dotted.match('*b', 'b.b.b')
'b.b.b'
>>> dotted.match('*b', 'a.b.c') is None
True
Grouping
Path grouping
Parentheses group operations that branch from a common point. Each branch
is a full operation chain—keys, slots, attrs, and further groups can all
appear inside. Branches are combined using , (disjunction), &
(conjunction), and ! (negation).
>>> import dotted
>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> dotted.get(d, '(a,b)') # , — get a and b
(1, 2)
>>> dotted.get(d, '(a&b)') # & — get a and b only if both exist
(1, 2)
>>> dotted.get(d, '(!a)') # ! — get everything except a
(2, 3)
The three access ops—. (key), @ (attr), [] (slot)—are what
actually look up a child value. Modifiers like ! (negation) and ~
(nop) wrap or filter but don't access anything themselves.
At top level, bare keys infer ., so (a,b) is equivalent to (.a,.b):
>>> dotted.get(d, 'a,b') # same as (a,b) and (.a,.b)
(1, 2)
>>> dotted.get(d, '!a') # same as (!a) and (!.a)
(2, 3)
Parentheses are needed to continue after the group ((a,b).c) or to
nest groups inside paths.
Mid-path groups
Mid-path groups—groups that follow another operation—come in two forms.
Prefix shorthand — the access op sits outside the parentheses and distributes to every branch. Branches are bare keys (no access ops inside):
| Shorthand | Equivalent |
|---|---|
a.(b,c) |
a(.b,.c) |
a.(!b) |
a(!.b) |
@obj@(x,y) |
@obj(@x,@y) |
items[(0,1)] |
items([0],[1]) |
Explicit — no prefix; every branch specifies its own access op. Branches can mix different access ops freely:
| Example | Meaning |
|---|---|
a(.b,.c) |
Keys b and c from a |
a(.b,@b) |
Key b and attr b from a |
a(!.secret) |
Everything except key secret |
a(.x.y,[0].z) |
Key path x.y and slot 0 then key z |
The rule is simple: if an access op floats down from a prefix, you can't specify one; otherwise, you must.
Disjunction (OR)
Comma separates branches. Returns all matches that exist. Disjunction doesn't
short-circuit—when updating, all matching branches get the update. Using the
match-first operator (?) is probably what you want when updating.
>>> d = {'a': {'x': 1, 'y': 2}}
>>> dotted.get(d, 'a(.x,.y)')
(1, 2)
>>> dotted.get(d, 'a(.x,.z)') # z missing, x still returned
(1,)
Updates apply to all matching branches. When nothing matches, the first concrete path (scanning last to first) is created:
>>> d = {'a': {'x': 1, 'y': 2}}
>>> dotted.update(d, 'a(.x,.y)', 99)
{'a': {'x': 99, 'y': 99}}
>>> dotted.update({'a': {}}, 'a(.x,.y)', 99) # nothing matches → creates last (.y)
{'a': {'y': 99}}
Cut (#) in disjunction
Suffix a branch with # so that if it matches, only that branch is used
(get/update/remove); later branches are not tried. Useful for "update if exists,
else append" in lists. Example with slot grouping:
>>> data = {'emails': [{'email': 'alice@x.com', 'verified': False}]}
>>> dotted.update(data, 'emails[(*&email="alice@x.com"#, +)]', {'email': 'alice@x.com', 'verified': True})
{'emails': [{'email': 'alice@x.com', 'verified': True}]}
>>> data = {'emails': [{'email': 'other@x.com'}]}
>>> dotted.update(data, 'emails[(*&email="alice@x.com"#, +)]', {'email': 'alice@x.com', 'verified': True})
{'emails': [{'email': 'other@x.com'}, {'email': 'alice@x.com', 'verified': True}]}
First branch matches items where email="alice@x.com" and updates them (then cut);
if none match, the + branch appends the new dict.
Soft cut (##) in disjunction
Hard cut (#) stops all later branches when the cut branch matches. Soft cut (##)
is more selective: later branches still run, but skip any paths that overlap with
what the soft-cut branch already yielded. Use soft cut when a branch handles some
keys and you want a fallback branch to handle the rest.
>>> d = {'a': {'b': [1, 2, 3]}, 'x': {'y': {'z': [4, 5]}}, 'extra': 'stuff'}
>>> dotted.pluck(d, '(*(*#, [*]:!(str, bytes)):-2(.*, [])##, (*, []))')
(('a.b', [1, 2, 3]), ('x.y.z', [4, 5]), ('extra', 'stuff'))
Here *(*#, [*]:!(str, bytes)):-2(.*, []) recurses into containers (dicts and lists)
and yields their leaf containers. The ## means: for keys that recursion covered
(like a and x), don't try the (*, []) fallback. But extra was not covered
by the recursive branch, so the fallback picks it up.
Compare with hard cut (#), which would lose extra entirely:
>>> dotted.pluck(d, '(*(*#, [*]:!(str, bytes)):-2(.*, [])#, (*, []))')
(('a.b', [1, 2, 3]), ('x.y.z', [4, 5]))
Conjunction (AND)
Use & for all-or-nothing behavior. Returns values only if ALL branches exist:
>>> d = {'a': {'x': 1, 'y': 2}}
>>> dotted.get(d, 'a(.x&.y)')
(1, 2)
>>> dotted.get(d, 'a(.x&.z)') # z missing, fails entirely
()
Updates all branches so the conjunction eval as true—creates missing paths. If a filter or NOP prevents a branch, no update:
>>> dotted.update({'a': {'x': 1, 'y': 2}}, 'a(.x&.y)', 99)
{'a': {'x': 99, 'y': 99}}
>>> dotted.update({'a': {'x': 1}}, 'a(.x&.y)', 99) # y missing → creates it
{'a': {'x': 99, 'y': 99}}
First match
Use ? suffix to return only the first match. When nothing matches, same
fallback as disjunction—first concrete path (last to first):
>>> d = {'a': {'x': 1, 'y': 2}}
>>> dotted.get(d, 'a(.z,.x,.y)?') # first that exists
(1,)
>>> dotted.update({'a': {}}, 'a(.x,.y)?', 99) # nothing matches → creates last (.y)
{'a': {'y': 99}}
Negation (NOT)
Use ! prefix to exclude keys matching a pattern:
>>> import dotted
# Exclude a single key at top level
>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> dotted.get(d, '!a')
(2, 3)
# Exclude single key — explicit form (access op on every branch)
>>> user = {'email': 'a@x.com', 'name': 'alice', 'password': 'secret'}
>>> sorted(dotted.get({'user': user}, 'user(!.password)'))
['a@x.com', 'alice']
# Same thing — shorthand form (access op distributes from prefix)
>>> sorted(dotted.get({'user': user}, 'user.(!password)'))
['a@x.com', 'alice']
# Works with lists too
>>> dotted.get({'items': [10, 20, 30]}, 'items(![0])')
(20, 30)
Updates and removes apply to all non-matching keys:
>>> d = {'a': {'x': 1, 'y': 2, 'z': 3}}
>>> dotted.update(d, 'a(!.x)', 99)
{'a': {'x': 1, 'y': 99, 'z': 99}}
>>> dotted.remove(d, 'a(!.x)')
{'a': {'x': 1}}
Note: For De Morgan's law with filter expressions, see the Filters section below.
Precedence
Access ops (., @, []) bind tightest—they build compound paths
before any operator sees them. Then !, then &, then ,:
| Precedence | Operator | Example |
|---|---|---|
| tightest | . @ [] (access ops) |
a.b is one path |
! (negation) |
!a.b = !(a.b) |
|
& (conjunction) |
!a&b = (!a)&b |
|
| loosest | , (disjunction) |
!a&b,c = ((!a)&b),c |
>>> d = {'a': 1, 'b': 2, 'c': 3}
>>> dotted.get(d, '!a&b,c') # parsed as ((!a)&b), c
(2, 3, 2, 3)
Since . binds tightest, !a.b&c is (!a.b) & c, not !(a.b&c):
>>> d = {'a': {'b': 1}, 'c': 2, 'x': 3}
>>> dotted.get(d, '!a.b&c') # (!a.b)&c — negate a.b, conjoin with c
(2, 3, 2)
>>> dotted.get(d, '!(a.b&c)') # negate the conjunction as a unit
(3,)
Operators
The concat + operator
Outside of brackets, + joins parts of a key segment using Python's native +
operator. Each part can be a literal, substitution, or reference:
>>> dotted.replace('user_+$(name)', {'name': 'alice'})
'user_alice'
>>> data = {'config': {'key': 'name'}, 'name_v2': 42}
>>> dotted.get(data, '$$(config.key)+_v2')
42
All-literal concats collapse at parse time — hello+world becomes the key
helloworld. When any part is dynamic (substitution or reference), a Concat
node is preserved and resolved at traversal or replace time.
Python's native + semantics apply: str + str concatenates, int + int adds,
and mixed types raise TypeError. Per-part transforms allow type coercion:
>>> dotted.get({'x': {3: 'found'}}, 'x[1+2]')
'found'
Concat works in all three access contexts — key (.), slot ([]), and attr (@):
>>> dotted.replace('$0+_suffix', ['hello'])
'hello_suffix'
Inside brackets, + as concat is distinguished from + as append by context:
[+] and [+:] remain append/slice operators, while [a+b] is a concat.
The append + operator
Both bracketed fields and slices support the '+' operator which refers to the end of sequence. You may append an item or slice to the end a sequence.
>>> import dotted
>>> d = {'hi': {'there': [1, 2, 3]}, 'bye': {'there': [4, 5, 6]}}
>>> dotted.update(d, '*.there[+]', 8)
{'hi': {'there': [1, 2, 3, 8]}, 'bye': {'there': [4, 5, 6, 8]}}
>>> dotted.update(d, '*.there[+:]', [999])
{'hi': {'there': [1, 2, 3, 8, 999]}, 'bye': {'there': [4, 5, 6, 8, 999]}}
The append-unique +? operator
If you want to update only unique items to a list, you can use the ?
postfix. This will ensure that it's only added once (see match-first below).
>>> import dotted
>>> items = [1, 2]
>>> dotted.update(items, '[+?]', 3)
[1, 2, 3]
>>> dotted.update(items, '[+?]', 3)
[1, 2, 3]
The invert - operator
You can invert the meaning of the notation by prefixing a -. For example,
to remove an item using update:
>>> import dotted
>>> d = {'a': 'hello', 'b': 'bye'}
>>> dotted.update(d, '-b', dotted.ANY)
{'a': 'hello'}
>>> dotted.remove(d, '-b', 'bye again')
{'a': 'hello', 'b': 'bye again'}
The NOP ~ operator
The NOP operator means "match but don't update." At update and remove time, paths
marked with ~ are matched for traversal but the mutation is skipped at that segment.
NOP applies only to the segment it wraps; child segments are unaffected.
| Syntax | Meaning |
|---|---|
~a.b |
NOP at a, then .b |
a.~b |
NOP at b (dot segment) |
~(name.first) |
NOP on grouped path |
[~*] or ~[*] |
NOP on slot (canonical: [~stuff]) |
@~attr or ~@attr |
NOP on attr |
>>> dotted.update({'a': {'b': 1}}, '~a.b', 2) # NOP at a, update .b
{'a': {'b': 2}}
>>> dotted.update({'a': {'b': 1}}, 'a.~b', 2) # NOP at b, no change
{'a': {'b': 1}}
Combine NOP with cut (#) for "update only if missing" semantics—if the NOP
branch matches, cut commits to it and skips the remaining branches:
>>> # first existed — NOP branch matched and cut
>>> dotted.update({'name': {'first': 'alice'}}, '(name.~first#, name.first)', 'bob')
{'name': {'first': 'alice'}}
>>> # first missing — fell through to update branch
>>> dotted.update({'name': {}}, '(name.~first#, name.first)', 'bob')
{'name': {'first': 'bob'}}
The cut # operator
Think of cut as an OR/disjunction short-circuit. Suffix a branch with #
so that if it matches, only that branch is used and later branches are not tried.
If it doesn't match, evaluation falls through to the next branch.
>>> import dotted
>>> dotted.get({'a': 1, 'b': 2}, '(a#, b)')
(1,)
>>> dotted.get({'b': 2}, '(a#, b)')
(2,)
This is especially useful for "update if exists, else create" patterns:
>>> data = {'emails': [{'email': 'alice@x.com', 'verified': False}]}
>>> dotted.update(data, 'emails[(*&email="alice@x.com"#, +)]', {'email': 'alice@x.com', 'verified': True})
{'emails': [{'email': 'alice@x.com', 'verified': True}]}
>>> data = {'emails': [{'email': 'other@x.com'}]}
>>> dotted.update(data, 'emails[(*&email="alice@x.com"#, +)]', {'email': 'alice@x.com', 'verified': True})
{'emails': [{'email': 'other@x.com'}, {'email': 'alice@x.com', 'verified': True}]}
The soft cut ## operator
Soft cut is a gentler version of cut. Where # stops all later branches when the
cut branch matches, ## only suppresses later branches for paths that overlap with
what the soft-cut branch yielded. Later branches still run for non-overlapping paths.
>>> import dotted
>>> d = {'a': 1, 'b': 2}
>>> dotted.get(d, '(a##, *)') # a covers 'a'; * still yields 'b'
(1, 2)
>>> dotted.get(d, '(a#, *)') # hard cut: a matches, * never runs
(1,)
This is especially useful with recursive patterns where one branch handles nested structures and a fallback handles the rest:
>>> d = {'a': {'b': [1, 2, 3]}, 'x': {'y': {'z': [4, 5]}}, 'extra': 'stuff'}
>>> dotted.pluck(d, '(*(*#, [*]:!(str, bytes)):-2(.*, [])##, (*, []))')
(('a.b', [1, 2, 3]), ('x.y.z', [4, 5]), ('extra', 'stuff'))
The recursive branch covers a and x, so (*, []) skips those. But extra was
not covered, so the fallback picks it up. With hard cut, extra would be lost entirely.
Two paths "overlap" if one is a prefix of the other (e.g. a and a.b.c overlap,
but a.b and a.c do not). You can test this directly:
>>> dotted.overlaps('a', 'a.b.c')
True
>>> dotted.overlaps('a.b', 'a.c')
False
The numericize # operator
The # prefix is also used to coerce a quoted field to a numeric type (e.g. float).
This is needed when a non-integer numeric key contains a decimal point that would
otherwise be parsed as a path separator. See Typing & Quoting
for full details.
>>> import dotted
>>> d = {'a': {1.2: 'hello', 1: {2: 'fooled you'}}}
>>> dotted.get(d, 'a.1.2')
'fooled you'
>>> dotted.get(d, 'a.#"1.2"')
'hello'
Filters
The key-value filter
You may filter by key-value to narrow your result set. All six comparison operators are
supported: =, !=, <, >, <=, >=. Filter keys can be dotted paths and may
include slice notation (e.g. name[:5]="hello", file[-3:]=".py").
You may use with key or bracketed fields. Key-value fields may be disjunctively (OR)
specified via the , delimiter.
A key-value field on key field looks like: keyfield&key1=value1,key2=value2....
This will return all key-value matches on a subordinate dict-like object. For example,
>>> d = {
... 'a': {
... 'id': 1,
... 'hello': 'there',
... },
... 'b': {
... 'id': 2,
... 'hello': 'there',
... },
... }
>>> dotted.get(d, '*&id=1')
({'id': 1, 'hello': 'there'},)
>>> dotted.get(d, '*&id=*')
({'id': 1, 'hello': 'there'}, {'id': 2, 'hello': 'there'})
A key-value field on a bracketed field looks like: [key1=value1,key2=value2...].
This will return all items in a list that match key-value filter. For example,
>>> d = {
... 'a': [{'id': 1, 'hello': 'there'}, {'id': 2, 'hello': 'there'}],
... 'b': [{'id': 3, 'hello': 'there'}, {'id': 4, 'hello': 'bye'}],
... }
>>> dotted.get(d, 'a[hello="there"][*].id')
(1, 2)
>>> dotted.get(d, '*[hello="there"][*].id')
(1, 2, 3)
The key-value first filter
You can have it match first by appending a ? to the end of the filter.
>>> d = {
... 'a': [{'id': 1, 'hello': 'there'}, {'id': 2, 'hello': 'there'}],
... 'b': [{'id': 3, 'hello': 'there'}, {'id': 4, 'hello': 'bye'}],
... }
>>> dotted.get(d, 'a[hello="there"?]')
[{'id': 1, 'hello': 'there'}]
Conjunction vs disjunction
To conjunctively connect filters use the & operator. Filters offer the ability to act
disjunctively as well by using the , operator.
For example, given
*&key1=value1,key2=value2&key3=value3. This will filter
(key1=value1 OR key2=value2) AND key3=value3.
Note that this gives you the ability to have a key filter multiple values, such as:
*&key1=value1,key2=value2.
Grouping with parentheses
Use parentheses to control precedence in complex filter expressions:
>>> data = [
... {'id': 1, 'type': 'a', 'active': True},
... {'id': 2, 'type': 'b', 'active': True},
... {'id': 3, 'type': 'a', 'active': False},
... ]
# (id=1 OR id=2) AND active=True
>>> dotted.get(data, '[(id=1,id=2)&active=True]')
[{'id': 1, 'type': 'a', 'active': True}, {'id': 2, 'type': 'b', 'active': True}]
# id=1 OR (id=3 AND active=False)
>>> dotted.get(data, '[id=1,(id=3&active=False)]')
[{'id': 1, 'type': 'a', 'active': True}, {'id': 3, 'type': 'a', 'active': False}]
Groups can be nested for complex logic:
# ((id=1 OR id=2) AND type='a') OR id=4
>>> dotted.get(data, '[((id=1,id=2)&type="a"),id=4]')
[{'id': 1, 'type': 'a', 'active': True}]
Precedence: & (AND) binds tighter than , (OR). Use parentheses when you need
OR groups inside AND expressions.
To use literal parentheses in keys, quote them: "(key)".
Filter negation and not-equals
Use ! to negate filter conditions, or != as syntactic sugar for not-equals (key!=value ≡ !(key=value)):
>>> data = [
... {'status': 'active', 'role': 'admin'},
... {'status': 'inactive', 'role': 'user'},
... {'status': 'active', 'role': 'user'},
... ]
# Not-equals: items where status != "active"
>>> dotted.get(data, '[status!="active"]')
[{'status': 'inactive', 'role': 'user'}]
# Equivalent using negation
>>> dotted.get(data, '[!status="active"]')
[{'status': 'inactive', 'role': 'user'}]
# Negate grouped expression - NOT (active AND admin)
>>> dotted.get(data, '[!(status="active"&role="admin")]')
[{'status': 'inactive', 'role': 'user'}, {'status': 'active', 'role': 'user'}]
# Combine negation with AND - active non-admins
>>> dotted.get(data, '[status="active"&!role="admin"]')
[{'status': 'active', 'role': 'user'}]
Precedence: ! binds tighter than & and ,:
[!a=1&b=2] → [(!a=1) & b=2]
[!(a=1&b=2)] → negate the whole group
Filtering for missing fields
Use !field=* to filter for items where a field is missing entirely (vs exists with
value None):
>>> data = [
... {'name': 'alice', 'email': 'alice@example.com'},
... {'name': 'bob'}, # no email field
... {'name': 'charlie', 'email': None}, # email exists but is None
... ]
# Field missing (doesn't exist)
>>> dotted.get(data, '[!email=*]')
[{'name': 'bob'}]
# Field exists with value None
>>> dotted.get(data, '[email=None]')
[{'name': 'charlie', 'email': None}]
This works because email=* matches any value when the field exists, so !email=*
only passes when the field is missing.
Comparison operators
Filters and value guards support <, >, <=, >= for range queries:
>>> items = [
... {'name': 'alice', 'age': 15},
... {'name': 'bob', 'age': 20},
... {'name': 'charlie', 'age': 25},
... ]
>>> dotted.get(items, '[age>=18][*].name')
('bob', 'charlie')
>>> dotted.get(items, '[age<20][*].name')
('alice',)
Comparison operators work in all the same contexts as = and !=—value guards,
recursive guards, and with transforms:
>>> d = {'a': 5, 'b': 15, 'c': 25}
>>> dotted.get(d, '*<10')
(5,)
>>> dotted.get(d, '*>=15')
(15, 25)
>>> deep = {'x': 1, 'y': {'z': 100, 'w': 3}}
>>> dotted.get(deep, '**<10')
(1, 3)
>>> dotted.get({'val': '7'}, 'val|int>5')
'7'
Incomparable types silently fail to match (no exception):
>>> dotted.get({'a': 'hello', 'b': 3}, '*>1')
(3,)
Boolean and None filter values
Filters support True, False, and None as values:
>>> data = [
... {'name': 'alice', 'active': True, 'score': None},
... {'name': 'bob', 'active': False, 'score': 100},
... ]
>>> dotted.get(data, '[active=True]')
[{'name': 'alice', 'active': True, 'score': None}]
>>> dotted.get(data, '[score=None]')
[{'name': 'alice', 'active': True, 'score': None}]
Value guard
A value guard tests the value at a path and yields it only if it matches.
Use key=value or [slot]=value after accessing a field. All comparison
operators (=, !=, <, >, <=, >=) are supported:
>>> d = {'first': 7, 'last': 3}
>>> dotted.get(d, 'first=7')
7
>>> dotted.get(d, 'first=3') # no match
>>> dotted.get(d, '*=7')
(7,)
>>> dotted.pluck(d, '*=7')
(('first', 7),)
For lists of primitive values, use [*]=value:
>>> data = [1, 7, 3, 7]
>>> dotted.get(data, '[*]=7')
(7, 7)
>>> dotted.get(data, '[0]=1')
1
Guards support all value types: numbers, None, True/False, strings, regex, *,
and container patterns ([1, ...], {"a": 1, ...: *}, {1, 2, ...}):
>>> dotted.get([None, 1, 2], '[*]=None')
(None,)
>>> dotted.get(['hello', 'world', 'help'], '[*]="hello"')
('hello',)
>>> dotted.get(['hello', 'world', 'help'], '[*]=/hel.*/')
('hello', 'help')
Use != for negation, or ordered operators for range checks:
>>> dotted.get([True, False, None, 1, 2], '[*]!=True')
(False, None, 2)
>>> dotted.get({'a': 7, 'b': 3}, '*!=7')
(3,)
>>> dotted.get([10, 20, 30, 40], '[*]>25')
(30, 40)
>>> dotted.get({'a': 7, 'b': 3}, '*<=5')
(3,)
Guards compose with continuation (dot paths):
>>> dotted.get({'a': {'first': 7}}, 'a.first=7')
7
Note: [*=value] (equals inside brackets) is a SliceFilter — it tests keys of each
dict-like list item. [*]=value (equals outside brackets) is a value guard — it tests
the item values directly. For primitive lists, use [*]=value.
Note: Python equality applies, so 1 == True and 0 == False:
>>> dotted.get([True, 1, False, 0], '[*]=True')
(True, 1)
Guard transforms
Guards and filters can apply transforms before comparing. Place transforms between the field and the comparison operator:
>>> d = {'a': '7', 'b': '3'}
>>> dotted.get(d, '*|int=7')
('7',)
>>> dotted.get(d, '*|int!=7')
('3',)
The transform is used for matching only — the yielded value is the original (untransformed) value:
>>> dotted.get({'val': '7'}, 'val|int=7')
'7'
>>> dotted.get({'val': '3'}, 'val|int=7') # no match
Slot guards work the same way:
>>> dotted.get(['3', '7', '7'], '[*]|int=7')
('7', '7')
Multiple transforms can be chained:
>>> dotted.get({'val': '7.9'}, 'val|float|int=7')
'7.9'
Guard transforms work with filters too. Use key|transform=value inside a filter:
>>> items = [{'val': '7', 'name': 'a'}, {'val': '3', 'name': 'b'}]
>>> dotted.get(items, '[val|int=7]')
[{'val': '7', 'name': 'a'}]
>>> dotted.get(items, '[*&val|int!=7].name')
('b',)
And with recursive patterns:
>>> deep = {'x': {'val': '10'}, 'y': {'val': '5'}}
>>> dotted.get(deep, '**.val|int=10')
('10',)
Guard transforms compose with update, remove, and has:
>>> dotted.update({'a': '7', 'b': '3'}, '*|int=7', 'X')
{'a': 'X', 'b': '3'}
>>> dotted.remove({'a': '7', 'b': '3'}, '*|int!=7')
{'a': '7'}
>>> dotted.has({'val': '7'}, 'val|int=7')
True
Container filter values
Filter values and value guards can use container patterns to match against lists,
dicts, and sets structurally. Container patterns support wildcards (*), globs (...),
regex, and full nesting.
List patterns
[elements] matches list and tuple values element by element:
>>> import dotted
>>> d = {'items': [1, 2, 3], 'name': 'test', 'pair': (1, 2)}
# Exact match
>>> dotted.get(d, '*=[1, 2, 3]')
([1, 2, 3],)
# Wildcard: * matches exactly one element
>>> dotted.get(d, '*=[1, *, 3]')
([1, 2, 3],)
# Glob: ... matches zero or more elements ([] matches list and tuple)
>>> dotted.get(d, '*=[1, ...]')
([1, 2, 3], (1, 2))
# Empty list matches any empty list or tuple
>>> dotted.get({'a': [], 'b': [1]}, '*=[]')
([],)
Unprefixed [] matches both list and tuple. See Type prefixes
for strict type matching.
Dict patterns
{key: value} matches dict values by key-value pairs:
>>> d = [{'cfg': {'a': 1, 'b': 2}}, {'cfg': {'a': 1}}]
# Exact match — no extra keys allowed
>>> dotted.get(d, '[*&cfg={"a": 1}]')
({'cfg': {'a': 1}},)
# Partial match — ...: * allows extra entries
>>> dotted.get(d, '[*&cfg={"a": 1, ...: *}]')
({'cfg': {'a': 1, 'b': 2}}, {'cfg': {'a': 1}})
# Any dict: {...: *}
>>> dotted.get({'x': {'a': 1}, 'y': 42}, '*={...: *}')
({'a': 1},)
Set patterns
{elements} (comma-separated, no colons) matches set and frozenset values:
>>> d = {'tags': {1, 2, 3}, 'name': 'test'}
# Exact match
>>> dotted.get(d, '*={1, 2, 3}')
({1, 2, 3},)
# Partial match — ... allows extra members
>>> dotted.get(d, '*={1, ...}')
({1, 2, 3},)
Unprefixed {v, v} matches both set and frozenset. See Type prefixes
for strict type matching.
The ... glob element
Inside containers, ... matches zero or more elements. It supports optional regex
patterns and count constraints:
| Form | Meaning |
|---|---|
... |
0 or more, anything |
...5 |
0 to 5 |
...2:5 |
2 to 5 |
...2: |
2 or more |
.../regex/ |
0 or more, each matching regex |
.../regex/2:5 |
2 to 5, each matching regex |
Examples:
>>> d = {'nums': [1, 2, 3, 4, 5]}
# Glob with count: at most 3 elements
>>> dotted.get(d, '*=[...3]')
()
>>> dotted.get({'nums': [1, 2, 3]}, '*=[...3]')
([1, 2, 3],)
# Glob with regex: all elements must be digits
>>> dotted.get(d, '*=[.../\\d+/]')
([1, 2, 3, 4, 5],)
In dicts, ... appears on the key side:
>>> d = {'user_a': 1, 'user_b': 2, 'admin': 3}
>>> dotted.get({'x': d}, 'x={.../user_.*/: *, ...: *}')
{'user_a': 1, 'user_b': 2, 'admin': 3}
Nested containers
Patterns nest fully:
>>> d = {'data': {'a': [1, 2, 3], 'b': {'x': 10}}}
>>> dotted.get(d, '*={"a": [1, ...], ...: *}')
({'a': [1, 2, 3], 'b': {'x': 10}},)
Filters with container values
Container patterns work in filter expressions too:
>>> data = [{'tags': [1, 2, 3]}, {'tags': [4, 5]}, {'tags': [1]}]
>>> dotted.get(data, '[*&tags=[1, ...]]')
({'tags': [1, 2, 3]}, {'tags': [1]})
Type prefixes
Container patterns support type prefixes for strict type matching. Without a prefix,
matching is loose (e.g., [] matches both list and tuple). With a prefix, only the
specified type matches.
| Prefix | Type | Bracket |
|---|---|---|
l |
list | l[...] |
t |
tuple | t[...] |
d |
dict | d{...} |
s |
set | s{...} |
fs |
frozenset | fs{...} |
Examples:
>>> d = {'a': [1, 2], 'b': (1, 2)}
# Unprefixed: matches both
>>> dotted.get(d, '*=[1, 2]')
([1, 2], (1, 2))
# Prefixed: strict type
>>> dotted.get(d, '*=l[1, 2]')
([1, 2],)
>>> dotted.get(d, '*=t[1, 2]')
((1, 2),)
Empty containers with prefixes:
>>> d = {'a': set(), 'b': frozenset(), 'c': []}
>>> dotted.get(d, '*=s{}')
(set(),)
>>> dotted.get(d, '*=fs{}')
(frozenset(),)
Note: Unprefixed {} matches dict (Python convention — {} is a dict, not a set).
Use s{} for empty set, fs{} for empty frozenset.
String glob patterns
String globs match string values by prefix, suffix, or substring using ... between
quoted fragments. They work in filter values and value guards:
| Form | Matches |
|---|---|
"hello"... |
Strings starting with "hello" |
..."world" |
Strings ending with "world" |
"hello"..."world" |
Starts with "hello", ends with "world" |
"a"..."b"..."c" |
Contains substrings in order |
"hello"...5 |
Starts with "hello", at most 5 more chars |
"a"...2:5"b" |
2-5 chars between "a" and "b" |
Examples:
>>> import dotted
>>> d = {'greeting': 'hello world', 'farewell': 'goodbye world', 'name': 'alice'}
# Prefix match
>>> dotted.get(d, '*="hello"...')
('hello world',)
# Suffix match
>>> dotted.get(d, '*=..."world"')
('hello world', 'goodbye world')
# Prefix + suffix
>>> dotted.get(d, '*="hello"..."world"')
('hello world',)
In filters:
>>> data = [{'name': 'user_alice'}, {'name': 'admin_bob'}, {'name': 'user_carol'}]
>>> dotted.get(data, '[*&name="user_"...]')
({'name': 'user_alice'}, {'name': 'user_carol'})
# Negated
>>> dotted.get(data, '[*&name!="user_"...]')
({'name': 'admin_bob'},)
String globs only match str values — not bytes. For bytes matching, use
bytes glob patterns.
Bytes glob patterns
Bytes globs are the bytes counterpart to string globs. Use b"..." on any
fragment to produce a bytes glob — all other quoted fragments are automatically
encoded to bytes:
| Form | Matches |
|---|---|
b"hello"... |
Bytes starting with b"hello" |
...b"world" |
Bytes ending with b"world" |
b"hello"...b"world" |
Starts with b"hello", ends with b"world" |
b"hello"..."world" |
Same — naked "world" auto-encoded to bytes |
"hello"...b"world" |
Same — naked "hello" auto-encoded to bytes |
b"hello"...5 |
Starts with b"hello", at most 5 more bytes |
If any fragment uses the b prefix, the entire glob becomes a bytes glob and
naked quoted strings are encoded to bytes automatically.
Examples:
>>> import dotted
>>> d = {'a': b'hello world', 'b': b'goodbye world'}
>>> dotted.get(d, '*=b"hello"...')
(b'hello world',)
>>> dotted.get(d, '*=b"hello"..."world"')
(b'hello world',)
In filters:
>>> data = [{'data': b'user_alice'}, {'data': b'admin_bob'}]
>>> dotted.get(data, '[*&data=b"user_"...]')
({'data': b'user_alice'},)
Bytes globs only match bytes values — never str.
Value groups
Value groups express disjunction (OR) over filter values. Use (val1, val2, ...)
to match any of several alternatives:
>>> import dotted
>>> d = {'a': 1, 'b': 2, 'c': 3, 'd': 4}
>>> dotted.get(d, '*=(1, 3)')
(1, 3)
Alternatives can be any value type — strings, numbers, regex, string globs, *,
None, True/False, or containers:
>>> data = [
... {'name': 'user_alice'},
... {'name': 'admin_bob'},
... {'name': 'guest_carol'},
... ]
>>> dotted.get(data, '[*&name=("user_"..., /admin_.*/)]')
({'name': 'user_alice'}, {'name': 'admin_bob'})
Use != for negation — matches values not in the group:
>>> d = {'a': 1, 'b': 2, 'c': 3, 'd': 4}
>>> dotted.get(d, '*!=(1, 3)')
(2, 4)
In filters:
>>> data = [{'status': 1}, {'status': 2}, {'status': 3}]
>>> dotted.get(data, '[*&status=(1, 2)]')
({'status': 1}, {'status': 2})
>>> dotted.get(data, '[*&status!=(1, 2)]')
({'status': 3},)
Note: Value groups (val1, val2) in value position are different from path
grouping (a,b) in path position. Value groups appear after = or !=.
Dotted filter keys
Filter keys can contain dotted paths to filter on nested fields:
>>> d = {
... 'items': [
... {'user': {'id': 1, 'name': 'alice'}, 'value': 100},
... {'user': {'id': 2, 'name': 'bob'}, 'value': 200},
... ]
... }
>>> dotted.get(d, 'items[user.id=1]')
[{'user': {'id': 1, 'name': 'alice'}, 'value': 100}]
>>> dotted.get(d, 'items[user.name="bob"][0].value')
200
Slice notation in filter keys
Filter keys can include slice notation so the comparison applies to a slice of the field value (prefix, suffix, or any slice). Use the same slice syntax as in paths: integers and + for start/stop/step.
>>> data = [
... {'name': 'hello world', 'file': 'app.py'},
... {'name': 'hi', 'file': 'readme.md'},
... {'name': 'hello', 'file': 'x.py'},
... ]
>>> dotted.get(data, '[*&name[:5]="hello"]')
({'name': 'hello world', 'file': 'app.py'}, {'name': 'hello', 'file': 'x.py'})
>>> dotted.get(data, '[*&file[-3:]=".py"]')
({'name': 'hello world', 'file': 'app.py'}, {'name': 'hello', 'file': 'x.py'})
Transforms
You can optionally add transforms to the end of dotted notation. These will
be applied on get and update. Transforms are separated by the | operator
and multiple may be chained together. Transforms may be parameterized using
the : operator.
>>> import dotted
>>> d = [1, '2', 3]
>>> dotted.get(d, '[1]')
'2'
>>> dotted.get(d, '[1]|int')
2
>>> dotted.get(d, '[0]|str:number=%d')
'number=1'
You may register new transforms via either register or the @transform
decorator.
Built-in Transforms
| Transform | Parameters | Description |
|---|---|---|
str |
fmt, raises |
Convert to string. Optional format: |str:Hello %s |
int |
base, raises |
Convert to int. Optional base: |int:16 for hex |
float |
raises |
Convert to float |
decimal |
raises |
Convert to Decimal |
none |
values... | Return None if falsy or matches values: |none::null:empty |
strip |
chars, raises |
Strip whitespace or specified chars |
len |
default |
Get length. Optional default if not sized: |len:0 |
lowercase |
raises |
Convert string to lowercase |
uppercase |
raises |
Convert string to uppercase |
add |
rhs, raises |
Add value: |add:10 |
sub |
rhs, raises |
Subtract value: |sub:3 |
mul |
rhs, raises |
Multiply: |mul:2 |
div |
rhs, raises |
Divide: |div:4 |
mod |
rhs, raises |
Modulo: |mod:3 |
pow |
rhs, raises |
Power: |pow:2 |
neg |
raises |
Negate: |neg |
abs |
raises |
Absolute value: |abs |
round |
ndigits, raises |
Round: |round or |round:2 |
ceil |
raises |
Ceiling: |ceil |
floor |
raises |
Floor: |floor |
min |
bound, raises |
Clamp to upper bound: |min:100 |
max |
bound, raises |
Clamp to lower bound: |max:0 |
eq |
rhs, raises |
Equal: |eq:5 → True/False |
ne |
rhs, raises |
Not equal: |ne:5 → True/False |
gt |
rhs, raises |
Greater than: |gt:5 → True/False |
ge |
rhs, raises |
Greater or equal: |ge:5 → True/False |
lt |
rhs, raises |
Less than: |lt:5 → True/False |
le |
rhs, raises |
Less or equal: |le:5 → True/False |
in |
rhs, raises |
Membership: |in:[1, 2, 3] → True/False |
not_in |
rhs, raises |
Negative membership: |not_in:[1, 2, 3] → True/False |
list |
raises |
Convert to list |
tuple |
raises |
Convert to tuple |
set |
raises |
Convert to set |
By default, transforms return the original value on error. The raises parameter
causes the transform to raise an exception instead:
>>> import dotted
>>> dotted.get({'n': 'hello'}, 'n|int') # fails silently
'hello'
>>> dotted.get({'n': 'hello'}, 'n|int::raises') # raises ValueError
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'hello'
Math transforms can be chained for inline computation:
>>> dotted.get({'n': -3.14159}, 'n|abs|round:2')
3.14
>>> dotted.get({'n': 15}, 'n|max:0|min:10')
10
Comparison and membership transforms return True/False:
>>> dotted.get({'n': 5}, 'n|gt:3')
True
>>> dotted.get({'n': 2}, 'n|in:[1, 2, 3]')
True
Container transform arguments
Transform parameters accept container literals in addition to scalars. These produce
raw Python values — no patterns (*, ..., /regex/) allowed in this context.
>>> import dotted
>>> @dotted.transform('lookup')
... def lookup(val, table):
... return table.get(val)
>>> dotted.get({'code': 'a'}, 'code|lookup:{"a": 1, "b": 2}')
1
Container arguments support type prefixes:
| Syntax | Produces |
|---|---|
[1, 2, 3] |
list |
t[1, 2, 3] |
tuple |
{"a": 1} |
dict |
{1, 2, 3} |
set |
fs{1, 2} |
frozenset |
[] |
[] (empty list) |
t[] |
() (empty tuple) |
{} |
{} (empty dict) |
s{} |
set() (empty set) |
Containers nest: {"a": [1, 2], "b": t[3, 4]} produces {'a': [1, 2], 'b': (3, 4)}.
Custom Transforms
Register custom transforms using register or the @transform decorator:
>>> import dotted
>>> @dotted.transform('double')
... def double(val):
... return val * 2
>>> dotted.get({'n': 5}, 'n|double')
10
View all registered transforms with dotted.registry().
Constants and Exceptions
ANY
The ANY constant is used with remove and update to match any value:
>>> import dotted
>>> d = {'a': 1, 'b': 2}
>>> dotted.remove(d, 'a', dotted.ANY) # remove regardless of value
{'b': 2}
>>> dotted.update(d, '-b', dotted.ANY) # inverted update = remove
{}
ParseError
Raised when dotted notation cannot be parsed:
>>> import dotted
>>> dotted.get({}, '[invalid') # doctest: +ELLIPSIS
Traceback (most recent call last):
...
dotted.api.ParseError: Invalid dotted notation: ...
CLI (dq)
The dq command lets you query and transform nested data from the command line,
similar to jq but using dotted notation.
Basic usage
echo '{"a": {"b": 1}}' | dq 'a.b'
# 1
echo '{"a": 1, "b": 2, "c": 3}' | dq -p a -p b
# {"a": 1, "b": 2}
Operations
get (default) — extract values or project fields:
echo '{"a": 1, "b": 2}' | dq -p a
echo '{"a": 1, "b": 2}' | dq get -p a
update — set values (each -p takes a path and value):
echo '{"a": 1, "b": 2}' | dq update -p a 42 -p b 43
# {"a": 42, "b": 43}
remove — delete paths (optionally conditional on value):
echo '{"a": 1, "b": 2}' | dq remove -p a
# {"b": 2}
echo '{"a": 1, "b": 2}' | dq remove -p a 1
# {"b": 2} (only removes if value is 1)
File input
Read from a file instead of stdin with -f:
dq -f data.json -p name
dq -f config.yaml -o json
When -f is used, the input format is auto-detected from the file extension
(.json, .jsonl, .ndjson, .yaml, .yml, .toml, .csv).
Use -i to override:
dq -f data.txt -i json -p name
Format conversion
Use -i and -o to specify input/output formats (json, jsonl, py, pyl, yaml, toml, csv):
cat data.yaml | dq -i yaml -o json -p name -p age
printf '{"a":1}\n{"a":2}\n' | dq -i jsonl -o csv -p a
echo "{'a': None, 'b': True}" | dq -i py -o json
Output format defaults to the input format. Without any paths, dq acts as
a pure format converter:
cat data.yaml | dq -i yaml -o json
Path files
Load paths from a file with -pf:
echo '{"a": 1, "b": 2}' | dq -pf paths.txt
Path file format (one path per line, # comments supported):
# paths.txt
a
b.c
For update, each line has a path and value:
# updates.txt
a 42
b.c "hello"
Projection
Multiple paths produce a projection preserving nested structure:
echo '{"a": {"x": 1, "y": 2}, "b": 3}' | dq -p a.x -p b
# {"a": {"x": 1}, "b": 3}
Unpack
Use --unpack to flatten the result to dotted normal form. Lists of dicts
are fully expanded with indexed paths:
echo '{"users": [{"name": "alice", "age": 30}, {"name": "bob", "age": 25}]}' | dq --unpack
# {"users[0].name": "alice", "users[0].age": 30, "users[1].name": "bob", "users[1].age": 25}
--unpack works with any operation:
echo '{"a": {"x": 1}, "b": {"c": 2}}' | dq --unpack update -p a.x 99
# {"a.x": 99, "b.c": 2}
Add --unpack-attrs standard to include object attributes (excluding dunders),
or --unpack-attrs standard special for all attributes.
Pack
Use --pack to rebuild nested structure from dotted normal form, including
indexed lists:
echo '{"users[0].name": "alice", "users[0].age": 30, "users[1].name": "bob"}' | dq --pack
# {"users": [{"name": "alice", "age": 30}, {"name": "bob"}]}
--pack and --unpack compose — pack rebuilds structure on input, unpack
flattens on output. This lets you use nested operations on dotted normal form. For
example, removing an entire group without listing every key:
echo '{"db.host": "localhost", "db.port": 5432, "app.debug": true}' | dq --pack --unpack remove -p db
# {"app.debug": true}
FAQ
Why do I get a tuple for my get?
get() returns a single value for a non-pattern path and a tuple of values for a pattern path. A path is a pattern if:
- Any path segment is a pattern (e.g. wildcard
*, regex), or - The path uses path grouping: disjunction
(a,b), conjunction(a&b), or negation(!a).
Filters (e.g. key=value, *=None) can use patterns but do not make the path a pattern; only the path segments and path-level operators do. So name.first&first=None is non-pattern (single value), while name.*&first=None is pattern (tuple), even though both can express "when name.first is None." Value guards (e.g. name.first=None) also preserve the pattern/non-pattern status of the underlying path segment.
For update and remove you usually don't care: the result is the (possibly mutated) object either way. For get, the return shape depends on pattern vs non-pattern. Use dotted.is_pattern(path) if you need to branch on it. Similarly, dotted.is_template(path) tells you whether a path contains substitution references.
How do I craft an efficient path?
Same intent can be expressed in more or less efficient ways. Example: "match when name.first is None"
- Inefficient:
name.*&first=None— pattern path; iterates every key undername, then filters. No short-circuit. - Better:
name.*&first=None?— same path with first-match?; stops after one match. - Even better:
name.first=None— value guard; non-pattern path; goes straight toname.firstand tests the value directly. - Also good:
name.first&first=None— non-pattern path with a concrete filter key.
Prefer a concrete path when it expresses what you want; use pattern + ? when you need multiple candidates but only care about the first match.
Why do I get a RuntimeError when updating with a slice filter?
A slice filter like [id=1] returns the filtered sublist as a single value — it operates on the list itself, not on individual items. Updating that sublist in place is ambiguous: the matching items may be at non-contiguous indices, so there's no clean way to splice a replacement back into the original.
But you probably don't want to update a slice filter anyway — instead, use a pattern like [*&id=1] which walks the items individually and updates each match in place.
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 dotted_notation-0.42.7.tar.gz.
File metadata
- Download URL: dotted_notation-0.42.7.tar.gz
- Upload date:
- Size: 218.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2be7342bba318cb857e8d79678af7c92c581d3918b8abcd3a7887dc1168b56e6
|
|
| MD5 |
12df765bec7b4dc0880ae1fe915a649e
|
|
| BLAKE2b-256 |
eefd20738361a8daad467946b6505b42f8a1d3c25677ec01b29f663bba7a9350
|
File details
Details for the file dotted_notation-0.42.7-py3-none-any.whl.
File metadata
- Download URL: dotted_notation-0.42.7-py3-none-any.whl
- Upload date:
- Size: 180.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.11.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
186c74936fd6c2ba2c3102fa57af6907bb192b318ad14878f778c57421459d3e
|
|
| MD5 |
53736d9ed909f69f0645e1db2a2e0dd2
|
|
| BLAKE2b-256 |
132c583fc0f5398dc05853e9655c526139b7109aae71460a250826d78fe73773
|