Guide

Python package for Fluree, a graph database with time travel, history, and fine-grained policy. The engine runs in your Python process — there is no server to start. The API reference documents every class and method, and the Fluree documentation covers concepts, query languages and configuration; its queries run unchanged through ledger.query().

pip install fluree                # or "fluree[pandas]", "fluree[polars]"

Wheels are published for Linux (x86-64 and arm64, glibc 2.28 or later), macOS on Apple silicon, and Windows (x86-64), for CPython 3.10 and later — the platforms the Fluree CLI ships on. The package version is the version of the Fluree engine inside it.

import fluree

with fluree.connect("./data") as conn:          # or fluree.connect(":memory:")
    people = conn.create("people")

    people.insert("""
        @prefix ex: <http://example.org/> .
        ex:alice ex:name "Alice" ; ex:age 42 .
    """)
    commit = people.insert({
        "@context": {"ex": "http://example.org/"},
        "@id": "ex:bob", "ex:name": "Bob", "ex:age": 37,
    })

    rows = people.select("""
        PREFIX ex: <http://example.org/>
        SELECT ?name ?age WHERE { ?s ex:name ?name ; ex:age ?age } ORDER BY ?name
    """)
    for name, age in rows:
        print(name, age)              # Alice 42, then Bob 37 — native str and int
    df = rows.to_pandas()             # with pandas installed: pip install "fluree[pandas]"

    # Every past state stays queryable.
    before_bob = people.at(t=commit.t - 1)
    print(before_bob.query("PREFIX ex: <http://example.org/> ASK { ex:bob ?p ?o }"))  # False

Writing

insert, upsert and update each make one commit and return a Commit (t, id, digest, asserts, retracts).

  • insert(data) and upsert(data) take JSON-LD (a dict, list, or JSON text), Turtle or TriG text, or a path to a file.

  • update(txn) takes SPARQL UPDATE, a Cypher write (CREATE, MERGE, SET, DELETE, or a ; script of them, committed all or nothing), or JSON-LD where/delete/insert. The language is told from the text; language= overrides it.

  • message= on any of them is recorded with the commit; ledger.log() shows it.

  • sync(data, graph=None) makes the default graph, or a named graph, hold exactly data, committing only the difference; dry_run=True counts what would change. Handy for mirroring an export from another system.

  • insert_rows(rows, id="ex:person/{id}", type="ex:Person") loads a pandas or polars DataFrame (or a list of dicts), one node per row: columns become properties, missing values (None, NaN, NaT) add nothing, and refs= turns columns into links to other nodes. upsert_rows replaces existing values, to load a changed table again. A pandas integer column with gaps is a float column unless it uses the nullable Int64 dtype.

  • Named graphs are created by writing to them (TriG, SPARQL GRAPH, or a JSON-LD node’s "@graph"). ledger.graphs() lists them, and ledger.drop_graph(iri) retracts one’s contents in a single commit.

A property value in a JSON-LD dict may be any value a query returns, and reads back as it went in: a fluree.IRI or BlankNode is a reference to that node, a LangString keeps its tag, a Literal its datatype, and a Decimal, datetime, date or time its XSD type. fluree.Vector and numpy arrays are embedding vectors. The same values work as query parameters and in JSON-LD where patterns. Keyword entries (@id, @type, @context) take plain strings.

To make several writes one commit, use a transaction. Each write applies over the ones before it and is checked as it is staged; queries on the transaction read the staged state, which no one else sees until it commits:

with people.transaction(message="onboard bob") as txn:
    txn.insert({...})
    txn.update("DELETE { ... } INSERT { ... } WHERE { ... }")   # sees the insert
    txn.query("SELECT ...")
print(txn.committed)     # the Commit; an exception in the block rolls back

A write that fails (bad syntax, denied by policy, a SHACL violation) raises at once and is left out, all of it: a Cypher script that fails part way leaves none of its statements staged. The commit holds only the net change.

If another commit lands first, a transaction that only wrote is staged again on top of it (each update’s WHERE matches the new data). One that was also read raises fluree.ConflictError instead, since what it read may have decided what it wrote. To read and then write safely, hand the work to transact, which runs it again on a conflict:

def birthday(txn):
    (age,) = txn.query("SELECT ?age WHERE { ex:alice ex:age ?age }")[0]
    txn.upsert({"@id": "ex:alice", "ex:age": age + 1})

people.transact(birthday)

Querying

