Skip to content

maplib

maplib turns Polars DataFrames into RDF knowledge graphs, and lets you query, validate, reason over, and federate them without ever leaving Python. It's written in Rust, ships with Polars bundled, and is built for performance on large, messy data.

The whole loop is small:

  1. Describe the shape of your triples with an OTTR template.
  2. Push a DataFrame through it with Model.map(...).
  3. Query, validate, reason, federate, and serialise, all in one session.

Install

pip install maplib
maplib bundles Polars. DataFrames passed to map(...) must be Polars (pl.DataFrame). Convert pandas with pl.from_pandas(df) first.


One running example: a band catalog

The whole page uses one small dataset, a catalog of bands, and grows it to show every part of maplib. Here it is:

import polars as pl

bands = pl.DataFrame({
    "name":    ["Pink Floyd", "Rush", "Deafheaven", "Mogwai"],
    "genre":   ["Prog Rock", "Prog Rock", "Blackgaze", "Post-Rock"],
    "country": ["United Kingdom", "Canada", "United States", "United Kingdom"],
    "formed":  [1965, 1968, 2010, 1995],
})

Map it to a graph and ask a question. The core loop is five lines:

from maplib import Model

m = Model()
m.add_template("""
@prefix mu:<http://example.org/music/>.
@prefix xsd:<http://www.w3.org/2001/XMLSchema#>.

mu:Band [ ottr:IRI ?iri, xsd:string ?name, xsd:string ?genre,
          xsd:string ?country, xsd:long ?formed ] :: {
    ottr:Triple(?iri, a,          mu:Band),
    ottr:Triple(?iri, mu:name,    ?name),
    ottr:Triple(?iri, mu:genre,   ?genre),
    ottr:Triple(?iri, mu:country, ?country),
    ottr:Triple(?iri, mu:formed,  ?formed)
} .
""")

bands_iri = bands.with_columns(
    (pl.lit("http://example.org/music/band/") + pl.col("name").str.replace_all(" ", "_")).alias("iri")
)
m.map("http://example.org/music/Band", bands_iri)

m.query("PREFIX mu:<http://example.org/music/> SELECT ?name WHERE { ?b mu:name ?name }")

Everything below extends this graph.


Mapping

Get tabular (or JSON/XML) data into the graph. The OTTR template is the usual path, but maplib offers several depending on where your data starts.

A template written as a string is the most common path: a reusable "schema" that maps each row to triples. Variable names line up with DataFrame columns.

m.add_template("""
@prefix mu:<http://example.org/music/>.
@prefix xsd:<http://www.w3.org/2001/XMLSchema#>.

mu:Band [ ottr:IRI ?iri, xsd:string ?name, xsd:long ?formed ] :: {
    ottr:Triple(?iri, a,         mu:Band),   # (1)!
    ottr:Triple(?iri, mu:name,   ?name),
    ottr:Triple(?iri, mu:formed, ?formed)
} .
""")
m.map("http://example.org/music/Band", bands_iri)
  1. a is shorthand for rdf:type; ottr:Triple(s, p, o) is the atomic building block.

When the template shape depends on runtime metadata, build it from Python objects instead of a string:

from maplib import Template, Parameter, Instance, Variable, IRI, RDFType, xsd

mu = "http://example.org/music/"
band = Variable("iri")
name = Variable("name")
formed = Variable("formed")

tmpl = Template(
    iri=IRI(mu + "Band"),
    parameters=[
        Parameter(band,   rdf_type=RDFType.IRI),
        Parameter(name,   rdf_type=RDFType.Literal(xsd.string)),
        Parameter(formed, rdf_type=RDFType.Literal(xsd.long)),
    ],
    instances=[
        Instance(IRI("http://ns.ottr.xyz/0.4/Triple"), [band, IRI(mu + "name"),   name]),
        Instance(IRI("http://ns.ottr.xyz/0.4/Triple"), [band, IRI(mu + "formed"), formed]),
    ],
)
m.add_template(tmpl)
m.map(mu + "Band", bands_iri)

Already have JSON? Map it straight to triples, no template needed.

m.map_json("""
{
  "name": "Pink Floyd",
  "genre": "Prog Rock",
  "formed": 1965
}
""")
# m.map_json("bands.json")            # …or from a file

The same quick path for XML data:

m.map_xml("""
<band>
  <name>Pink Floyd</name>
  <genre>Prog Rock</genre>
  <formed>1965</formed>
</band>
""")
# m.map_xml("bands.xml")              # …or from a file

Already have an identifier column? Use map_default

If your DataFrame already has a primary-key column, m.map_default(df, "iri") auto-generates a template, where every other column becomes a predicate. Pass dry_run=True to inspect the generated template without mapping.


SPARQL

Query and mutate the graph with standard SPARQL.

SELECT returns a Polars DataFrame, so results flow straight back into analytics:

