Operational Context
A taxonomy developer maintaining a museum collection management system hits the same wall on every enrichment pass: the catalogue stores materials, object types, and places as free text — oil paint, Oil Paints, oils — and each one has to be hand-resolved to a stable Getty identifier before it can key a controlled-vocabulary index. Doing that lookup interactively, one term at a time against a browser tab, is where hours disappear and where the same twelve materials get re-resolved every week to (sometimes) slightly different URIs. This page is the artifact that ends the repetition: a curated, bookmarkable set of tables that map the common free-text terms a collection actually contains to their Art & Architecture Thesaurus (AAT) and Thesaurus of Geographic Names (TGN) URIs, plus a small reusable lookup that turns those tables into code. It sits inside the Getty AAT and TGN implementation stage of the Core Architecture and Collection Taxonomy pipeline, upstream of the network resolver — you match against this table first, and only the misses go to resolving Getty vocabulary URIs with SPARQL.
Root Cause Analysis
The pain is not that Getty is hard to query — it is that the same query gets issued endlessly against inputs that vary in ways the vocabulary does not care about. Three root causes compound.
First, free-text variance. Bronze, bronze, and bronze are the same concept, but a raw string index treats them as three keys, so any lookup that is not preceded by a normalization step misses its own row. The variance is superficial — case, whitespace, trailing punctuation — yet it multiplies the apparent vocabulary many times over.
Second, repeated manual resolution. Without a shared table, every developer resolves etching afresh, and there is no single place where the institution’s decision — this free-text string resolves to that URI — is recorded. Two people can resolve the same term to two different (both plausible) AAT concepts, and nothing catches the divergence.
Third, no stable, versioned mapping. Getty preferred labels can change; a good pipeline keys on the immutable URI, not the label. But the mapping from your local free-text string to that URI is itself an institutional decision that deserves to be curated, reviewed, and versioned like any other reference data. The remedy for all three is the same: fix the mapping in a table, normalize inputs before matching, and treat unmatched terms as a review signal rather than a silent default.
Canonical Solution
The solution is two things kept deliberately separate: the tables (curated reference data, below) and a small lookup that consumes them. Present the tables so a human can bookmark and read them, then load the identical rows into code for batch resolution. Every URI is an immutable Getty subject identifier — http://vocab.getty.edu/aat/{id} for AAT concepts, http://vocab.getty.edu/tgn/{id} for places — so a match here yields the exact key the rest of the pipeline persists.
Materials → AAT
| Free-text term | Getty AAT URI | Preferred label |
|---|---|---|
| oil paint | http://vocab.getty.edu/aat/300015050 | oil paint |
| watercolor | http://vocab.getty.edu/aat/300015045 | watercolor (paint) |
| bronze | http://vocab.getty.edu/aat/300010957 | bronze (metal) |
| marble | http://vocab.getty.edu/aat/300011443 | marble (rock) |
| silver | http://vocab.getty.edu/aat/300011029 | silver (metal) |
| gold | http://vocab.getty.edu/aat/300011021 | gold (metal) |
| oak | http://vocab.getty.edu/aat/300012264 | oak (wood) |
| canvas | http://vocab.getty.edu/aat/300014109 | canvas (textile material) |
| graphite | http://vocab.getty.edu/aat/300011098 | graphite |
| ceramic | http://vocab.getty.edu/aat/300235507 | ceramic (material) |
| ivory | http://vocab.getty.edu/aat/300011857 | ivory (material) |
| glass | http://vocab.getty.edu/aat/300010797 | glass (material) |
Object types → AAT
| Free-text term | Getty AAT URI | Preferred label |
|---|---|---|
| painting | http://vocab.getty.edu/aat/300033618 | paintings (visual works) |
| sculpture | http://vocab.getty.edu/aat/300047090 | sculpture (visual work) |
| http://vocab.getty.edu/aat/300041273 | prints (visual works) | |
| etching | http://vocab.getty.edu/aat/300041365 | etchings (prints) |
| drawing | http://vocab.getty.edu/aat/300033973 | drawings (visual works) |
| photograph | http://vocab.getty.edu/aat/300046300 | photographs |
| vase | http://vocab.getty.edu/aat/300132254 | vases (containers) |
| coin | http://vocab.getty.edu/aat/300037222 | coins (money) |
| manuscript | http://vocab.getty.edu/aat/300028569 | manuscripts (documents) |
| poster | http://vocab.getty.edu/aat/300027221 | posters |
| furniture | http://vocab.getty.edu/aat/300037680 | furniture |
| textile | http://vocab.getty.edu/aat/300014063 | textiles (visual works) |
Roles → AAT
| Free-text term | Getty AAT URI | Preferred label |
|---|---|---|
| artist | http://vocab.getty.edu/aat/300025103 | artists |
| painter | http://vocab.getty.edu/aat/300025136 | painters (artists) |
| sculptor | http://vocab.getty.edu/aat/300025194 | sculptors |
| printmaker | http://vocab.getty.edu/aat/300025214 | printmakers |
| photographer | http://vocab.getty.edu/aat/300025791 | photographers |
| engraver | http://vocab.getty.edu/aat/300025058 | engravers |
| draftsman | http://vocab.getty.edu/aat/300025208 | draftsmen |
| architect | http://vocab.getty.edu/aat/300024987 | architects |
| donor | http://vocab.getty.edu/aat/300025450 | donors |
| patron | http://vocab.getty.edu/aat/300025234 | patrons |
Place names → TGN
| Free-text term | Getty TGN URI | Preferred label |
|---|---|---|
| Paris | http://vocab.getty.edu/tgn/7008038 | Paris (France) |
| London | http://vocab.getty.edu/tgn/7011781 | London (England) |
| Rome | http://vocab.getty.edu/tgn/7000874 | Rome (Italy) |
| Florence | http://vocab.getty.edu/tgn/7000457 | Florence (Italy) |
| Venice | http://vocab.getty.edu/tgn/7017455 | Venice (Italy) |
| Amsterdam | http://vocab.getty.edu/tgn/7006952 | Amsterdam (Netherlands) |
| Berlin | http://vocab.getty.edu/tgn/7003712 | Berlin (Germany) |
| New York | http://vocab.getty.edu/tgn/7007567 | New York (state) |
| Athens | http://vocab.getty.edu/tgn/7001393 | Athens (Greece) |
| Kyoto | http://vocab.getty.edu/tgn/7002766 | Kyoto (Japan) |
| Cairo | http://vocab.getty.edu/tgn/7002516 | Cairo (Egypt) |
| Beijing | http://vocab.getty.edu/tgn/7001758 | Beijing (China) |
The lookup
The tables above are the specification; the code below is the enforcement. It normalizes an incoming string, tries an exact key, then a synonym alias, then a deprecated-term redirect, and finally reports an unmatched term for review rather than guessing.
from __future__ import annotations
import csv
import re
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
class MatchKind(str, Enum):
EXACT = "exact" # normalized term is a table key
SYNONYM = "synonym" # matched an alias that points at a table key
REDIRECTED = "redirected" # matched a deprecated term, resolved to its successor
UNMATCHED = "unmatched" # no mapping — route to curator review, never default
@dataclass(frozen=True)
class Resolution:
term: str # the original free-text input, preserved for the audit log
uri: str | None # the resolved Getty URI, or None when unmatched
kind: MatchKind
_WS = re.compile(r"\s+")
def normalize(term: str) -> str:
# Fold the free-text variance the vocabulary does not care about: case,
# surrounding whitespace, collapsed internal runs, and a trailing period.
# " Oil Paint. " and "oil paint" both become the single key "oil paint".
# Plurals are handled by the synonym table, not here, so "canvas" is never
# mangled into "canva" by a naive de-pluralizer.
return _WS.sub(" ", term.strip().lower()).rstrip(".")
# The Materials → AAT rows above, loaded as the canonical mapping. In practice
# you would keep this in a versioned CSV (see load_table) rather than inline.
MATERIALS: dict[str, str] = {
"oil paint": "http://vocab.getty.edu/aat/300015050",
"watercolor": "http://vocab.getty.edu/aat/300015045",
"bronze": "http://vocab.getty.edu/aat/300010957",
"marble": "http://vocab.getty.edu/aat/300011443",
"canvas": "http://vocab.getty.edu/aat/300014109",
"graphite": "http://vocab.getty.edu/aat/300011098",
}
# Aliases collapse spelling and number variants onto a single canonical key,
# so the mapping itself stays one row per concept.
SYNONYMS: dict[str, str] = {
"oils": "oil paint",
"oil paints": "oil paint",
"watercolour": "watercolor",
"pencil": "graphite",
}
# Deprecated free-text the catalogue still emits, redirected to the current key.
DEPRECATED: dict[str, str] = {
"india rubber": "canvas", # illustrative: an obsolete local term → current concept
}
def resolve(
term: str,
table: dict[str, str],
synonyms: dict[str, str] | None = None,
deprecated: dict[str, str] | None = None,
) -> Resolution:
key = normalize(term)
if key in table:
return Resolution(term, table[key], MatchKind.EXACT)
if synonyms and (target := normalize(synonyms.get(key, ""))) in table:
return Resolution(term, table[target], MatchKind.SYNONYM)
if deprecated and (target := normalize(deprecated.get(key, ""))) in table:
return Resolution(term, table[target], MatchKind.REDIRECTED)
return Resolution(term, None, MatchKind.UNMATCHED)
def load_table(path: str | Path) -> dict[str, str]:
# Read a two-column CSV (term,uri) so the mapping lives in versioned data,
# not source. Keys are normalized on load so the table and lookups agree.
with open(path, newline="", encoding="utf-8") as fh:
return {normalize(row["term"]): row["uri"] for row in csv.DictReader(fh)}The normalization step is what makes the table small: one row per concept absorbs every casing and spacing variant, and the synonym layer absorbs the spelling and number variants that a keystroke normalizer should not touch. Because resolve never invents a URI, an unrecognized term is a visible UNMATCHED result you can count and route, not a silent fallback that quietly poisons an index.
Edge Cases and Variants
- Synonyms and spelling variants. British spellings (
watercolour), plurals (oil paints), and colloquial forms (pencilforgraphite) resolve through the synonym layer onto one canonical key, so the mapping stays one row per concept while the lookup absorbs the surface variation. - Deprecated terms. When Getty deprecates a concept in favor of a successor, or when your own catalogue retires a legacy local term, record it in the
DEPRECATEDmap so old inputs redirect to the current URI and returnMatchKind.REDIRECTED— a signal you can log and use to schedule a source-data cleanup. - Hierarchy and broader terms. These tables map to the most specific concept a free-text string names; they deliberately do not walk the
gvp:broaderPreferredchain. When you need the parent concept —printsaboveetchings— resolve the URI and follow the hierarchy at the network layer, as resolving Getty vocabulary URIs with SPARQL shows. - CSV vs. inline vs. API source.
load_tablereads the same rows from a versioned CSV so the mapping lives in reviewable data; an inline dict is fine for a fixture or test; and a term that misses every table is precisely the input you hand to the live resolver, so the table acts as a cache in front of the network. - Strict vs. lenient matching. This lookup is exact-after-normalization by design — it never fuzzy-matches, because auto-accepting a near miss is how the wrong
objectWorkTypereaches a public facet. Fuzzy reconciliation is a separate, curator-in-the-loop step whose output is a chosen URI you then add to the table.
Validation
The one invariant a reference table must never violate is a malformed URI, because a broken identifier propagates silently into every downstream index. This test asserts that every value in every table is a well-formed Getty subject URI and that the lookup reports the right match kind for each path.
# test_mapping_tables.py -> run with: pytest -q test_mapping_tables.py
import re
_GETTY_URI = re.compile(r"^http://vocab\.getty\.edu/(aat|tgn)/\d+$")
ALL_TABLES = {"materials": MATERIALS} # extend with object_types, roles, places
def test_every_uri_is_well_formed():
for name, table in ALL_TABLES.items():
for term, uri in table.items():
assert _GETTY_URI.match(uri), f"{name}: {term!r} -> bad URI {uri!r}"
def test_keys_are_already_normalized():
for table in ALL_TABLES.values():
for key in table:
assert key == normalize(key), f"un-normalized key: {key!r}"
def test_exact_and_synonym_and_unmatched_paths():
assert resolve(" Oil Paint. ", MATERIALS).kind is MatchKind.EXACT
assert resolve("oils", MATERIALS, SYNONYMS).kind is MatchKind.SYNONYM
assert resolve("watercolour", MATERIALS, SYNONYMS).uri == MATERIALS["watercolor"]
miss = resolve("unobtainium", MATERIALS, SYNONYMS, DEPRECATED)
assert miss.uri is None and miss.kind is MatchKind.UNMATCHEDA green run proves that no table ships a broken identifier, that every stored key is normalized (so lookups and rows agree), and that the exact, synonym, and unmatched paths each behave — including that a genuinely unknown term returns None rather than a wrong guess.
Standards Alignment
These tables are an application of the Getty Vocabularies as Linked Open Data: every URI is a dereferenceable subject identifier in the AAT or TGN scheme, and appending .json to any of them returns the SKOS/GVP representation the network resolver parses. Keying on those URIs rather than labels is what keeps a controlled vocabulary stable — a preferred label may be revised at Getty, but the identifier this table records does not move, which is the same discipline the Getty AAT and TGN implementation stage enforces end to end. Downstream, a resolved AAT identifier lands in a LIDO objectWorkType or termMaterialsTech element and a TGN identifier in place, which is exactly what the LIDO-to-internal-database mapping layer persists; the same IRIs carry through into schema:material nodes and IIIF Presentation API 3.0 manifest metadata so a viewer can link back to the authority record.
FAQ
Should I store the Getty label or the URI in my database?
Store the URI. Getty preferred labels can be revised, but the subject identifier is immutable — that is the entire reason this table maps to URIs. Persist the URI as the durable key and treat the label as a cached, refreshable attribute; re-resolving a term should be able to overwrite the displayed label without breaking any foreign key.
How do I keep the tables current when Getty changes a concept?
Treat the mapping as versioned reference data. When Getty deprecates a concept in favor of a successor, add the old term to the DEPRECATED map so inputs redirect to the current URI and surface a REDIRECTED result you can review. Periodically re-resolve each URI at the network layer and diff the returned preferred label against the one recorded here to catch upstream relabelling.
What happens to a term that is not in any table?
It returns MatchKind.UNMATCHED with a None URI. That is deliberate — an unrecognized term is a review signal, not a default. Route it to the curator queue with its original string attached, and only once a curator confirms a URI does it earn a new row in the table.
Can I fuzzy-match to fill gaps automatically?
Not into these tables. The lookup is exact after normalization precisely so that a near miss never auto-resolves; the wrong objectWorkType on a public facet is far more expensive than a review flag. Fuzzy reconciliation belongs in a separate, curator-confirmed step whose approved output is added here as an exact row.
Related
- Implementing Getty AAT & TGN — parent resolution stage
- Resolving Getty Vocabulary URIs with SPARQL — network resolution for table misses
- Mapping LIDO to Internal Databases — persists resolved URIs
- Core Architecture & Collection Taxonomy — the enclosing pipeline stage
- Designing Museum Object Schemas — queues terms for enrichment