Built with Tutify.io

    AIStudyLabs

    How to Build an Ontology in Python with RDFLib

    How to Build an Ontology in Python with RDFLib

    Many ontology tutorials start in a graphical editor such as Protégé. That's a fine way to learn the ideas, but data engineers usually want the model in code: in Git, reviewed in pull requests, loaded by the same pipelines that move the data. Building an ontology in Python gives you exactly that, and RDFLib is the most widely used Python library for it.

    This tutorial builds a small ontology for an online learning platform: courses, lessons, instructors and people. You will define classes and properties, add instance data, query it with SPARQL, watch RDFS inference add facts, and validate the data with SHACL so a missing value fails loudly. It is part of a series that starts with the overview of ontology in data and AI. The code uses Turtle, RDFS, OWL and SHACL; if those names are new, the companion piece on RDF, RDFS, OWL and SHACL explains each one.

    All output below comes from running the code with Python 3.13, RDFLib 7.6.0, pySHACL 0.40.1 and owlrl 7.6.2.

    Setting up an ontology in Python

    You need three packages. RDFLib handles graphs, parsing, serialization and SPARQL. pySHACL validates data. owlrl provides RDFS and OWL RL inference; pySHACL already depends on it, but installing it explicitly keeps the import safe.

    pip install rdflib pyshacl owlrl
    

    RDFLib 7.6 needs Python 3.8.1 or later, and pySHACL 0.40 needs Python 3.9 or later. Use a virtual environment so the versions stay pinned with your project.

    Before writing code, decide what the ontology has to answer. These are called competency questions, and they keep the model small. For this example:

    Each question will map to a query or a validation rule by the end.

    Step 1: Define the classes and properties

    Every term in an RDF ontology is an IRI. RDFLib's Namespace makes those easy to create: EDU.Course becomes https://example.org/edu#Course. The built-in RDF, RDFS, OWL and XSD namespaces cover the standard vocabulary.

    from rdflib import Graph, Namespace, Literal
    from rdflib.namespace import RDF, RDFS, OWL, XSD
    
    EDU = Namespace("https://example.org/edu#")
    
    g = Graph()
    g.bind("edu", EDU)
    
    # Classes
    g.add((EDU.Course, RDF.type, OWL.Class))
    g.add((EDU.Lesson, RDF.type, OWL.Class))
    g.add((EDU.Person, RDF.type, OWL.Class))
    g.add((EDU.Instructor, RDF.type, OWL.Class))
    g.add((EDU.Instructor, RDFS.subClassOf, EDU.Person))
    g.add((EDU.Course, RDFS.label, Literal("Course", lang="en")))
    
    # Object properties link two resources
    g.add((EDU.teaches, RDF.type, OWL.ObjectProperty))
    g.add((EDU.teaches, RDFS.domain, EDU.Instructor))
    g.add((EDU.teaches, RDFS.range, EDU.Course))
    
    g.add((EDU.hasLesson, RDF.type, OWL.ObjectProperty))
    g.add((EDU.hasLesson, RDFS.domain, EDU.Course))
    g.add((EDU.hasLesson, RDFS.range, EDU.Lesson))
    
    # Datatype properties link a resource to a literal value
    g.add((EDU.durationMinutes, RDF.type, OWL.DatatypeProperty))
    g.add((EDU.durationMinutes, RDFS.domain, EDU.Lesson))
    g.add((EDU.durationMinutes, RDFS.range, XSD.integer))
    
    print(len(g), "triples in the schema")
    

    Output:

    15 triples in the schema
    

    A few things are worth noticing. A Graph is a set of triples, and g.add() takes a single tuple of subject, predicate and object. The namespace ends with #, so terms join cleanly; leave it off and you get https://example.org/eduCourse, one of the most common RDFLib mistakes. g.bind("edu", EDU) only affects prefixes in output and queries, not the data.

    Instructor is a subclass of Person, which means every instructor is a person. teaches has a domain of Instructor and a range of Course. That says what kind of thing sits at each end of the relationship. As you will see in step 5, it does not stop you from linking the wrong things.

    The ontology uses owl:Class and owl:ObjectProperty rather than plain rdfs:Class because OWL-aware tools such as Protégé expect them. The model still only uses RDFS-level semantics, which is all this example needs.

    Step 2: Add instance data

    Classes and properties are the model. The knowledge graph is the data that follows it. Add one course with two lessons and an instructor:

    g.add((EDU.sparkBasics, RDF.type, EDU.Course))
    g.add((EDU.sparkBasics, RDFS.label, Literal("Spark Basics", lang="en")))
    
    g.add((EDU.lesson1, RDF.type, EDU.Lesson))
    g.add((EDU.lesson1, RDFS.label, Literal("What is a DataFrame?", lang="en")))
    g.add((EDU.lesson1, EDU.durationMinutes, Literal(12)))
    
    g.add((EDU.lesson2, RDF.type, EDU.Lesson))
    g.add((EDU.lesson2, RDFS.label, Literal("Reading CSV files", lang="en")))
    g.add((EDU.lesson2, EDU.durationMinutes, Literal(18)))
    
    g.add((EDU.sparkBasics, EDU.hasLesson, EDU.lesson1))
    g.add((EDU.sparkBasics, EDU.hasLesson, EDU.lesson2))
    
    g.add((EDU.priya, EDU.teaches, EDU.sparkBasics))
    g.add((EDU.priya, RDFS.label, Literal("Priya Raman")))
    
    print(len(g), "triples in total")
    

    Output:

    27 triples in total
    

    Literal(12) picks up its datatype from the Python value, so it is stored as an xsd:integer, not a string. Typing numbers as strings is another frequent slip, and it breaks numeric sorting and aggregation in SPARQL later.

    Notice what is missing: there is no triple saying Priya is an Instructor. That is deliberate, and it comes back in step 5.

    In a real project you would not write g.add() by hand for every record. You would loop over rows from a database or a CSV file and generate IRIs from stable keys in a separate data namespace, such as EDU_ID[f"lesson/{row.lesson_id}"], where EDU_ID = Namespace("https://data.yourcompany.com/id/"). The modelling choices stay the same.

    Choosing IRIs that last

    The IRIs you mint now will end up in other people's queries, mappings and LLM prompts, so a little care pays off. Four rules cover most cases.

    First, use a domain you control. https://example.org/ is fine for a tutorial, but production terms should live under a base such as https://data.yourcompany.com/ontology/edu#, even if nothing is served at that address yet. Second, keep the schema and the data in separate namespaces, for example .../ontology/edu# for classes and properties and .../id/lesson/ for individuals. That makes it obvious in any triple which part is model and which is data.

    Third, build individual IRIs from stable business keys, never from labels. A lesson's title will change; its database ID won't. An IRI such as EDU_ID[f"lesson/{row.lesson_id}"] survives a rename, while an IRI made from "What is a DataFrame?" breaks the first time someone fixes a typo. Fourth, name classes in singular UpperCamelCase (Lesson) and properties in lowerCamelCase verbs (hasLesson, teaches). Consistent naming is the cheapest documentation you will ever write.

    None of this is enforced by RDFLib. It will accept any IRI you give it, which is why it belongs in a short conventions file in the repository next to the ontology.

    Step 3: Query the ontology with SPARQL

    RDFLib runs SPARQL 1.1 queries directly against the graph. This answers the first competency question:

    q = """
    SELECT ?course ?lesson ?minutes
    WHERE {
        ?c a edu:Course ;
           rdfs:label ?course ;
           edu:hasLesson ?l .
        ?l rdfs:label ?lesson ;
           edu:durationMinutes ?minutes .
    }
    ORDER BY ?minutes
    """
    for row in g.query(q, initNs={"edu": EDU, "rdfs": RDFS}):
        print(row.course, "|", row.lesson, "|", row.minutes)
    

    Output:

    Spark Basics | What is a DataFrame? | 12
    Spark Basics | Reading CSV files | 18
    

    The initNs argument supplies the prefixes, so the query doesn't need PREFIX lines. Each result row exposes variables as attributes, so row.course holds the course label.

    The third competency question, total minutes per course, uses a property path and an aggregate:

    total = g.query("""
    SELECT (SUM(?m) AS ?total)
    WHERE { edu:sparkBasics edu:hasLesson/edu:durationMinutes ?m }
    """, initNs={"edu": EDU})
    
    for row in total:
        print("Total minutes:", row.total)
    

    Output:

    Total minutes: 30
    

    edu:hasLesson/edu:durationMinutes follows two properties in one step: from the course to each lesson, then to its duration. Property paths like this are where SPARQL starts to feel easier than SQL joins for connected data.

    The second competency question, who teaches each course, is a two-hop pattern:

    q = """
    SELECT ?name ?course
    WHERE {
        ?p edu:teaches ?c ;
           rdfs:label ?name .
        ?c rdfs:label ?course .
    }
    """
    for row in g.query(q, initNs={"edu": EDU, "rdfs": RDFS}):
        print(row.name, "|", row.course)
    

    Output:

    Priya Raman | Spark Basics
    

    You can also navigate the graph without SPARQL. g.subjects() finds every subject with a given predicate and object, g.value() returns a single object, and g.objects() iterates over many:

    for course in g.subjects(RDF.type, EDU.Course):
        print("Course:", g.value(course, RDFS.label))
        lessons = sorted(g.objects(course, EDU.hasLesson),
                         key=lambda l: int(g.value(l, EDU.durationMinutes)))
        for lesson in lessons:
            print("  -", g.value(lesson, RDFS.label), g.value(lesson, EDU.durationMinutes), "min")
    

    Output:

    Course: Spark Basics
      - What is a DataFrame? 12 min
      - Reading CSV files 18 min
    

    The sort matters. A graph is a set, so the order of g.objects() is not guaranteed. If your output order matters, sort explicitly or use ORDER BY in SPARQL.

    Step 4: Save and reload the ontology with RDFLib

    Turtle is the most readable format for review in Git. Serialize the whole graph:

    print(g.serialize(format="turtle"))
    

    Output:

    @prefix edu: <https://example.org/edu#> .
    @prefix owl: <http://www.w3.org/2002/07/owl#> .
    @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
    @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
    
    edu:durationMinutes a owl:DatatypeProperty ;
        rdfs:domain edu:Lesson ;
        rdfs:range xsd:integer .
    
    edu:hasLesson a owl:ObjectProperty ;
        rdfs:domain edu:Course ;
        rdfs:range edu:Lesson .
    
    edu:priya rdfs:label "Priya Raman" ;
        edu:teaches edu:sparkBasics .
    
    edu:teaches a owl:ObjectProperty ;
        rdfs:domain edu:Instructor ;
        rdfs:range edu:Course .
    
    edu:Instructor a owl:Class ;
        rdfs:subClassOf edu:Person .
    
    edu:Person a owl:Class .
    
    edu:lesson1 a edu:Lesson ;
        rdfs:label "What is a DataFrame?"@en ;
        edu:durationMinutes 12 .
    
    edu:lesson2 a edu:Lesson ;
        rdfs:label "Reading CSV files"@en ;
        edu:durationMinutes 18 .
    
    edu:sparkBasics a edu:Course ;
        rdfs:label "Spark Basics"@en ;
        edu:hasLesson edu:lesson1,
            edu:lesson2 .
    
    edu:Course a owl:Class ;
        rdfs:label "Course"@en .
    
    edu:Lesson a owl:Class .
    

    To write a file, pass destination, and to read it back, use parse():

    g.serialize(destination="edu.ttl", format="turtle")
    
    g2 = Graph()
    g2.parse("edu.ttl", format="turtle")
    print(len(g2), "triples loaded from edu.ttl")
    print(g2.isomorphic(g))
    

    Output:

    27 triples loaded from edu.ttl
    True
    

    isomorphic() confirms the reloaded graph is the same as the original. A practical layout is to keep the schema in one file, such as edu-ontology.ttl, and generate instance data into separate files or a triple store. Reviewers can then see model changes clearly in a diff.

    Turtle is not the only option. format="nt" writes N-Triples, one triple per line, which is easy to stream and to diff line by line, and format="json-ld" writes JSON-LD for web APIs and for schema.org markup. format="xml" writes RDF/XML, the format owlready2 and many older OWL tools expect. RDFLib can parse all of them, so converting between formats is a two-line job: parse with one format, serialize with another.

    Step 5: RDFS inference adds facts

    Remember that no triple says Priya is an instructor. Run RDFS inference with owlrl and check again:

    import owlrl
    
    print((EDU.priya, RDF.type, EDU.Instructor) in g)
    print(len(g), "triples before inference")
    
    owlrl.DeductiveClosure(owlrl.RDFS_Semantics).expand(g)
    
    print(len(g), "triples after inference")
    print((EDU.priya, RDF.type, EDU.Instructor) in g)
    print((EDU.priya, RDF.type, EDU.Person) in g)
    

    Output:

    False
    27 triples before inference
    71 triples after inference
    True
    True
    

    Before inference, the fact isn't there. After inference, the reasoner has used the domain of teaches to conclude that Priya is an Instructor, then used subClassOf to conclude she is a Person. Nobody stored either fact.

    This is the behaviour that surprises developers coming from relational databases. Domain and range are not foreign keys. If you had accidentally written edu:lesson1 edu:teaches edu:sparkBasics, the reasoner would happily conclude that the lesson is an instructor. Inference is useful, but it is not validation.

    The jump from 27 to 71 triples is mostly RDFS bookkeeping, such as every resource being typed rdfs:Resource. Two practical notes. expand() modifies the graph in place, so run it on a copy when asserted and inferred triples need to stay apart. And for full OWL 2 DL reasoning with HermiT or Pellet, use owlready2; it needs Java installed and does not read Turtle, so convert the file to RDF/XML with RDFLib first.

    Step 6: Validate data with SHACL

    The last competency question asks whether every lesson has a duration. The graph g now holds the inferred triples from step 5 too; the validation result is the same either way, because inference never adds a duration. Under the open-world rules of RDFS and OWL, a missing value is simply unknown. SHACL treats it as an error, which is what a pipeline needs.

    from pyshacl import validate
    
    shapes = Graph().parse(format="turtle", data="""
    @prefix sh:  <http://www.w3.org/ns/shacl#> .
    @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
    @prefix edu: <https://example.org/edu#> .
    
    edu:LessonShape a sh:NodeShape ;
        sh:targetClass edu:Lesson ;
        sh:property [
            sh:path edu:durationMinutes ;
            sh:datatype xsd:integer ;
            sh:minCount 1 ;
            sh:maxCount 1 ;
            sh:minInclusive 1 ;
        ] .
    """)
    
    # A lesson with no duration
    g.add((EDU.lesson3, RDF.type, EDU.Lesson))
    g.add((EDU.lesson3, RDFS.label, Literal("Writing Parquet", lang="en")))
    
    conforms, report_graph, report_text = validate(g, shacl_graph=shapes)
    print("Conforms:", conforms)
    print(report_text)
    

    Output:

    Conforms: False
    Validation Report
    Conforms: False
    Results (1):
    Constraint Violation in MinCountConstraintComponent (http://www.w3.org/ns/shacl#MinCountConstraintComponent):
    	Severity: sh:Violation
    	Source Shape: [ sh:datatype xsd:integer ; sh:maxCount Literal("1", datatype=xsd:integer) ; sh:minCount Literal("1", datatype=xsd:integer) ; sh:minInclusive Literal("1", datatype=xsd:integer) ; sh:path edu:durationMinutes ]
    	Focus Node: edu:lesson3
    	Result Path: edu:durationMinutes
    	Message: Less than 1 values on edu:lesson3->edu:durationMinutes
    

    The shape targets every edu:Lesson and requires exactly one integer duration of at least one minute. lesson3 has none, so validation fails and the report names the node, the property and the rule. validate() returns three values: a boolean, the report as an RDF graph, and the report as text. In a pipeline, check the boolean and fail the job; store the report graph to track data quality over time.

    Testing an ontology in Python with pytest

    An ontology changes over time, and every change can break a query or a downstream pipeline. Treat it like code and test it. The competency questions from the start of this tutorial make natural test cases: each one becomes a SPARQL query with an expected answer over a small fixture graph.

    # test_ontology.py
    from rdflib import Graph, Namespace
    
    EDU = Namespace("https://example.org/edu#")
    
    def load():
        g = Graph()
        g.parse("edu.ttl", format="turtle")
        return g
    
    def test_course_minutes():
        g = load()
        rows = list(g.query(
            "SELECT (SUM(?m) AS ?t) WHERE { edu:sparkBasics edu:hasLesson/edu:durationMinutes ?m }",
            initNs={"edu": EDU}))
        assert int(rows[0].t) == 30
    
    def test_instructor_is_person():
        g = load()
        assert (EDU.Instructor, None, EDU.Person) in g
    

    Here the tests load the edu.ttl export from step 4 as a stand-in; in a real project they would load a small tests/fixture.ttl. Running pytest -q gives:

    ..                                                                       [100%]
    2 passed in 0.11s
    

    Run it in CI, together with a pySHACL check over sample data. When someone renames a property or changes a class hierarchy, the failing test tells you which business question just stopped working. That is far more useful than discovering it in a dashboard a week later.

    Keep the fixture data small and hand-written. A dozen individuals that cover the edge cases, such as a course with no lessons or a lesson with two durations, test more than a full export does, and they run quickly.

    Should you use RDFLib, owlready2 or Protégé?

    Each tool suits a different part of the work, and many teams use all three:

    | Tool | Best for | Limits | |---|---|---| | RDFLib | Building, loading, querying and serializing RDF in pipelines | RDFS and OWL RL inference only, through owlrl | | owlready2 | Treating OWL classes as Python classes, full OWL 2 DL reasoning with HermiT or Pellet | Needs Java for reasoning; reads RDF/XML, OWL/XML and N-Triples, not Turtle | | Protégé | Designing and browsing a model visually with domain experts | Desktop or web (WebProtégé) editor; not part of a data pipeline | | pySHACL | Validating data graphs against shapes | Validation only, no modelling |

    A sensible split is to sketch the first version of the model in Protégé with the people who know the domain, export it to Turtle, and from then on maintain the ontology in Python with RDFLib, where it lives next to the code that loads data into it. Bring in owlready2 only when you need full OWL reasoning, for example to classify individuals using equivalence axioms.

    Building an ontology in Python is not the same as building a big one. The best first models are small: a handful of classes, a few properties, and enough sample data to answer the competency questions. Grow it when a new question appears that the model can't answer.

    Common mistakes when building an ontology in Python

    Where to take this ontology in Python next

    The same pattern scales to real systems. Generate IRIs from database keys, load the schema and data into a triple store, and keep the SHACL shapes in CI so bad data never reaches production. If your sources are relational databases you don't want to copy, map them to the ontology instead, as described in ontology-based data integration.

    The model you just built is also exactly what an LLM pipeline needs as a reference. The classes and properties become the allowed entity and relationship types for extraction, and the SHACL shapes become the check on what the model produces. That is the subject of ontologies and LLMs and of ontology-driven GraphRAG. If you are still unsure whether your domain needs an ontology or just a hierarchy of tags, see ontology vs taxonomy vs knowledge graph.

    Key takeaways

    Related articles in this series