Dr. JSON-LDapothecary for linked data

A minibook in nine chapters

A JSON-LD Handbook

Everything that actually bites, in the order it bites you. Written for someone who can read JSON, has seen a @context, and would like to stop guessing. About an hour end to end.

An open codex

Chapter one

Why a context at all

Two teams each ship a JSON document with a field called name. One means the legal name of a company. The other means the display name of a user account. Both are correct. Neither is wrong. And when the two documents land in the same database, something has to decide whether those fields are the same field.

JSON has no answer. A key in JSON is a string in a namespace of one document. The meaning lives in the documentation, or in the head of whoever wrote the integration, and it does not travel with the data.

JSON-LD's answer is minimal and worth stating plainly: a @context maps the short keys people want to type onto IRIs, which are globally unique. Once name means https://schema.org/name, two documents that use that IRI are talking about the same thing, and two that do not, are not. Nothing else in JSON-LD is as important as this sentence.

{
  "@context": { "name": "https://schema.org/name" },
  "name": "Ada Lovelace"
}

That document says one thing: some unnamed subject has a https://schema.org/name of "Ada Lovelace". A consumer who has never heard of your API can read it. That is the whole pitch.

What a context is not

A context is not a schema. It does not say which fields are required, what a valid value looks like, or which fields may appear together. It has no notion of an error. This surprises people who arrive from JSON Schema or OpenAPI, and it is the root of most JSON-LD pain.

A context cannot reject your document. It can only translate the parts it recognises and discard the parts it does not. A validation layer is a separate thing you must add — SHACL, ShEx, JSON Schema, or a tool that reports lossiness. None of it is in the context.

So the question to hold through the rest of this book is not is my document valid? — the answer is always yes. It is how much of my document survived, and does what survived mean what I intended?

Chapter two

Expansion, the only algorithm that matters

Everything a JSON-LD processor does begins by throwing the context away. Expansion rewrites your document into a form with no context at all: every key is a full IRI, every value is a tagged object, every shorthand is gone. If you understand expansion you understand JSON-LD, and if you do not, nothing else will make sense.

Here is the document from chapter one, expanded:

[{
  "https://schema.org/name": [
    { "@value": "Ada Lovelace" }
  ]
}]

Three things happened. The context vanished. The key became an IRI. And the value became a one-element array containing an object with an @value. Expansion is relentlessly uniform: everything is an array, every literal is an object, nothing is abbreviated. It is deliberately ugly, because it is a canonical form for machines to compare, not a format for people to write.

The property that ruins everything

Expansion is total. It is defined to always succeed. There is no input for which the expansion algorithm raises "I do not know this key". A key the context does not define, and which is not itself an IRI, expands to nothing — and a key that expands to nothing is removed, silently, along with its value and everything nested below it.

{
  "@context": { "name": "https://schema.org/name" },
  "name": "Ada Lovelace",
  "emial": "[email protected]",
  "address": { "city": "London", "country": "GB" }
}

Expanded:

[{
  "https://schema.org/name": [ { "@value": "Ada Lovelace" } ]
}]

The typo is gone. The entire address is gone — not because it was invalid, but because address is undefined, so the key expanded to nothing, and the object beneath it went with the key. Two thirds of the document evaporated and the processor returned success. No warning. Exit code zero.

This is the single behaviour that motivates every tool in this project. L2.key-dropped is the rule id, and it points at the line in your document where the loss happened.

Why is it defined this way? Because JSON-LD was designed so that any JSON document can be given a context and become linked data, with the parts nobody has modelled yet just sitting there harmlessly. That is a real virtue for incremental adoption. It is also why a production pipeline needs something watching the drop count.

What survives without a context

Three kinds of key survive expansion whatever the context says:

  • A keyword@id, @type, @value and the rest. They are part of the language.
  • An absolute IRI — a key that is already https://schema.org/name needs no definition.
  • A compact IRIschema:name, when schema is defined as a prefix. This is the one people overestimate: it requires the prefix to be defined, and an undefined prefix makes the key drop like any other.

There is a fourth, conditional case: if the context sets @vocab, every otherwise-unmatched key is resolved against it. That turns silent loss into silent invention, which is a different problem — chapter seven.

Chapter three

The term definition

A term is an entry in the context. In its short form it is a string; in its long form it is an object, and the object is where all the power is. Learning JSON-LD is largely learning what may go in that object and what each entry changes.

{
  "@context": {
    // short form: just the IRI
    "name": "https://schema.org/name",

    // long form: the IRI plus everything else
    "author": {
      "@id":        "https://schema.org/author",
      "@type":      "@id",
      "@container": "@set"
    }
  }
}

