Skip to main content

mushroomdb (Python)

Python bindings for mushroomdb — the embedded graph database where edges are declared, not inserted.

import mushroomdb

db = mushroomdb.GraphDb.open("./db")
db.insert_node("Org", "org-01", {"founded_year": 2010})

GraphDb.open creates the directory if it does not exist. The handle is also a context manager, so with mushroomdb.GraphDb.open("./db") as db: closes on exit.

Writing nodes

db.insert_node("Person", "alice", {"team": "red"})   # raises if 'alice' exists
db.upsert_node("Person", "alice", {"team": "blue"})  # "inserted" or "updated"
db.set_prop("alice", "team", "green")
db.set_prop("alice", "team", None)                   # same as remove_prop
db.remove_prop("alice", "team")                      # False if already absent
report = db.delete_node("alice")                     # {"manual_edges", "derived_edges"}

upsert_node writes only the fields you pass whose value differs from the stored one. Fields you omit are left alone, and unchanged fields produce no WAL record, so rules do not re-fire needlessly. An existing key under a different label raises ValueError — relabelling is not an upsert.

To change properties on many nodes, use one call rather than one per node:

db.set_props_many([("alice", {"score": 3}), ("bob", {"score": 5, "stale": None})])
# {"nodes": 2, "props_set": 2, "props_removed": 1}

It is one commit: unchanged values write nothing, an unknown key raises KeyNotFound and nothing is written, and rules fire on what changed inside that commit. wal_total_commits() moves by one frame, or by two when a rule derived or retracted an edge — never by the number of nodes.

It is a raw property write, like set_prop. On an entity node, name and the two alias lists are maintained by upsert_entity and remember: a name written here is not reflected in aliases until the next describing write.

Querying

query and query_write both accept parameters as a dict, as a list of (name, value) tuples, or not at all. Parameters are bound, never interpolated into the Cypher string.

rows = db.query(
    "MATCH (n:Person) WHERE n.age > $min RETURN key(n) AS id",
    {"min": 18},
)
db.query_write(
    "MATCH (n:Person) WHERE key(n) = $k SET n.age = 31 RETURN key(n)",
    {"k": "alice"},
)

n.key / n.id read the node key (a stored property of the same name wins). key(n) / id(n) always return the id-map key. node_info returns the key too.

Scoping: who may see what

Bind the scope to the handle, not to the call. scoped() returns a read-only child that applies the scope to every read:

reader = db.scoped(role="reader-a")
tenant = db.scoped(namespace="tenant-a", keys=visible_ids)

It shares the parent's store and mutex — not a second open, no second lock — and refuses every write. Legs intersect, so scoped() on a scoped handle narrows further and can never widen; an unknown role raises at scoped(), not on the first read. refresh() is allowed: it writes nothing.

A read that answers about the whole store takes no mask, so a scoped handle refuses it with ValueError rather than narrow it: pagerank, connected_components, degree_centrality, communities, search, fulltext_pairs, rules, suggest_rules, recall, schema_report, identity_clusters and roles. is_fulltext_enabled answers: it is a schema fact about a pair you named.

Every read obeys one contract: the subject is checked first, so a key outside the scope is indistinguishable from a key that does not exist. node_info answers None, node_edges raises KeyNotFound, degree counts only visible neighbours, find_similar scores only visible candidates. Hidden is absent, never "hidden".

The per-call arguments are the same idea, narrowed for one call rather than for a caller:

Argument scoped() form
role= (a role from roles.json) scoped(role=…)
namespace= scoped(namespace=…)
mask= (an explicit key allow-list) scoped(keys=…)
visible_where in roles.json carried inside role=

None of them is deprecated — they are the right tool when the narrowing really does belong to one call.

where= is not one of them. It is a predicate on the data, not on the caller: an argument this call chose, filtering a result set the caller was already entitled to see. A role's visible_where takes the same dict shape and is the opposite thing — part of who the caller is, not passed by them. Do not use where= as an access control. It composes with a scope, intersecting.

db.roles() lists what roles.json defines. It raises Corrupt if that file was corrupt at open, rather than answering [] as an unrestricted store does.

Full detail: masks.md.