query() takes SPARQL, Cypher, or JSON-LD — told apart by how the query opens, or set with language= — and reads the latest state. ledger.snapshot() and ledger.at(t=..., time=..., commit=...) return a frozen view that every query sees identically.

  • SPARQL SELECT and every Cypher query return a Result of Records in the query’s column order. A record unpacks like a tuple and reads by position, key, or attribute (r[0], r["name"], r.name); the result has keys(), value(key), values(), data(), to_pandas() (alias to_df()) and to_polars(), the Neo4j driver’s vocabulary. In Jupyter a result shows as a table. single() returns the one record and raises unless there is exactly one; first() returns the first record, or None.

  • An unbound variable, such as one an OPTIONAL didn’t match, is None, and its column is still in the record: r.get("desc", "") returns None. get’s default applies only to a column the result doesn’t have.

  • Literals are Python values (int, float, Decimal, datetime, str, …); IRIs are fluree.IRI and language-tagged strings fluree.LangString, both str subclasses. A literal with no lossless Python type stays a fluree.Literal.

  • Dates and times come back as Fluree stores them (temporal values): an xsd:dateTime is a datetime in UTC whatever offset it was written with, so isoformat() writes +00:00 where the data said Z; an xsd:date or xsd:time has no offset; and fractions of a second stop at the microsecond.

  • SPARQL ASK returns a bool, CONSTRUCT a JSON-LD document; JSON-LD queries (a dict) return their JSON result as Python objects.

  • select() is query() for tables: it takes a SPARQL SELECT or a Cypher query and is typed to return a Result, so editors and type checkers know what comes back. Any other query is refused before it runs.

  • ledger.stream(query) reads a large SELECT row by row in flat memory. Open it with with ledger.stream(query) as rows: when the loop may stop early: leaving the block stops the query.

  • query(..., max_fuel=..., timeout=...) bounds a query’s work and time; profile(query) reports what it cost, and explain(query) shows the plan. Ctrl-C cancels a running query.

  • conn.query(...) runs a query whose FROM names the ledgers, so it can span several.

  • ledger.set_context({...}) sets the default JSON-LD context: queries that omit PREFIX or @context resolve prefixes against it.

  • Parameters bind values by name, from a dict or keyword arguments, in query(), update(), stream(), explain() and profile(). In SPARQL a parameter replaces its variable (?name or $name) wherever it appears, as if the value were written there:

    people.query("SELECT ?s WHERE { ?s ex:name $name ; ex:age ?age FILTER(?age > $min) }",
                 name="Alice", min=21)
    

    A value is an IRI, LangString, Literal or Vector, a Python str, int, float, bool, Decimal, datetime, date or time, a Cypher Node (its element_id), or a BlankNode a query returned. A BlankNode built from any other label raises InvalidRequestError: written in the query, it would match every node. So does a parameter the query never mentions, rather than leaving a misspelt variable unbound. JSON-LD queries take none.

Cypher

result = people.query(
    "MATCH (p:Person {name: $name})-[r:KNOWS]->(friend) RETURN p, r, friend.name AS friend",
    name="Alice",
)
for record in result:
    record.friend, record["p"]["age"], record["r"].type

commit = people.update("CREATE (p:Person {name: $name}) RETURN p", name="Eve")
commit.result.single()["p"]
  • $name parameters come from a dict or keyword arguments, as for SPARQL.

  • Nodes, relationships and paths come back as fluree.Node (properties read like a dict, plus labels and element_id — the same IRI SPARQL returns for it), fluree.Relationship (type, start_node, end_node) and fluree.Path.

  • A Cypher write runs through update(); the records its RETURN produces are the commit’s result. Inside with people.transaction() as txn:, txn.update(...) stages Cypher alongside SPARQL and JSON-LD writes, each seeing the others, in one commit.

  • Cypher and SPARQL see the same data: Cypher’s names are bare IRIs (Person is <Person>).

  • Not yet for Cypher: stream(); read a large Cypher result with query().

Graph sources

Tables in Iceberg, Delta Lake, or a SQL engine can be queried in place, mapped to RDF by an R2RML mapping, without copying them into a ledger:

from pathlib import Path

people = conn.map_iceberg("people", Path("people.ttl"),
                          table_location="s3://lake/silver/people")
sales = conn.map_delta("sales", Path("sales.ttl"), root="s3://lake/Tables")
crm = conn.map_sql("crm", "https://trino.example.com", Path("crm.ttl"),
                   catalog="pg", schema="public",
                   auth=fluree.OAuth2(token_url, client_id,
                                      fluree.EnvVar("TRINO_SECRET")))