The entries that matter, and what each one governs:

EntryGovernsChapter
@idWhich IRI the key meansthis one
@typeHow the value is interpretedfour
@containerThe JSON shape the value takesfive
@contextA context that applies below this keysix
@languageDefault language tag for string valuesfive
@directionBase text direction for stringsfive
@reverseThe edge points the other wayeight
@nestGroup keys in JSON without meaning anythingsix
@prefixWhether this term may be used as a prefixseven
@protectedDownstream contexts may not redefine itseven
@indexA property that carries the index of an index mapfive

The key is not the term, and neither is the IRI

This is the distinction that most often goes wrong in a long-lived vocabulary. A term has a JSON key and an IRI, and they change independently with completely different consequences:

  • Change the key and every document already written breaks — they use the old key, it is now undefined, and it drops. But the RDF is unchanged: nothing you said about the world became false.
  • Change the IRI and every document keeps parsing exactly as it did. But every statement now means something different, and anything that joined on the old IRI silently stops joining.

One is a breaking change to your consumers' code. The other is a breaking change to their data. They are not the same review, and a diff tool that reports only "the context changed" has told you nothing.

Which is why this tool gives every term a stable element id. The key and the IRI are both mutable attributes of it. A rename is then detected rather than inferred from string similarity, and ldm diff can say renamed where a textual diff can only say one removed, one added.

Chapter four

Coercion: @type in a term definition

JSON has four value types and RDF has thousands. @type in a term definition bridges the gap: it tells the processor how to read the value it finds, and getting it wrong produces the second most expensive bug in JSON-LD after the dropped key.

"@type": "@id" — the value is a reference

Consider two contexts that differ by one line, and one document:

{
  "@context": { "author": "https://schema.org/author" },
  "author": "https://example.org/people/ada"
}

// expands to a string literal that happens to look like a URL:
[{ "https://schema.org/author": [
     { "@value": "https://example.org/people/ada" } ] }]
{
  "@context": { "author": {
      "@id": "https://schema.org/author", "@type": "@id" } },
  "author": "https://example.org/people/ada"
}

// expands to an actual edge in the graph:
[{ "https://schema.org/author": [
     { "@id": "https://example.org/people/ada" } ] }]

The documents are byte-identical. In the first, the graph has a node with a string attached. In the second, the graph has two nodes and an edge between them. Every traversal, every join, every SPARQL query that follows schema:author works in one case and returns nothing in the other.

L2.coercion-did-not-fire: a value that parses as an absolute IRI, under a term with no @type: @id. It is a heuristic, deliberately — sometimes a URL really is a string. It is reported, not refused.

Datatypes

@type can also be a datatype IRI, which tags the literal. This is how you say a string is really a date:

{
  "@context": {
    "born": { "@id": "https://schema.org/birthDate",
              "@type": "http://www.w3.org/2001/XMLSchema#date" }
  },
  "born": "1815-12-10"
}

Without the coercion, "1815-12-10" is a string, and a consumer sorting by date is sorting lexically — which happens to work for ISO dates and fails the moment someone writes 10 December 1815. The datatype is the contract that makes the sort meaningful.

"@type": "@vocab" — the value is a term

The rarer third form. It resolves the value against @vocab rather than as a plain IRI, which is how you get enum-like values that are really IRIs:

{
  "@context": {
    "@vocab": "https://schema.org/",
    "status": { "@id": "https://schema.org/eventStatus",
                "@type": "@vocab" }
  },
  "status": "EventCancelled"   // → https://schema.org/EventCancelled
}

The writer types a short word. The graph gets a global identifier. This is the best available answer to "how do I model an enumeration" in JSON-LD, and it is badly underused.

Chapter five

Containers

@container is the part of JSON-LD that changes the JSON and — in most cases — changes no triple at all. It exists entirely for the convenience of the developer writing and reading the document. That makes it the hardest feature to reason about, because you cannot check your work by looking at the graph.

@set — always an array

The most useful and least interesting. It says: whatever the document writes, treat it as a list of values, and when compacting, always produce an array — even for one element.

This is the closest thing JSON-LD has to future-proofing. A property that is single-valued today and multi-valued in two years does not change the shape of every document that already exists, and does not break every consumer who wrote doc.tags.map(...) against the old one. If you are unsure, use @set. It costs nothing and it is not a semantic statement.

@list — order is meaningful

RDF has no inherent order; a set of triples is a set. @list buys you order by encoding a linked list into the graph, which is expensive and awkward to query, and is correct exactly when the order is part of what you are saying. The authors of a paper are a list. A person's email addresses are not.

