Let’s dive into some practical solutions on how to work with SKOS vocabularies in a knowledge graph driven data pipeline. Embrace yourself for code! I’ll cover seven practical approaches:
Strings to things
Hidden labels as “garbage collection”
Codes instead of names
Checking for correct concept scheme
Materialise without reasoning
Deprecating concepts
Validating vocabularies
But first, let’s have a look at the vocabulary I’ll be using in these examples.
The vocabulary
Let’s go for music genres, and dive into some of my favourite bands. Each of these concepts carries a preferred label, and some other spellings some people might use, some usual typos, and a code from some code list.
voc:Genres a skos:ConceptScheme ;
skos:prefLabel "Music genres"@en .
voc:rock a skos:Concept ;
skos:inScheme voc:Genres ;
skos:topConceptOf voc:Genres ;
skos:prefLabel "Rock"@en ;
skos:notation "ROCK" .
voc:post-rock a skos:Concept ;
skos:inScheme voc:Genres ;
skos:broader voc:rock ;
skos:prefLabel "Post-rock"@en ;
skos:altLabel "Post Rock"@en , "Post rock"@en ;
skos:hiddelLabel "postrock"@en, "post_rock"@en ;
skos:notation "PRCK" .
voc:shoegaze a skos:Concept ;
skos:inScheme voc:Genres ;
skos:broader voc:rock ;
skos:prefLabel "Shoegaze"@en ;
skos:altLabel "Shoegazing"@en ;
skos:notation "SHOE" .
voc:blackgaze a skos:Concept ;
skos:inScheme voc:Genres ;
skos:broader voc:shoegaze ;
skos:prefLabel "Blackgaze"@en ;
skos:altLabel "Black gaze"@en ;
skos:hiddenLabel "black-gaze"@en ;
skos:notation "BLKG" .
voc:industrial a skos:Concept ;
skos:inScheme voc:Genres ;
skos:topConceptOf voc:Genres ;
skos:prefLabel "Industrial"@en ;
skos:notation "IND" .
voc:industrial-rock a skos:Concept ;
skos:inScheme voc:Genres ;
skos:broader voc:industrial ;
skos:prefLabel "Industrial rock"@en ;
skos:altLabel "Industrial-rock"@en ;
skos:notation "INDR" .In other words, we have the genres rock, post-rock, shoegaze, blackgaze, industrial and industrial rock to play with.
And then some data:
band:gybe a mu:Band ;
mu:name "Goodspeed You! Black Empreror" ;
mu:genre "Post Rock" .
band:mbv a mu:Band ;
mu:name "My Bloody Valentine" ;
mu:genre "Shoegazing" .
band:deafhaven a mu:Band ;
mu:name "Deafhaven" ;
mu:genre "blackgaze", "post-metal" .
band:neubauten a mu:Band ;
mu:name "Einstürzende Neubauten" ;
mu:genreCode "IND" .
band:nin a mu:Band ;
mu:name "Nine Inch Nails" ;
mu:genreCode "INDR" .The data could have come in any shape or form, I chose to type it in RDF Turtle for the sake of example. Since my wild guess is that most readers of this particular article will already be very well familiar with the syntax. If you wanna see transformations from csv or parquet, you can check out the demo I had for the Practical Data Community.
Strings to Things
This is probably the query I write the most when working with SKOS Concepts. Transforming incoming strings into things according to the vocabulary. That’s solvable using SPARQL UPDATE, matching on any of the three label properties, handling case sensitivity.
from maplib import Model
m = Model()
m.read("vocabulary.ttl")
m.read("bands.ttl")
m.update("""
PREFIX mu: <http://example.org/music/>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
DELETE { ?b mu:genre ?label }
INSERT { ?b mu:genre ?concept }
WHERE {
?b a mu:Band ;
mu:genre ?label .
FILTER(isLiteral(?label))
?concept a skos:Concept ;
?labelProp ?candidate .
FILTER(?labelProp IN (skos:prefLabel, skos:altLabel, skos:hiddenLabel))
FILTER(LCASE(STR(?candidate)) = LCASE(STR(?label)))
}
""")FILTER(isLiteral(?label)) makes this update idempotent. Meaning, if we run it again, there’s nothing to do, as the values are IRIs already by then.
Matching on skos:hiddenLabel will also catch the (probably) out-dated terms that are not in use, or typical typos, but sill connected to the concept somehow.
LCASE(STR(?candidate)) strips both the case and language tag before comparing. This let Deafhaven’s lowercase “blackgaze” find the concept’s “Blackgaze”@en.
After having run this particular query, our data will look like:
band:gybe a mu:Band ;
mu:name "Goodspeed You! Black Emperor" ;
mu:genre voc:post-rock .
band:mbv a mu:Band ;
mu:name "My Bloody Valentine" ;
mu:genre voc:shoegaze .
band:deafhaven a mu:Band ;
mu:name "Deafhaven" ;
mu:genre voc:blackgaze, "post-metal" .
band:neubauten a mu:Band ;
mu:name "Einstürzende Neubauten" ;
mu:genreCode "IND" .
band:nin a mu:Band ;
mu:name "Nine Inch Nails" ;
mu:genreCode "INDR" .Collecting garbage with skos:hiddenLabel
Maybe not collecting garbage in the traditional sense in computer science, but anyhow. The resource skos:hiddenLabel can be used in peculiar ways. The SKOS Reference describes hidden labels as being for text-search cases where the user might misspell a term.
In a pipeline, you can use hidden label to put the spellings of your source systems. For example: postrock, post_rock, and/or POST-ROCK.
m.prefix("""
PREFIX mu: <http://example.org/music/>
SELECT ?name ?unmatched
WHERE {
?b a mu:Band ;
mu:name ?name ;
mu:genre ?unmatched .
FILTER(isLiteral(?unmatched))
}
""") After having updated strings to things, any values for mu:genre that is still a literal value either needs a new hidden label on some existing concept, or a new concept. This is a decision for whoever owns the vocabulary. Remember, do not drop what you could not match!!
Working further with our data example, the result set of this query would be:

