skip to content

In OpenAPI, how do file and URL $ref targets resolve, and what breaks when a spec is split across files?

level: middleimportance: should knowfreq 50%

answer

  1. A $ref value is a URI
  2. Relative to the referring file
  3. Fragments walk the target document
  4. Bundle before you publish
  5. Bundle keeps names; inlining copies shapes

basics

~20 s

Relative file references resolve against the base URI of the document containing the reference, not the root spec, and may carry a fragment pointer into the target. URL references fetch at build time. Split specs commonly break because tools resolve differently, so bundle before publishing.

solid answer

~40 s

A `$ref` value is a URI. `'./schemas/order.yaml'` pulls in that whole file; `'./common.yaml#/components/schemas/Error'` pulls one node out of it; `'https://example.com/common.yaml#/Error'` fetches over the network. The critical rule is that a relative reference resolves against the **base URI of the file it appears in**, so a reference written inside `schemas/order.yaml` is relative to the `schemas/` directory, not to the root document — getting this wrong is the classic multi-file bug. Splitting also exposes tooling variance: browser-based renderers must fetch each file over HTTP and hit CORS, some validators and generators only resolve local references, and a URL reference makes your build depend on someone else's uptime and mutable content. The standard fix is to **bundle**: rewrite external references into a single self-contained document before you publish or generate.

code

yaml · 22 lines
yaml
# openapi.yaml  (project root)
paths:
  /orders:
    $ref: './paths/orders.yaml'
components:
  schemas:
    Order:
      $ref: './schemas/order.yaml'
    Error:
      $ref: './shared/common.yaml#/schemas/Error'

# schemas/order.yaml  -- a bare schema document
type: object
properties:
  id:
    type: string
  total:
    # relative to schemas/, NOT to the project root
    $ref: './money.yaml'
  problem:
    # one level up, then into shared/
    $ref: '../shared/common.yaml#/schemas/Error'

go deeper

for a junior

Recognise the three shapes of a reference — local #/..., a file path, and a URL — and know that a fragment after # points inside the target document.

for a middle

Explain base-URI resolution precisely: relative references are resolved against the file they are written in, so a nested file's references are relative to its own directory.

for a senior

Talk about operating a split spec — resolver differences between IDE and CI, CORS on browser renderers, pinning third-party documents, and publishing a bundled artefact.

for a principal

Decide the authoring topology: one file or many, which shared components are org-owned, and how the bundled artefact is produced, versioned and validated in the pipeline.

## References are URIs, not includes Every `$ref` value is a URI reference with two optional halves: a document identifier and a `#` fragment. That gives three shapes. - **Local** — `#/components/schemas/Order`. No document part, so the fragment is a JSON Pointer into the current document. - **File** — `./schemas/order.yaml` refers to another document as a whole; `./common.yaml#/components/schemas/Error` refers to one node inside it. The fragment is still a JSON Pointer, now walked from *that* file's root. - **URL** — `https://example.com/specs/common.yaml#/components/schemas/Error`. Same rules, fetched over the network. The target file does not have to be a valid OpenAPI document. A file that contains nothing but a bare schema is perfectly normal; `./schemas/order.yaml` with `type: object` at its root is a common layout. ## Base URI resolution — the bug everyone hits A relative reference is resolved against the base URI of the document in which it is written. If `openapi.yaml` at the project root says `$ref: './schemas/order.yaml'`, that is `<root>/schemas/order.yaml`. If `schemas/order.yaml` then says `$ref: './money.yaml'`, that resolves to `<root>/schemas/money.yaml` — relative to `schemas/`, not to the root. People who assume all paths are root-relative write `$ref: './schemas/money.yaml'` inside `schemas/order.yaml` and get a resolver error pointing at a path that does not exist. The corollary: moving a file changes the meaning of every relative reference it contains and every relative reference pointing at it. Directory reshuffles in a split spec are mechanical but never free. ## Fragments across files When a fragment is present, it is a JSON Pointer into the target document, with the usual escaping (`~1` for `/`, `~0` for `~`). Nothing requires the target's structure to mirror the root document's, so `./common.yaml#/schemas/Error` is fine if `common.yaml` simply has a top-level `schemas` key — it does not need a `components` wrapper. A subtlety: a reference into another file drags that node's own references with it. If `common.yaml`'s `Error` schema references `./problem.yaml`, then pulling in `Error` also pulls in `problem.yaml`, resolved relative to `common.yaml`. Transitive closure is easy to underestimate when deciding what a "small shared file" costs. ## What breaks in practice **Tool variance.** Local references are universally supported; external ones are not. Some validators, mock servers and generators resolve only what is in the file they were handed, or resolve files but not URLs. A split spec that works in your IDE can fail in CI simply because a different resolver is involved. **Browser fetching.** A docs renderer running in a browser must fetch each referenced file over HTTP, so every file must be served from a reachable location with permissive CORS headers. This is why teams publish a bundled single file for documentation even when they author many. **Network references at build time.** A `https://` reference means your build reaches out to a third party. That is an availability dependency (their outage breaks your build), a reproducibility problem (the content can change under you with no version pin), and a supply-chain surface (whatever that document says becomes part of your generated code). If you must consume someone else's spec, vendor a pinned copy into your repository. **Duplicate names on merge.** Two files may each define a `Metadata` schema. When a bundler folds them into one document, it must rename one of them, and the renamed component surfaces in generated SDKs as something like a suffixed type. Consistent naming across files avoids the churn. ## Bundling versus dereferencing Two distinct operations are often confused. **Bundling** produces one self-contained document that still uses `$ref`, with external targets hoisted into `components` and the references rewritten to local pointers. Shared shapes keep a single name, so generated code keeps one class per model. This is what you publish and what you feed to generators. **Dereferencing (inlining)** replaces every reference with a copy of its target. The result has no references at all, which some primitive tools require, but it duplicates shapes, loses the names, and cannot terminate on a recursive schema. Redocly CLI's `bundle` command performs both modes and is the common choice; older setups use the APIDevTools `swagger-cli bundle`. Whichever you use, treat the bundled artefact as a build output: generate it in CI from the authored sources, and validate and lint the bundle rather than the fragments, because the bundle is what consumers actually see.

  • A reference inside schemas/order.yaml points at './money.yaml' and fails. Why?
    Almost always a base-URI mistake. Relative references resolve against the file that contains them, so from `schemas/order.yaml` that path means `schemas/money.yaml`. If `money.yaml` actually sits at the project root the correct reference is `'../money.yaml'`. The same trap bites whenever a shared file is moved between directories.
  • What is the difference between bundling and dereferencing a spec?
    Bundling produces a single document that still contains `$ref`s, with external targets hoisted into `components` and pointers rewritten locally — names survive, so generators still emit one class per model. Dereferencing inlines every target, removing all references; it duplicates shapes, discards names and cannot terminate on recursive schemas.
  • Is it acceptable to $ref a spec hosted on another team's URL directly from your build?
    Rarely. It makes your build depend on their uptime, gives you no version pin against silent changes, and lets their content flow straight into your generated code. Vendor a pinned copy into your repository and update it deliberately, so the change shows up as a reviewable diff.

saying these in an interview costs you the question

  • Thinks relative paths resolve from the root document
  • Assumes every tool resolves external references
  • Believes bundling and inlining are the same operation
  • Refs a live third-party URL from CI without pinning
  • Forgets a referenced node drags its own references along

context