{ "@context": { "authors": {
      "@id": "https://schema.org/author",
      "@container": "@list", "@type": "@id" } },
  "authors": [ "ex:ada", "ex:charles" ] }

The maps: @index, @language, @id, @type

Four containers turn an array into an object, so the document can be indexed by something instead of scanned. They differ in whether the key survives expansion.

ContainerThe object's key isIn the graph?
@languagea language tagYes — it becomes the literal's language
@idthe node's IRIYes — it becomes @id
@typethe node's type IRIYes — it becomes @type
@indexanything you likeNo — it is discarded
// @language: the key is data
{ "@context": { "label": {
      "@id": "https://schema.org/name",
      "@container": "@language" } },
  "label": { "en": "Mortar", "la": "Mortarium" } }

// expands to two literals, each carrying its language:
[{ "https://schema.org/name": [
     { "@value": "Mortar",    "@language": "en" },
     { "@value": "Mortarium", "@language": "la" } ] }]

@index is a hole in the bottom of the jar. The index key is thrown away by expansion. It is genuinely useful — it lets a document be organised for the code that reads it — but anything you put there is invisible to every RDF consumer. Put a sort order there; do not put the only copy of a fact there.

JSON-LD 1.1 also allows pairs: ["@graph", "@id"], ["@index", "@set"] and so on. They compose the way you would hope, and they are where processor bugs live — this project's expansion code was wrong about ["@graph", "@index"] until the W3C test suite said so.

Chapter six

Scoped contexts and position

Up to here, the context has been one thing that applies to the whole document. JSON-LD 1.1 broke that assumption in the most useful and most confusing way available: a context can be attached to a term, and then it applies only below that term.

Property-scoped contexts

{
  "@context": {
    "name":    "https://schema.org/name",
    "address": {
      "@id": "https://schema.org/address",
      "@context": {
        "name": "https://schema.org/streetAddress"
      }
    }
  },
  "name": "Ada Lovelace",
  "address": { "name": "12 St James's Square" }
}

Two keys spelled name, in one document, meaning two different properties. This is the right answer to a real problem — nested objects genuinely want their own vocabulary, and forcing globally unique key names across a large document produces addressStreetAddressLine1. But note what it costs: you can no longer tell what a key means by looking at the context. You have to know where in the document the key appears.

Which is why the canvas draws a scoped context as a region containing the terms it affects, rather than as an annotation on the term that declares it. Its effect is positional, so the picture should be positional.

Type-scoped contexts

The same idea keyed on @type rather than on the property. A context attached to a type definition applies to any node object declaring that type. Unlike property-scoped contexts, type-scoped contexts do not propagate into nested nodes by default — they apply to the node bearing the type and stop there, unless "@propagate": true says otherwise.

Nobody remembers this rule. Write a test for it.

@nest — grouping with no meaning

@nest lets a document group keys under a JSON object that has no semantic existence at all. It is there to make a document readable when a flat node object would have thirty keys.

{
  "@context": {
    "details": "@nest",
    "name":  "https://schema.org/name",
    "born":  { "@id": "https://schema.org/birthDate",
                "@nest": "details" }
  },
  "name": "Ada Lovelace",
  "details": { "born": "1815-12-10" }
}

On expansion, details disappears entirely and born is a direct property of the node. The nesting is presentation. There is no details node, no details edge, nothing.

This is the gap the two-pane canvas exists to show. @nest is a box in the JSON pane and is absent in the graph pane. @container: @set changes the JSON pane and leaves the graph pane still. A scoped context is a region in both. Once you can see the two pictures side by side, the 1.1 feature set stops being a list of tricks and becomes two coherent halves.

Chapter seven

Vocab, base, and protection

Three context-level keywords that change how everything else resolves. Each of them is a convenience with a long tail, and each is worth an explicit decision rather than a copied line.

@vocab

@vocab sets a fallback: any key not otherwise matched is appended to it. "@vocab": "https://schema.org/" means widget becomes https://schema.org/widget, whether or not schema.org has ever heard of a widget.

So @vocab converts silent loss into silent invention. Which of those you prefer depends entirely on your situation. For an internal vocabulary you control, where the base is your own namespace, @vocab is excellent: every key gets an IRI and typos become obviously-wrong IRIs rather than nothing. For a document mapped onto someone else's published vocabulary, it is dangerous: it will happily mint https://schema.org/emial and that IRI will never join with anything.

Do not set @vocab to a namespace you do not own. You are not extending their vocabulary. You are creating IRIs in their namespace that they have not defined and will not resolve, and your data now asserts things in a space you have no authority over.

@base

@base resolves relative IRIs in @id values — not keys. "@id": "people/ada" against a base of https://example.org/ becomes https://example.org/people/ada.