When source gives you codes instead of names
Some feeds do not give you “Post Rock”, but rather some code PRCK. In my data example, there are two examples of this. IND for Neubauten and INDR for Nine Inch Nails.
I bet that some readers would go for a dictionary in Python to handle this?
GENRE_CODES = {"PRCK": "post-rock", "INDR": "industrial-rock", ...} # please don'tHow on earth will you maintain this? It will probably be outdated quite fast. A hard-coded dictionary will live in a repository rather than a vocabulary, and no domain expert will ever look at it. SKOS has a property for these things. skos:notation is a string that identifies a concept, but is not a word. Notations are typed literals rather than language tagged strings. Which makes sense! As “PRCK” is certainly not English.
Codes belong in the SKOS vocabulary:
m.update("""
PREFIX mu: <http://example.org/music/>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
DELETE { ?b mu:genreCode ?code }
INSERT { ?b mu:genre ?concept }
WHERE {
?b mu:genreCode ?code .
?concept a skos:Concept ;
skos:notation ?code .
}This follows the same pattern as the previous update query. And if two different source systems use different codes for the same concept, you can use skos:exactMatch and the other mapping resources from SKOS. The crosswalk between vocabularies are also queryable data!
Is the concept in the right scheme?
A property with rdfs:range skos:Concept accepts any concept. Genres, and for example recording formats, are skos:Concept, so nothing stops a mapping bug from putting voc:vinyl as genre. There are two ways to do a check on this.
Using OWL
The OWL way is to define the class of members and use that as the range. A concept scheme is an individual, therefore it can’t be the range of something. rdfs:range requires a class in its object position. However, a hasValue restriction on skos:inScheme can be the range.
voc:GenreConcept a owl:Class ;
owl:equivalentClass [
a owl:Class ;
owl:intersectionOf (
skos:Concept
[ a owl:Restriction ;
owl:onProperty skos:inScheme ;
owl:hasValue voc:Genres ]
)
] .
mu:genre rdfs:range voc:GenreConcept .Using SHACL
The SHACL way is to state it as a constraint on values.
shapes:GenreValue a sh:NodeShape ;
sh:targetObejctsOf mu:genre ;
sh:class skos:Concept ;
sh:property [
sh:path skos:inScheme ;
sh:hasValue voc:Genres ;
sh:minCount 1 ;
sh:meassage "Genre values must come from voc:Genres."@en ;
] .report = m.validate(shape_graph="urn:graph:shapes")
report.results()Even though the OWL and SHACL way might seem similar, they behave in quite different manners. There’s a reason why these two are different specs, they have different design purposes. Let’s have a look:
What the approach does with bad values is perhaps the most important, and the thing you’ll have to concider based on your need. Under the open-world assumption, a range will be a step in inference, so a reasoner will entail membership triples if nothing stated. If it finds the assertion mu:genre voc:vinyl, then an open-world reasoner will conclude that vinyl is a genre.
OWL documents intent in your ontology, not constraints for validation, that you’ll get with SHACL.
Materialise without reasoning
skos:broader is a sub-property of skos:broaderTransitive, which is declared as a transitive property (with OWL). But you don’t need a reasoner to walk this path:
m.query("""
PREFIX mu: <http://example.org/music/>
PREFIX voc: <http://example.org/vocabulary/>
PREFIX skos: <http://www.w3.org/2004/02/skos/core#>
SELECT ?name ?genre
WHERE {
?b mu:name ?name ;
mu:genre ?c .
?c skos:broader* voc:rock ;
skos:prefLabel ?genre .
}
ORDER BY ?name
""")boarder* (zero-or-more) includes the concept itself (voc:rock), broader+ (one-or-more) will exclude it. The result set from this query will look like:

Deafhaven is a part of the result set because it is hitting skos:broader voc:rock after two jumps (blackgaze, shoegaze, rock).
Worth mentioning: taxonomies “cannot do inheritance”. Usually, all we need is to “roll-up”, and a SPARQL property path gives us that for free, while the vocabulary editors stays free to re-parent a genre without touching the ontology model itself.
Never delete anything
Knowledge is a living thing. And vocabularies, they change! Say that the editors decides “industrial metal” and “industrial rock” is two concepts they want to merge, as they are used interchangeably in their catalogue. Deleting a concept can break the graph, and even worse, change the meaning of historical data.
There is no depreciation property in SKOS, but we can borrow this from OWL. After all, it’s all triples.
voc:industrial-metal a skos:Concept ;
skos:inScheme voc:Genres ;
skos:prefLabel "Industrial metal"@en ;
owl:deprecated true ;
dct:isReplacedBy voc:industrial-rock ;
skos:historyNote "Merge into Industrial rock in 2026-03. Was used interchangeably."@en .owl:deprecated marks the term as deprecated, dct:isReplacedBy from the Dublin Core vocabulary points to the new term to be used instead, and skos:historyNote records the why for the person who finds this in five years.
The migration can easily be done in SPARQL:
m.update("""
PREFIX mu: <http://example.org/music/>
PREFIX dct: <http://purl.org/dc/terms/>
PREFIX owl: <http://www.w3.org/2002/07/owl#>
DELETE { ?b mu:genre ?old }
INSERT { ?b mu: genre ?new }
WHERE {
?b mu:genre ?old .
?old owl:deprecated true ;
dct:isReplacedBy ?new .
}
""")If you want to see what’s being moved first, you can run this as a SELECT query to have a look. In maplib, every SELECT-query returns a Polars DataFrame, keep it as a record of the migration.
Validating the vocabulary
Did you know that SHACL can be used for validating more stuff than just the data? After all, it’s all triples!
SKOS has integrity conditions, and we can express them with SHACL and ensure they are getting followed.
shapes:Concept a sh:NodeShape ;
sh:targetClass skos:Concept ;
sh:property [
sh:path skos:prefLabel ;
sh:minCount 1 ;
sh:uniqueLang true ;
sh:disjoint skos:altLabel, skos:hiddenLabel ;
sh:message """A concepts needs exactly one preferred label per language.
Preferred label has to be different from altLabel and hiddenLabel."""@en ;
] ;
sh:property [
sh:path skos:inScheme ;
sh:minCount 1 ;
sh:message "The concept is not in any concept scheme."@en ;
] ;
sh:property [
sh:path skos:definition ;
sh:minCount 1 ;
sh:message "The concept does not have a definition."@en ;
] .
Wrapping up
SKOS is a very, very handy vocabulary for any knowledge graph project. After having received several comments regarding practical solutions for working with SKOS in a pipeline, I decided to put together this piece with the top seven cases that came to mind. I hope this was useful, and happy SKOSing around!
About the author

Ontologist who code in C, Java and Python. Been a knowledge graph practitioner for over a decade, and had numerous talks on the topic around the globe. Knowledge Graph Specialist at Data Treehouse. Author of SHACL for the Practitioner and awarded amongst Norway’s Top 50 Women in Tech 2024.
More about the author and her work at: https://veronahe.no



I like the examples of using skos:hiddenLabel, never seen that much. For deprecated concepts I find the Dublin core (dct:) “valid” property useful. It also accepts a duration, so one can give the concept a time range. And the pair dct: replaces and dct:isReplacedBy gives a simple way of linking old and new concepts. Very good for changed concepts between versions of classifications.