Scores are cosine similarity in [-1, 1] (score >= min). A distance of 1 - sim is the caller's conversion — on pairwise_similar too. Convert after the call rather than baking a distance threshold into min.

mask= alone is the approximate path. exact=True and a where= predicate each force an exact GEMM brute-force. A mask= allow-list — and a scoped() handle — do not: they narrow which nodes may be returned without changing which kernel runs, so the answer stays approximate whenever an HNSW rule covers the field. Under a mask the beam widens until it has k visible hits, so the result is never short while more visible hits exist, but it is still not guaranteed to be the true top k. For an exhaustive answer over the visible set, pass exact=True alongside the mask.

hits = db.find_similar("embedding", query_vec, mask=visible)              # approximate
hits = db.find_similar("embedding", query_vec, mask=visible, exact=True)  # exact

When no approximate rule covers the field — check with has_vector_rule("embedding") — every call is already an exact brute-force scan.

min defaults to 0.8 — here, in the MCP find_similar tool and on HTTP POST /find_similar. Through 0.6 this binding and HTTP defaulted to 0.0: a call that never named min now drops every hit below 0.8, and nothing raises. Pass min=0.0 for the old behaviour. pairwise_similar still defaults to 0.0.

where= uses the property index only when a label accompanies it. The index is keyed on (label, field), so both label= and a prior enable_index(label, field) are needed for the index path. Without them the call scans the labelled set, or the whole live set when there is no label — correct, just slower, and nothing tells you at runtime which path you took.

db.enable_index("Document", "status")
hits = db.find_similar(                                 # index path
    "embedding", query_vec, k=10, min=0.0,
    label="Document",
    where={"field": "status", "eq": "published"},
)
pairs = db.pairwise_similar(["a", "b", "c"], "embedding", k=5)
# exact per-key top-k among `keys`; self excluded; no HNSW

Degree

Adjacency is a set; degree is unique-neighbour count ("both" is out+in sum). Distinct from degree_centrality and from Degree views.

db.degree("alice", direction="both")
db.degrees(keys=["alice", "bob"], limit=10)  # degree desc, key asc

Rules

db.create_rule({
    "name": "same_team",
    "src_label": "Person",
    "dst_label": "Person",
    "predicate": {"kind": "field_equal", "fields": ["team"]},
    "edge_type": "SAME_TEAM",
    "weight_prop": None,
})

The canonical predicate shape is snake_case — {"kind": ..., "fields": [...]} plus whatever numeric knob the kind takes (min, tolerance, km, or parts for all/any). This is exactly the shape explain emits, so an explanation round-trips straight back into a new rule:

why = db.explain("alice", "bob")
clone = {**base, "name": "same_team_clone", "predicate": why[0]["predicate"]}
db.create_rule(clone)
kind extra keys
key_match, field_equal —
overlap, vector_similar min
numeric_within tolerance
geo_radius km
all, any parts (a list of nested predicates)

The Rust-native externally-tagged form is still accepted: {"FieldEqual": {"field": "team"}}, {"Overlap": {"field": "skills", "min": 0.5}}.

create_rule returns True when it created the rule. Pass if_not_exists=True to get False instead of an exception when a rule of that name is already registered.

Listing, deleting, rebuilding, proposing

db.rules()                    # every rule, as dicts create_rule accepts
db.delete_rule("same_team")   # and every edge it derived
db.rebuild_rule("same_team")  # the way out of a tripped rule
for s in db.suggest_rules()["suggestions"]:
    print(s["name"], s["est_edges"], s["rationale"])
    # db.create_rule(s["create_rule_args"])  — nothing is created until you do

suggest_rules() never proposes a rule over a field the store writes for itself, and each proposal's create_rule_args creates the same rule here as it does through the MCP create_rule tool.

db.enable_fulltext("Doc", "body", if_not_exists=True)
db.search("body", 'graph OR "property index"', k=10)   # [(key, score), ...]
db.fulltext_pairs()                                    # [("Doc", "body")]
db.is_fulltext_enabled("Doc", "body")                  # True
db.disable_fulltext("Doc", "body")                     # the index and its postings

search is keyed by field alone: two labels indexed on the same field name are searched together. Every declared pair is rebuilt when the store opens.

Graph algorithms