With no @base and no document URL, a relative @id stays relative, and a relative IRI in RDF is not an identifier — it is a fragment waiting for a context that may never come. Two consumers who dereferenced your document from different URLs now disagree about what your node is called. Make the base explicit, or make every @id absolute.

@protected

New in 1.1, and the closest JSON-LD comes to a contract. A protected term cannot be redefined by a context layered on top of it — an attempt is an error rather than an override.

This matters because contexts compose at runtime. A document can list several contexts, and later ones win. Without protection, a document can include your vocabulary and then quietly redefine name to mean something else, and every statement it makes with your term is now a statement about something you did not define. Protection is a promise to downstream consumers that a term they resolved once will keep meaning that.

Protect the terms that are load-bearing for verification or for identity. It is the same instinct as final: use it where you mean it, not everywhere.

Chapter eight

Compaction, and the round trip that isn't

Compaction is expansion run backwards: given expanded JSON-LD and a context, produce the shortest, most idiomatic document that expands back to the same thing. It is what makes JSON-LD pleasant to read, and it is not the inverse of expansion.

Expansion is lossy in one direction — it discards which of several possible spellings you chose. Compaction has to pick one, and the algorithm for picking is genuinely intricate: it builds an inverse index from the context, then for each property selects the term whose container, type, and language best match the value in hand, breaking ties by shortest-then-lexicographic order.

So a document can round-trip and come back different:

// you wrote
{ "@context": {...}, "tags": "apothecary" }

// expand then compact, with "tags" having @container: @set
{ "@context": {...}, "tags": [ "apothecary" ] }

Same graph. Different bytes. This is correct behaviour and it catches people who expected canonical JSON out of a canonicalisation step. If you need stable bytes — for a signature, for a checksum, for a diff — you need canonicalization proper (URDNA2015 over the N-Quads), not a compaction round trip.

@reverse

One last feature, best introduced here because it is most visible in compaction. @reverse lets a document state an edge from the object's side:

{ "@context": { "wrote": {
      "@reverse": "https://schema.org/author",
      "@type": "@id" } },
  "@id": "https://example.org/people/ada",
  "wrote": "https://example.org/notes/g" }

The triple produced has the note as its subject and Ada as its object. The document reads as though Ada is the subject throughout, which is often how a person wants to write it. In the graph pane, the arrow points the other way from where the JSON pane suggests — which is exactly the sort of thing worth drawing rather than describing.

Chapter nine

Publishing a context

The last chapter is the one that is not about the specification. You have a context that works. You put it at a URL. From that moment it is infrastructure, and the rules that govern infrastructure are not the rules that governed the file on your laptop.

A published context URL is a promise

Documents in the wild reference your context by URL, and resolve it at read time, possibly years from now, possibly in a verification step where a change in meaning is a security event. What you serve at that URL is not a file you own any more. It is an interface.

The consequence is unpopular and simple: a published context should never change in place. Publish a new URL and leave the old one serving what it always served. Every scheme that works — DID method contexts, the W3C credential contexts, schema.org's versioned releases — is this scheme.

Immutability, mechanically

"Never change it" is a policy, and policies decay. The mechanical version is to make the identity of a version derive from its content, so that changing it produces a different version rather than a changed one. That is what this tool does: a version is content-addressed, it holds the model as written, the lockfile, the examples and the emitted artifacts, and a manifest checksums every file and then checksums itself. The store refuses every write into a version that exists.

Humans still need names. So names are a separate, mutable layer: stable is a label that points at a version, and moving it leaves every published byte untouched. There is no command to rename a version, because a version's name is its content hash and there is nothing there to rename.

Know what kind of change you are making

Before you publish the next version, the useful question is not what changed but what kind of change was it:

ClassMeaning
additiveNew terms. Existing documents unaffected.
compatibleChanged, but every existing document still expands the same.
breakingExisting documents expand differently or lose content.
semanticDocuments parse identically and now mean something else.
illegalViolates a promise already made — redefining a protected term.

semantic is the row that justifies the whole table. It is invisible to every test you have, because every test still passes. An ambiguous change is classified breaking, on the principle that the cost of an unnecessary major version is an afternoon and the cost of a missed one is a year.

And vendor what you reference. If your context references someone else's, your build depends on their server and their editorial decisions. Fetch it once, commit it, record its hash, and read only the committed copy. Then a change on their side is a diff in your pull request instead of a Tuesday outage.

That is the book. The specification has more in it — framing, flattening, conversion to and from RDF datasets, the HTML embedding — but none of it will confuse you the way the nine chapters above will, and all of it rests on expansion.