df = m.query("""
    PREFIX mu:<http://example.org/music/>
    SELECT ?name ?genre WHERE {
        ?b a mu:Band ; mu:name ?name ; mu:genre ?genre .
    }
    ORDER BY ?name
""")

update runs a full SPARQL UPDATE. Here it normalises a genre label in place:

m.update("""
    PREFIX mu:<http://example.org/music/>
    DELETE { ?b mu:genre "Prog Rock" }
    INSERT { ?b mu:genre "Progressive Rock" }
    WHERE  { ?b mu:genre "Prog Rock" }
""")

insert runs a CONSTRUCT and materialises the result, tagging veteran bands:

m.insert("""
    PREFIX mu:<http://example.org/music/>
    CONSTRUCT { ?b a mu:VeteranBand }
    WHERE     { ?b a mu:Band ; mu:formed ?y . FILTER(?y < 1990) }
""")

Reasoning Licensed

Derive new facts from existing ones. We add a few mu:influencedBy edges, then compute the transitive closure two ways.

infer applies a Datalog ruleset and materialises the new triples:

import polars as pl
m.map_triples(pl.DataFrame({
    "subject": ["…/band/Rush",   "…/band/Mogwai"],
    "object":  ["…/band/Pink_Floyd", "…/band/Rush"],
}), predicate="http://example.org/music/influencedBy")

m.infer("""
@prefix mu:<http://example.org/music/>.
mu:influencedBy(?a, ?c) :- mu:influencedBy(?a, ?b), mu:influencedBy(?b, ?c) .
""")

The same transitive closure expressed as a recursive SPARQL CONSTRUCT. infer applies the rule repeatedly to a fixed point, exactly like the Datalog ruleset. Recursive CONSTRUCT is a capability unique to maplib.

m.infer("""
    PREFIX mu:<http://example.org/music/>
    CONSTRUCT { ?a mu:influencedBy ?c }
    WHERE {
        ?a mu:influencedBy ?b .
        ?b mu:influencedBy ?c .
    }
""")

Validation Licensed

Check the graph against SHACL shapes before anything downstream depends on it. For example, every band must have a name and a formed year.

m.read("shapes.ttl", graph="urn:g:shapes")

report = m.validate(shape_graph="urn:g:shapes")
print(report.conforms)        # bool
print(report.results())       # Polars DataFrame of violations

Explorer

Spin up a local web UI to browse the graph and run SPARQL interactively:

server = m.explore(port=8000)   # opens a browser UI; add fts=True for full-text search
# ... explore ...
server.stop()

Virtualisation Licensed

A knowledge graph rarely holds your time-series. With chrontext, a single SPARQL query can transparently span the in-memory graph and an external database. maplib pushes filters down to SQL and joins results via zero-copy Arrow.

Here each band links to its monthly streaming counts, stored in DuckDB rather than the graph:

m.add_virtualization(
    virtualized_database=vdb,            # wraps your DuckDB / Postgres / BigQuery / OPC UA
    resources={"streams": streams_template},
)

m.query("""
    PREFIX mu:<http://example.org/music/>
    PREFIX ct:<https://github.com/DataTreehouse/chrontext#>
    SELECT ?name (AVG(?plays) AS ?avg_monthly_plays)
    WHERE {
        ?b mu:name ?name ; ct:hasTimeseries ?ts .
        ?ts ct:hasDataPoint ?dp .
        ?dp ct:hasValue ?plays .
    }
    GROUP BY ?name
""")

Full setup (intermediate nodes, resource_sql_map, resource templates) lives in the Virtualisation guide.


Reading & writing RDF

maplib reads and writes the formats you'd expect; format is inferred from the file extension unless you pass format=.

m.read("catalog.ttl")
m.write("out.ttl", format="turtle")
m.read("catalog.nt")
s = m.writes(format="ntriples")     # returns a string
m.read("catalog.rdf")
m.write("out.rdf", format="rdf/xml")
m.write_cim_xml("model.xml", profile_graph="urn:graph:profiles",
                version="22", description="My CIM model")

Disk-based storage Licensed

For graphs too large to keep in memory, point the model at a storage folder when you create it. maplib then keeps the graph on disk in its native, Parquet-based format instead of in RAM, so you can work with graphs that exceed available memory.

from maplib import Model

m = Model(storage_folder=storage_path)

Licensing

maplib's core is free and open source (pip install maplib): templates and mapping, SPARQL query and update, and the standard RDF interchange formats (Turtle, N-Triples, RDF/XML, CIM/XML).

Some advanced capabilities are part of the commercial add-on. They are marked Licensed on this page:

  • Reasoning: Datalog infer and recursive SPARQL CONSTRUCT
  • Validation: SHACL validate
  • Virtualisation: chrontext federated time-series querying
  • Disk-based storage: the native Parquet-based on-disk format

These are always free for academics and personal exploration; a license is only needed for commercial use. Reach out at info@data-treehouse.com.


Head to the maplib API for the full Model surface, or the vocabulary for maplib's custom IRIs.