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)andupsert(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-LDwhere/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 exactlydata, committing only the difference;dry_run=Truecounts 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, andrefs=turns columns into links to other nodes.upsert_rowsreplaces existing values, to load a changed table again. A pandas integer column with gaps is a float column unless it uses the nullableInt64dtype.Named graphs are created by writing to them (TriG, SPARQL
GRAPH, or a JSON-LD node’s"@graph").ledger.graphs()lists them, andledger.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
SELECTand every Cypher query return aResultofRecords 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 haskeys(),value(key),values(),data(),to_pandas()(aliasto_df()) andto_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, orNone.An unbound variable, such as one an
OPTIONALdidn’t match, isNone, and its column is still in the record:r.get("desc", "")returnsNone.get’s default applies only to a column the result doesn’t have.Literals are Python values (
int,float,Decimal,datetime,str, …); IRIs arefluree.IRIand language-tagged stringsfluree.LangString, bothstrsubclasses. A literal with no lossless Python type stays afluree.Literal.Dates and times come back as Fluree stores them (temporal values): an
xsd:dateTimeis adatetimein UTC whatever offset it was written with, soisoformat()writes+00:00where the data saidZ; anxsd:dateorxsd:timehas no offset; and fractions of a second stop at the microsecond.SPARQL
ASKreturns abool,CONSTRUCTa JSON-LD document; JSON-LD queries (adict) return their JSON result as Python objects.select()isquery()for tables: it takes a SPARQLSELECTor a Cypher query and is typed to return aResult, so editors and type checkers know what comes back. Any other query is refused before it runs.ledger.stream(query)reads a largeSELECTrow by row in flat memory. Open it withwith 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, andexplain(query)shows the plan. Ctrl-C cancels a running query.conn.query(...)runs a query whoseFROMnames the ledgers, so it can span several.ledger.set_context({...})sets the default JSON-LD context: queries that omitPREFIXor@contextresolve prefixes against it.Parameters bind values by name, from a dict or keyword arguments, in
query(),update(),stream(),explain()andprofile(). In SPARQL a parameter replaces its variable (?nameor$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,LiteralorVector, a Pythonstr,int,float,bool,Decimal,datetime,dateortime, a CypherNode(itselement_id), or aBlankNodea query returned. ABlankNodebuilt from any other label raisesInvalidRequestError: 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"]
$nameparameters come from a dict or keyword arguments, as for SPARQL.Nodes, relationships and paths come back as
fluree.Node(properties read like a dict, pluslabelsandelement_id— the sameIRISPARQL returns for it),fluree.Relationship(type,start_node,end_node) andfluree.Path.A Cypher write runs through
update(); the records itsRETURNproduces are the commit’sresult. Insidewith 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 (
Personis<Person>).Not yet for Cypher:
stream(); read a large Cypher result withquery().
Search¶
Full-text and vector search run inside queries, so a search joins with any
other pattern and sees the same data: staged writes in a transaction, a past
t, a branch.
people.set_full_text(["ex:title", "ex:body"], language="en") # reindexes by default
people.query("""
SELECT ?doc ?score WHERE {
?doc ex:body ?body .
BIND(fulltext(?body, $q) AS ?score) FILTER(?score > 0)
} ORDER BY DESC(?score) LIMIT 10""", q="graph databases")
people.insert({"@context": ctx, "@id": "ex:doc1", "ex:embedding": fluree.Vector(model.encode(text))})
people.query("""
SELECT ?doc ?score WHERE {
?doc ex:embedding ?v .
BIND(dotProduct(?v, $q) AS ?score)
} ORDER BY DESC(?score) LIMIT 5""", q=query_embedding)
set_full_text(properties)makes the plain-string values of those properties searchable withfulltext(), analyzed inlanguage; language-tagged values use their own.full_text()reads the setting back. A property becomes searchable once an index build has seen values of it, so configure after loading data, or callreindex()after the first load.fluree.Vectorholds an embedding (stored as float32); a one-dimensional numpy array works anywhere aVectordoes, as a value or a parameter. Vectors come back from queries asVector, andnumpy.asarray(v)gives a float32 array.cosineSimilarity,dotProductandeuclideanDistancescore vectors;dotProductover normalized embeddings is the fastest.
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=...), eachrr:tableNamea 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 underroot, at locations intables, or through afluree.Unitycatalog. 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, andsource.drop()removes it (the tables are untouched).To join a source with a ledger, name the source with
FROM NAMEDand read it in aGRAPHblock (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 theFLUREE_ICEBERG_LOCAL_ROOTSenvironment 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"raisesfluree.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) andledger.reindex()manage indexing; queries never need it, but indexed data reads faster.ledger.verify()checks that the commit chain and index are intact, andledger.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.flpackarchive of the whole ledger, andconn.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(anInvalidRequestError): a write the ledger’s SHACL shapes reject.violationslists each failure as the sameValidationResultthatledger.validate()reports — focus node, path, value, constraint component and message.UniqueConstraintError(anInvalidRequestError): a value of anf:enforceUniqueproperty that another subject already holds;property,value,graph,existing_subjectandnew_subject.ConflictError: when a commit lost a race,expected_tandhead_tsay where the ledger was and where it moved to.DatabaseInUseError: the directory is open in another process;pidis 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.