db.pagerank(edge_type="CITES")["scores"][:10]
db.connected_components()["components"]         # [(key, component), ...]
db.degree_centrality(direction="in")["scores"]  # not degree() / degrees()
db.communities(edge_types=["CITES"])["communities"]

budget_ms defaults to 0 — no time limit — so the same store gives the same answer; pass one to bound a call and read converged / truncated. degree() and degrees() are a different thing: the degree of the keys you name, which can be scoped and filtered.

None of these runs on a scoped() handle. The algorithms, search, fulltext_pairs, rules and suggest_rules answer about the whole store and take no mask, so each raises ValueError there rather than return an answer computed over nodes the scope hides.

Memory

The MCP memory tools, as data. Each calls the function the tool calls and returns its report as a dict rather than a rendered digest.

r = db.remember(
    "Matthew is driving the 0.7 release",
    about=["matthew", "v0.7"],                           # matthew is unknown → a provisional stub
    entities=[{"key": "v0.7", "label": "Release", "props": {"name": "v0.7"}}],
    facts=[{"subject": "matthew", "predicate": "WORKS_ON", "object": "v0.7"}],
)
r["note"], r["provisional"]           # "note:…", ["matthew"]

db.recall("who is driving the release")["hits"]          # ranked rows, not text
db.upsert_entity("matthew", {"name": "Matthew Sherlin"}, label=None, aliases=["Matt"])
db.schema_report()["provisional"]                        # 0 — it has been described
db.forget(key="v0.7")["notes"]                           # ["note:…"] — the note about it, still there
db.identity_clusters()["clusters"]                       # which keys are one entity
  • upsert_entity is not upsert_node: it keeps aliases (derived from the current key and name, recomputed each time) and alias_keys (the aliases you declare, as written, accumulating), sets id, clears a provisional mark, and refuses to change a label. Its same_as_lost names an identity link the update retracted.
  • The memory module's refusals raise IngestError; .detail is the sentence, and the message carries an ingest error: prefix in front of it.
  • remember's report names the note under note, not key.
  • recall returns raw stored content. key, label and summary are unsanitized; only the MCP tool's rendered digest replaces control characters, line separators and bidi or zero-width characters. Rendering a row into an assistant's context makes that sanitisation yours to do, and this binding exposes no helper for it. recall does not offer the digest.
  • forget is a tombstone, not a redaction: history still reads it. A fact a rule derived raises RuleOwned, whose message is the whole refusal — which rule owns the edge and the fields it reads.
  • A store this binding creates has no memory schema. remember declares the text fields it needs as it goes; the identity preset is applied from the command line — mushroomdb schema apply <db> --memory-identity — with the handle closed.
  • recall, schema_report and identity_clusters raise ValueError on a scoped() handle; remember, upsert_entity and forget are writes and raise ReadOnly there.

Concurrency

One writer at a time across processes; readers see commits after refresh(). The store carries an advisory write lock. A read-write handle holds it for as long as it is open, so opening a second one anywhere on the machine raises MushroomBusy rather than letting two writers corrupt the store:

from mushroomdb import GraphDb, MushroomBusy

try:
    db = GraphDb.open("./db")
except MushroomBusy:
    ...  # another process is writing; nothing was changed, retry later

Note where that refusal lands: a plain handle takes the lock at open, so MushroomBusy is raised by GraphDb.open, not by the first write. Nothing was written, so retrying later is always safe.

A handle does not poll the store, so another process's commits stay invisible until you ask for them. refresh() applies them in place and returns how many arrived — no close() and reopen:

n = db.refresh()   # rules fire and derived edges appear, as on a fresh open

A refresh() that finds nothing new costs two filesystem metadata calls and an integer compare, reading no file contents at all, so polling on an interval is cheap. Open a reader once and refresh it; do not reopen per request.

Readers never take the lock. read_only=True opens immediately even while a writer holds it, writes nothing to disk, raises ReadOnly on any mutation, and can still refresh() to follow the writer:

reader = GraphDb.open("./db", read_only=True)
reader.refresh()

A commit another process is midway through writing is left alone and picked up by the next refresh(); a partial write is never an error.

