Ontology 101, Part 1 of 5: A hands-on series that starts at first principles and ends at Databricks Genie Ontology. Each part ends with a working artifact you carry into the next.
Two dashboards at the same company report churn for the same quarter. One says 4.1 percent. The other says 6.8 percent. Both are correct. One counts an account as churned when the subscription lapses. The other counts it when usage drops to zero for thirty days because the sales team argues that a lapsed contract with active users is a renewal conversation, not a loss.
Neither definition is written down anywhere a machine can read. Both live in the heads of the analysts who built the dashboards, one of whom left in March.
Now put an AI agent in front of that warehouse and ask it, "What's our churn?” It will pick one, confidently. It might even pick a third definition, inferred from a column named churn_flag whose population logic nobody remembers.
This series builds a governed layer of meaning between the question and the query, using one SaaS churn example throughout. Part 1 writes one churn rule into a file a machine can read. Keep that file. The later parts only make sense if it exists.
Each part is one idea plus a lab. You carry the same domain forward.
"Ontology" comes from the Greek "ontos," meaning "being," and "logia," meaning "study." Aristotle never used the word. In the categories, he listed ten kinds of things that can be said of a subject, e.g., substance, quantity, quality, and relation. Later writers, starting with Porphyry, drew those kinds as trees that nest.
The word itself arrived around 1606, in Jacob Lorhard’s Ogdoas scholastica. Christian Wolff’s Philosophia prima sive ontologia (1730) made it a standard name for the study of being. For three centuries it stayed a philosopher’s word.
Then computer science took it. In 1993, Tom Gruber needed a term for something knowledge engineers kept building. They wanted a formal description of what exists in a domain, written so software systems could share it. Gruber’s definition became the field’s default. An ontology is an explicit specification of a conceptualization.
Every word in that definition does work. Conceptualization is the abstract model in someone’s head, e.g., the sales team’s idea of what “churned” means. "Specification" means written down, not tribal knowledge. "Explicit" means the meaning is stated, not implied by a column name. The 4.1 versus 6.8 problem is, in Gruber’s terms, two conceptualizations and zero specifications.
People often mix the word with taxonomy. A taxonomy lists kinds and subkinds. An ontology also states relationships and rules, e.g. every subscription belongs to one account, and an account with no active subscription is churned. The rules have to live in the file. A query that restates them is still a query.
Between Aristotle and Gruber sits a stretch of AI history you should know, mostly because later parts of this series reuse it under new names.
Frames (Marvin Minsky, mid-1970s) packaged knowledge into named objects with slots and default values. A frame for “restaurant visit” has slots for menu, table, and bill. A JSON schema with inheritance describes the same shape.
Description logics (starting with Ron Brachman’s KL-ONE, late 1970s) made the semantics formal. A description logic is a decidable fragment of first-order logic built for this job. It defines classes, properties, and constraints so that a machine can prove consequences. If every enterprise subscription is a paid subscription and Acme’s subscription is enterprise, the machine derives that Acme pays. It does not look up the facts. It proves the fact from the definitions. Description logics became the mathematical foundation of OWL, which you will write into the file below, and which Part 4 will run on Databricks.
Then there is Cyc. Doug Lenat started it in 1984 with the goal of hand-coding common sense into one knowledge base. Forty years and tens of millions of rules later, Cyc had proved its premise and disproved its method. Machines need explicit knowledge to reason reliably. Humans should not type all of it in. Hold that thought until Part 5. There you will write some of the model by hand, and the platform will also learn a model from tables, queries, and dashboards. Genie Ontology is the learned layer. It is not the OWL file you are about to build.
In 2001, Tim Berners-Lee published a vision of a web where data carried machine-readable meaning. The Semantic Web mostly did not happen, and Part 2 covers why. People still use the W3C standards built for that project, including inside modern data platforms. Those standards are also the simplest way to make Gruber’s definition concrete. There are four pieces.
RDF is the data model. Everything is a triple: subject, predicate, object. A pile of triples is a graph, and that is the whole data model. Any fact, from any source, decomposes into the same shape, which is why triples outlived the larger project.
RDFS adds vocabulary for structure. You get classes, subclasses, and property domains and ranges. This layer is the taxonomy.
OWL adds the logic, backed by description logics. You can state that a property takes at most one value, that two classes cannot overlap, and that a class is defined by a condition. The OWL layer is where a taxonomy becomes an ontology.
SPARQL is the query language. It matches patterns over triples. If you know SQL, the SELECT / WHERE shape will look familiar. You can write useful queries in it within about ten minutes.
That covers the concepts. Now open a terminal.
The running example for this whole series is a B2B SaaS business: accounts, subscriptions, plans, users, usage events, support tickets, and churn. In later parts the example becomes real tables in Unity Catalog, a knowledge graph in Neo4j, governed metrics, and a Genie space. Today it becomes an ontology, and the first step is paper.
Write down the things in the domain, not the tables you would eventually store them in.
Now the relationships, as sentences:
And two rules, the kind that have to live in the file rather than in a dashboard:
Every one of those sentences is a triple or a constraint. The paper exercise is the ontology. Everything after this is transcription.
You need Python and one package:
pip install rdflib
Define the vocabulary. Classes first:
from rdflib import Graph, Namespace, Literal, RDF, RDFS, OWL, XSDSAAS = Namespace("http://example.org/saas#")g = Graph()g.bind("saas", SAAS)g.bind("owl", OWL)# Classes: the kinds of things that existfor cls in ["Account", "Subscription", "Plan", "User", "SupportTicket"]: g.add((SAAS[cls], RDF.type, OWL.Class))# Taxonomy: paid plans are plansg.add((SAAS.PaidPlan, RDF.type, OWL.Class))g.add((SAAS.PaidPlan, RDFS.subClassOf, SAAS.Plan))g.add((SAAS.FreePlan, RDF.type, OWL.Class))g.add((SAAS.FreePlan, RDFS.subClassOf, SAAS.Plan))print(f"{len(g)} triples so far")
Then the relationships. In OWL, relationships between things are object properties, and attributes with literal values are datatype properties:
rdfs:domain and rdfs:range are not checks. If you write Acme filedBy Sub_001, a reasoner does not reject the triple. It implies that Acme is a SupportTicket. The check you probably want is SHACL, which arrives in Part 4. Domain and range still tell a query engine what the property is for owl:FunctionalProperty, onPlan, and belongsTo are at most one half of the paper list. A reasoner treats two different plans on one subscription as a contradiction. SPARQL will not enforce that by itself. isActive is functional too, and that one matters later; it tells the reasoner a subscription cannot be both active and inactive, which the churn rule will depend on.
Now instances. The plans, and one customer:
The paper said an account is churned if it has no active subscription. Until that sentence is an OWL axiom, it is still a comment. Paste this Turtle into the graph. Turtle is the human-readable RDF syntax. You will save the whole graph as Turtle at the end.
g.parse(data="""@prefix saas: <http://example.org/saas#> .@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#> .saas:PaidPlan owl:disjointWith saas:FreePlan .saas:ActiveSubscription a owl:Class ; owl:equivalentClass [ a owl:Class ; owl:intersectionOf ( saas:Subscription [ a owl:Restriction ; owl:onProperty saas:isActive ; owl:hasValue true ] ) ] .saas:Churned a owl:Class ; rdfs:subClassOf saas:Account ; owl:equivalentClass [ a owl:Class ; owl:intersectionOf ( saas:Account [ a owl:Class ; owl:complementOf [ a owl:Restriction ; owl:onProperty saas:hasSubscription ; owl:someValuesFrom saas:ActiveSubscription ] ] ) ] .""", format="turtle")print(f"{len(g)} triples total")
Read it once in order. ActiveSubscription is a subscription whose isActive value is true. Churned is an account that has no such subscription. PaidPlan and FreePlan cannot overlap. Those three claims are now in the graph. SPARQL, in the next section, will not apply them, because rdflib matches only what you asserted and does not run OWL. A reasoner will, and the churn rule is going to teach you something about how reasoners think. That surprise is waiting in the Protégé section.
SPARQL matches graph patterns. Total MRR across active subscriptions on paid plans:
q = """PREFIX saas: <http://example.org/saas#>PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>SELECT (SUM(?mrr) AS ?activePaidMrr)WHERE { ?account saas:hasSubscription ?sub . ?sub saas:onPlan ?plan ; saas:mrr ?mrr ; saas:isActive true . ?plan a ?planType . ?planType rdfs:subClassOf* saas:PaidPlan .}"""for row in g.query(q): print("Active paid MRR:", row.activePaidMrr) # 4200.0
Look at the last two lines of the WHERE clause. The query does not name Pro or Enterprise. It asks for any plan whose type sits anywhere under PaidPlan the taxonomy, using the * path operator to walk the subclass hierarchy. Add a premium plan next quarter, declare it a PaidPlan, and this query is already correct. SQL would push the same job onto a WHERE plan IN (...) list somebody forgets to update.
Now the question this article opened with, under definition one, is churn as a lapsed subscription. The query below restates the churn condition directly so you can check the two accounts today without a reasoner.
q_churn = """PREFIX saas: <http://example.org/saas#>SELECT ?accountWHERE { ?account a saas:Account . FILTER NOT EXISTS { ?account saas:hasSubscription ?sub . ?sub saas:isActive true . }}"""for row in g.query(q_churn): print("Churned (definition A, SPARQL):", row.account) # http://example.org/saas#Initechq_typed = """PREFIX saas: <http://example.org/saas#>SELECT ?account WHERE { ?account a saas:Churned }"""print("Typed as Churned without a reasoner:", list(g.query(q_typed))) # []
The first query finds Initech. The second is empty. The churn rule is in the graph as OWL, but SPARQL will not apply it until a reasoner has run.
One thing to notice about the first query, because it comes back in a moment. FILTER NOT EXISTS treats absence as falsehood. No active subscription in the data means no active subscription, full stop. That assumption has a name, the closed-world assumption, and SPARQL makes it. OWL does not.
The competing usage-based definition needs usage events, which enter the dataset in Part 2. Part 3 will put both numbers into governed metrics. Part 5 will put both definitions in front of Genie Ontology. Today the disagreement is not resolved. One definition is in the file, in a form a later reasoner can use, and that file is what every later part of the series builds on.
Save your work:
g.serialize(destination="saas_ontology.ttl", format="turtle")ttl = open("saas_ontology.ttl").read()assert "owl:equivalentClass" in ttl or "equivalentClass" in ttlassert "disjointWith" in ttlprint(ttl[:1500])
Open the .ttl file. You should see your paper sentences, including owl:equivalentClass for Churned and owl:disjointWith two planned classes. Keep this file. In Part 4 you will load it into OntoBricks and map it onto Unity Catalog tables. If the file has no axioms, the reasoner in that part has nothing to do.
Download Protégé, the free Stanford ontology editor. Open saas_ontology.ttl, then start the built-in reasoner (HermiT or Pellet, under Reasoner).
Run three checks.
First, classify Enterprise. You typed it as PaidPlan. The reasoner should also place it under Plan because PaidPlan is a subclass of Plan. This is the easy kind of inference: walking up a hierarchy you declared.
Second, look up Initech. You asserted it as an account with a lapsed subscription. The SPARQL query found it. So after the reasoner runs, Initech should appear under Churned.
It does not. This is not a bug, and it is the most useful thing in this article.
OWL makes the open-world assumption: what is not stated is unknown, not false. To classify Initech as churned, the reasoner has to prove Initech has no active subscription. It cannot. Nothing in the file says Sub_002 is Initech’s only subscription. For all the reasoner knows, there is a Sub_003 out there, active, that you never mentioned. So it stays silent. Compare the SPARQL query, which found Initech immediately because it FILTER NOT EXISTS assumes the data it can see is all the data there is. Same rule, same account, opposite answers, and the difference is the world assumption. When you put ontologies over a data warehouse, as Part 4 does, managing this gap is most of the work.
To let the reasoner close the world for Initech, tell it the subscription list is complete. This is called a closure axiom:
g.parse(data="""@prefix saas: <http://example.org/saas#> .@prefix owl: <http://www.w3.org/2002/07/owl#> .# Closure: Sub_002 is Initech's only subscriptionsaas:Initech a [ a owl:Restriction ; owl:onProperty saas:hasSubscription ; owl:allValuesFrom [ a owl:Class ; owl:oneOf ( saas:Sub_002 ) ]] .""", format="turtle")g.serialize(destination="saas_ontology.ttl", format="turtle")
Reload in Protégé and run the reasoner again. Now the proof goes through: every subscription Initech has is Sub_002, Sub_002 has isActive false, isActive is functional, so it cannot also be true; therefore, Sub_002 is not an ActiveSubscription, therefore Initech has no active subscription, therefore Initech is churned. Initech appears under "Churned," inferred, not asserted. That chain is what "the machine proves the fact from the definitions" looks like in practice.
Third, make a contradiction on purpose. Assert that Free is also a PaidPlan, then re-run the reasoner. PaidPlan and FreePlan are disjoint, so Protégé should flag the ontology as inconsistent. A reasoner catches that contradiction; a document describing the same rule cannot.
If Initech does not show up under "Churned," the closure axiom did not load. Open the turtle, confirm saas:Churned, owl:equivalentClass, and the owl:allValuesFrom closure is present, and reload the file.
You now hold three things the rest of the series uses. An ontology is an explicit, machine-readable specification of what exists and what the rules are. One churn definition now lives in saas_ontology.ttl, not only in a query. And you have seen open world versus closed world produce different answers to the same question on the same data, which is the single sharpest edge you will hit when Part 4 puts this file over real tables.
You also hold a fair objection. The example was a toy. A handful of classes, two accounts, hand-typed triples. The Semantic Web community built artifacts like it for a decade, and the global machine-readable web never arrived. The reasons it never arrived are specific, and they are mostly economic rather than technical. Triples, IRIs, and SPARQL survived.
Part 2 tells that story and adds your first real graph database plus a retrieval technique that lets an LLM reason across documents instead of matching isolated chunks. Bring the .ttl file.
Part 2: Knowledge graphs and GraphRAG. The dataset, notebooks, and this article’s code live in the series repo.
What an Ontology Actually Is was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.