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.
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,@valueand the rest. They are part of the language. - An absolute IRI — a key that is already
https://schema.org/nameneeds no definition. - A compact IRI —
schema:name, whenschemais 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:
| Entry | Governs | Chapter |
|---|---|---|
@id | Which IRI the key means | this one |
@type | How the value is interpreted | four |
@container | The JSON shape the value takes | five |
@context | A context that applies below this key | six |
@language | Default language tag for string values | five |
@direction | Base text direction for strings | five |
@reverse | The edge points the other way | eight |
@nest | Group keys in JSON without meaning anything | six |
@prefix | Whether this term may be used as a prefix | seven |
@protected | Downstream contexts may not redefine it | seven |
@index | A property that carries the index of an index map | five |
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.
| Container | The object's key is | In the graph? |
|---|---|---|
@language | a language tag | Yes — it becomes the literal's language |
@id | the node's IRI | Yes — it becomes @id |
@type | the node's type IRI | Yes — it becomes @type |
@index | anything you like | No — 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:
| Class | Meaning |
|---|---|
additive | New terms. Existing documents unaffected. |
compatible | Changed, but every existing document still expands the same. |
breaking | Existing documents expand differently or lose content. |
semantic | Documents parse identically and now mean something else. |
illegal | Violates 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.