Keep a store that several processes open on a local disk. Advisory locks over a network filesystem — NFS, SMB, EFS — are not supported, and both the lock and the staleness check fail silently there. The supported arrangements, with a worked disposable-sidecar example, are in multiprocess.md.

Within one process the handle is guarded by a mutex, so calls from multiple threads are serialized and safe. They are not isolated transactions: readers can observe intermediate states while a batch is being applied.

Errors

There is an exception class for each engine error. MushroomError(RuntimeError) is the base and eighteen subclasses hang off it — KeyNotFound, ReadOnly, Corrupt, CasConflict, RoleWriteDenied and the rest — each carrying .code, a stable snake_case string, and the variant's own fields as attributes. .code is the compatibility surface: classes may be added, a code is never respelled. Because the base subclasses RuntimeError, every except RuntimeError written against an earlier release keeps catching what it caught. A test asserts every Rust GraphError variant maps to a distinct class, so adding one without a class fails the build. db.roles() reads back what roles.json defines, so a sidecar can validate a role name at boot rather than on the first request.

Type stubs

The wheel ships __init__.pyi and a py.typed marker, so mypy and Pyright pick up signatures without extra configuration.

Full documentation, the rules tour, and benchmarks live in the main repository.

Metadata

Release files for mushroomdb 0.8.2

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distributions (wheels)

Table of built distributions (wheels) for mushroomdb 0.8.2
File Interpreter ABI Platform
mushroomdb-0.8.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl CPython 3.10 abi3 Linux glibc 2.17+ x86-64 Details
mushroomdb-0.8.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl CPython 3.10 abi3 Linux glibc 2.17+ ARM64 Details
mushroomdb-0.8.2-cp310-abi3-macosx_11_0_arm64.whl CPython 3.10 abi3 macOS 11.0+ ARM64 Details

Total release size: 9.3 MB

Release files / mushroomdb-0.8.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

Download URL mushroomdb-0.8.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl
Size 3.3 MB
Tags CPython 3.10 Linux glibc 2.17+ x86-64 abi3
SHA-256 checksum
How to use checksums
190bd2a22bfb39f47daa45431a61124aed470640df617ec3d226fbc6475ae03f
BLAKE2b-256 checksum
How to use checksums
0b2040d4b9850c4a06eb65166b7bb668de00898ea83662dd3d1182b85982aca8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release files / mushroomdb-0.8.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

Download URL mushroomdb-0.8.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl
Size 3.1 MB
Tags CPython 3.10 Linux glibc 2.17+ ARM64 abi3
SHA-256 checksum
How to use checksums
40d20b5df0ecee7346f21dd164d9843dee44631f8203d83cfc0a4820b87a6803
BLAKE2b-256 checksum
How to use checksums
2385752ad5ebaab9e0f51605c3d7f3f1f1d19d6bf2a9d59efe7b82ad907cec3c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release files / mushroomdb-0.8.2-cp310-abi3-macosx_11_0_arm64.whl

Download URL mushroomdb-0.8.2-cp310-abi3-macosx_11_0_arm64.whl
Size 2.9 MB
Tags CPython 3.10 abi3 macOS 11.0+ ARM64
SHA-256 checksum
How to use checksums
efd14343ce8b380d4f205cde9fc5f4cee72043c3411b0642287762e8b2cfcb4b
BLAKE2b-256 checksum
How to use checksums
7dbe17b015ab7764378739a08f5b42c887919c19c98d5674b3ba2b42e571ecf0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 16, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.8.2 This release

3 release files

0.8.1

3 release files

0.8.0

3 release files

0.7.2

3 release files

0.7.1

3 release files

0.7.0

3 release files

0.6.12

3 release files

0.6.10

3 release files

0.6.9

3 release files

0.6.8

3 release files

0.6.7

3 release files

0.6.6

3 release files

0.6.5

3 release files

0.6.4

3 release files

0.6.3

3 release files

0.6.2

3 release files

0.6.1

3 release files

0.6.0

3 release files

0.5.2

3 release files

0.5.1

3 release files

0.5.0

3 release files

0.4.5

3 release files

0.4.4

3 release files

0.4.3

3 release files

0.4.2

3 release files

0.4.1

3 release files

0.4.0

3 release files

0.3.0

3 release files

0.2.0

15 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page