people.select("PREFIX ex: <http://example.org/> SELECT ?name WHERE { ?p ex:name ?name }")
  • The mapping is Turtle text or a path to a Turtle file. Give each object map an rr:datatype: a column without one reads back as a string.

  • An Iceberg table is read directly from table_location, or through a catalog: a REST catalog (catalog_uri=, warehouse=, auth=), an AWS Glue Data Catalog (glue=fluree.Glue(region=...), each rr:tableName a Glue <database>.<table>), or an AWS S3 Tables table bucket (s3_tables=fluree.S3Tables(table_bucket_arn)). Glue and S3 Tables read with the usual AWS environment. Delta tables are found under root, at locations in tables, or through a fluree.Unity catalog. A SQL source pushes queries to any Trino-protocol endpoint.

  • Secrets can be fluree.EnvVar("NAME"), read where the tables are read, rather than stored with the source.

  • conn.graph_sources() lists them, conn.graph_source(name) finds one, and source.drop() removes it (the tables are untouched).

  • To join a source with a ledger, name the source with FROM NAMED and read it in a GRAPH block (JSON-LD: "fromNamed" and ["graph", ...]): SELECT ... FROM <crm:main> FROM NAMED <people:main> WHERE { ... GRAPH <people:main> { ... } }.

  • people.materialize("people-copy") copies an Iceberg source into a ledger, reading only what is new on each later pass.

  • Local tables (file://...) must lie under a directory named in the FLUREE_ICEBERG_LOCAL_ROOTS environment variable, set before the process first reads one; they are not yet supported on Windows.

History

  • ledger.history(subject, predicate=None, from_t=1) lists every assertion and retraction of a subject, oldest first.

  • ledger.log() lists commits, newest first; ledger.changes(commit) lists what one commit asserted and retracted.

Branches

A branch is a ledger that shares another’s history up to the point it was created, then changes on its own. Branch ids are ledger:branch; a ledger’s first branch is main.

dev = people.branch("dev")                  # or people.at(t=5).branch("fix")
dev.insert({...})

preview = people.merge_preview("dev")       # ahead/behind commits, conflicts
if preview.mergeable:
    people.merge("dev")                     # bring dev's commits into main
  • ledger.branches() lists a ledger’s branches; conn.drop("people:dev") drops one.

  • ledger.merge(source, strategy=...) settles properties both branches changed: "take-both" (default) keeps both values, "take-source" or "take-branch" picks a side, "abort" raises fluree.ConflictError. merge_preview(...) reports what a merge would do, including the net changes (changes=True) and what each side wrote to each conflict (details=True).

  • branch.rebase() replays a branch’s own commits on top of the latest commit of the branch it came from.

  • ledger.revert(commits) undoes one or more commits in a new commit; revert_preview(...) checks first.

Policy

ledger.with_policy(identity=..., policy_class=..., policy=..., values=..., default_allow=...) returns a governed handle: reads through it are filtered by policy, and a write the policy does not allow raises fluree.PermissionDeniedError. History and commit contents are filtered the same way.

Policy belongs to the handle. A query or write on a ledger that names its own policy — JSON-LD "opts": {"identity": ...} or a SPARQL # PRAGMA identity — raises fluree.InvalidRequestError, so a query passed in from elsewhere can never widen what a governed handle sees.

Maintenance

  • ledger.validate() checks the data against the ledger’s SHACL shapes — or other shapes passed in — and returns a report, changing nothing.

  • ledger.index_status(), ledger.index() (index now and wait) and ledger.reindex() manage indexing; queries never need it, but indexed data reads faster.

  • ledger.verify() checks that the commit chain and index are intact, and ledger.sweep() deletes index files no index references any more.

Backup and export

  • ledger.export(path, format=...) writes Turtle, TriG, N-Triples, N-Quads or JSON-LD (or returns text when no path is given); ledger.at(...).export() exports a past state.

  • ledger.archive(path) writes a .flpack archive of the whole ledger, and conn.restore(path, name) loads one back.

rdflib

rdflib reads formats Fluree does not, such as RDF/XML and N3, and is what tools like pySHACL take and return. N-Triples moves a graph between the two without loss:

import rdflib

g = rdflib.Graph().parse("ontology.owl", format="xml")
ledger.insert(g.serialize(format="nt"), format="turtle")       # rdflib → Fluree

g = rdflib.Graph().parse(data=ledger.export(format="ntriples"), format="nt")

Storage

fluree.connect(path) stores data in a directory. For S3 or split commit/index storage, a DynamoDB nameservice, or encryption at rest, pass a JSON-LD connection document instead: fluree.connect(config={...}) — the same document fluree server takes with --connection-config.

Errors

Every error is a fluree.FlureeError, and also the matching builtin where one fits: NotFoundError is a LookupError, InvalidRequestError a ValueError, PermissionDeniedError a PermissionError, QueryTimeoutError a TimeoutError.

Some errors carry what the engine knows, so an application can act on them without reading the message:

  • ShaclViolationError (an InvalidRequestError): a write the ledger’s SHACL shapes reject. violations lists each failure as the same ValidationResult that ledger.validate() reports — focus node, path, value, constraint component and message.

  • UniqueConstraintError (an InvalidRequestError): a value of an f:enforceUnique property that another subject already holds; property, value, graph, existing_subject and new_subject.

  • ConflictError: when a commit lost a race, expected_t and head_t say where the ledger was and where it moved to.

  • DatabaseInUseError: the directory is open in another process; pid is that process’s id when it could be read.

try:
    people.insert(person)
except fluree.ShaclViolationError as e:
    for v in e.violations:
        print(v.focus, v.path, v.message)

Writes are checked against the ledger’s SHACL shapes where its config turns SHACL on, ledger-wide as here or per graph (see SHACL defaults):

people.update("""
PREFIX f: <https://ns.flur.ee/db#>
INSERT DATA { GRAPH <urn:fluree:people:main#config> {
    <urn:fluree:people:main:config:ledger> a f:LedgerConfig ;
        f:shaclDefaults <urn:fluree:people:main:config:shacl> .
    <urn:fluree:people:main:config:shacl> f:shaclEnabled true .
} }""")

Logging

The engine’s log goes to Python’s logging as the fluree.engine logger, each record carrying the engine module that wrote it as record.target. Warnings and errors are sent by default; fluree.set_log_level("INFO") (or "DEBUG", "TRACE") sends more, and "OFF" none. Below the level the engine skips the events altogether. As with any library, what is shown is the application’s choice: nothing reaches the console until it configures logging (logging.basicConfig(), say).

asyncio

fluree.aio is the same API with coroutines, for FastAPI and other asyncio applications:

import fluree.aio

async with fluree.aio.connect("./data") as conn:
    people = await conn.ledger("people")
    rows = await people.query("SELECT ...")
    async for row in people.stream("SELECT ..."):
        ...
    async with people.transaction() as txn:
        await txn.insert({...})

Each call runs on a worker thread while the event loop carries on. Cancelling a task that awaits a query (asyncio.timeout, a client that disconnects) stops the query in the engine; a write already under way still completes. Cancelled inside async with ledger.transaction(), the block waits for that write to finish, rolls the transaction back, and re-raises the cancellation, unless that write was the commit itself, which completes; txn.committed says whether it did.

Concurrency

Engine calls release the GIL, so threads can query in parallel, and each thread’s writes land on the latest commit. A transaction that read the ledger raises ConflictError when another write lands before it commits; hand such work to transact, which runs it again.

One process owns a database directory. Every connection the process opens to it, from any thread, shares one engine, so each sees the others’ commits at once; the engine shuts down when the last of them closes. They must agree on indexing: opening the directory again with the other setting raises InvalidRequestError. Another process that opens the directory meanwhile raises DatabaseInUseError, which names the process holding it. To share a database across processes, run a Fluree server on it and send their requests to the server. ":memory:" connections and directories of their own have no such limit. A directory on a filesystem that cannot lock it (some network and FUSE mounts) fails to open unless FLUREE_STORAGE_ALLOW_UNLOCKED=true is set, which opens it without that protection.

A process forked after Fluree started in its parent cannot use it: the engine’s threads do not survive a fork, and the lock state they leave behind is unsafe to reuse, so the child raises FlureeError rather than risk a crash. A connection opened before the fork raises FlureeError in the child too, and is left alone when the child exits. The child does not keep the directory locked: once the parent closes it or exits, another process can open it. For a multiprocessing pool, use the spawn or forkserver start method (multiprocessing.get_context("spawn"); Python before 3.14 defaults to fork on Linux), and have each process that uses Fluree open a database of its own. Serve a web app from one worker process with threads or asyncio (gunicorn --workers 1 --threads 8, or one uvicorn worker): with several workers on one directory, all but the first raise DatabaseInUseError.

Development

Building from source needs a Rust toolchain:

uv venv && uv pip install maturin pytest pandas
maturin develop          # build the extension into the active environment
pytest

Releases are built and published by .github/workflows/python-release.yml from the same version tags as the CLI; see docs/contributing/releasing.md.

License

Licensed under the Business Source License 1.1, with a Change Date to Apache License 2.0 as specified in that file.

In short: you may use Fluree in production for anything except offering Fluree itself to others as a hosted or managed database. Building your own applications and data products on Fluree is permitted, including publishing a dataset you curate with a public, read-only query endpoint. See the License FAQ for details.