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)
print 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.

python
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.

A normalized term tries exact, then synonym, then deprecated; anything unmatched goes to review A free-text term enters a normalize box that folds case, whitespace, and trailing punctuation into a single key. The key passes through three decision diamonds in sequence — exact key match, synonym or alias, deprecated term — and any yes exits upward to a single Resolved Getty URI box. A no from the final deprecated check drops down to a dashed Review queue terminal, so an unmatched term is flagged for a curator rather than silently defaulted. Match exact, then synonym, then deprecated — anything else is reviewed, not guessed Resolved Getty URI aat/… or tgn/… · immutable key Free-text term Normalize case · space · dot exact key? synonym alias? deprecated term? Review queue unmatched → curator no no yes · exact yes · alias yes · redirect no · unmatched

Edge Cases and Variants

  • Synonyms and spelling variants. British spellings (watercolour), plurals (oil paints), and colloquial forms (pencil for graphite) 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 DEPRECATED map so old inputs redirect to the current URI and return MatchKind.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:broaderPreferred chain. When you need the parent concept — prints above etchings — 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_table reads 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 objectWorkType reaches 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.

python
# 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.UNMATCHED

A 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.