LinkML logoQuickstart

LinkML to Postgres in One Call: A CoreModels Quickstart

Ten minutes, one YAML file, one HTTP call. By the end of this you will have converted a LinkML schema into Postgres DDL through CoreModels, read the machine-readable ledger of everything the conversion could not carry exactly, and sent the same schema back out as LinkML unchanged.

LinkML to Postgres in One Call: A CoreModels Quickstart

Ten minutes, one YAML file, one HTTP call. By the end of this you will have converted a LinkML schema into Postgres DDL through CoreModels, read the machine-readable ledger of everything the conversion could not carry exactly, and sent the same schema back out as LinkML unchanged.

LinkML is a YAML schema language: classes, slots, enums, and CURIEs that point at real ontology terms. CoreModels (by ARAMAI) implements it as a first-class transform format under the key linkml, and it works in both directions — we decode LinkML YAML into our neutral model and we encode that model back to LinkML YAML. Everything below is the encode/decode pair doing real work.

Before you start

  • An API host. We write https://coremodels.example.com throughout — substitute your deployment.
  • A bearer token in $TOKEN. The call in this quickstart is stateless and needs only Viewer on the scoping project.
  • A project id in $PROJECT_ID. It scopes authorization; nothing is written to it by the first call.

Transform routes live under graph/transform/... with no api/ prefix, and every response uses the same envelope: success, lossiness, errors, plus the payload key for that route.

The schema

Save this as sensor.yaml. It is small but not a toy: two classes, a class-to-class reference, a multivalued slot, an enum with ontology meaning CURIEs, a class_uri, a slot_uri, and a constraint facet.

id: https://example.org/sensor-registry
name: sensor_registry
prefixes:
  linkml: https://w3id.org/linkml/
  schema: https://schema.org/
  qudt: http://qudt.org/schema/qudt/
imports:
  - linkml:types
default_range: string

classes:
  Sensor:
    description: A deployed measurement device.
    class_uri: schema:Thing
    attributes:
      sensor_id:
        description: Stable identifier for the device.
        identifier: true
        required: true
      name:
        slot_uri: schema:name
        required: true
      installed_on:
        range: datetime
      sampling_interval_s:
        range: integer
        minimum_value: 1
      unit:
        range: UnitEnum
      readings:
        range: Reading
        multivalued: true
  Reading:
    description: One measurement emitted by a sensor.
    attributes:
      taken_at:
        range: datetime
        required: true
      value:
        range: float
        required: true
      qc_flag:
        range: boolean

enums:
  UnitEnum:
    description: Units the registry accepts.
    permissible_values:
      CELSIUS:
        description: Degrees Celsius.
        meaning: qudt:DEG_C
      PASCAL:
        meaning: qudt:PA
      PERCENT:

The call

schema/map is the stateless conversion route: it decodes your source, runs it through the mapping engine, and encodes the result into the target format. Nothing touches the project.

One thing to understand before you run it: the engine always works from a plan, and the default strategy (inferred) builds that plan by matching labels against a target hint — the vocabulary you are mapping toward. When all you want is a format conversion, the source schema's own labels are the target vocabulary, so pass the same document as the hint. (Skip the hint and the call fails honestly with The inference resolver requires a target IR to match against.)

jq -n --rawfile s sensor.yaml \
  '{sourceFormat:"linkml", sourceSchema:$s,
    targetFormat:"sql", vendor:"postgres",
    targetHintFormat:"linkml", targetHintSchema:$s,
    mapping:{kind:"inferred"}}' \
| curl -s "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @-

The jq -n --rawfile step exists only to JSON-escape the YAML; the body it produces is ordinary JSON. Add | jq -r '.schema' to the end and you get the DDL straight out:

CREATE TABLE "Sensor" (
  "sensor_id" VARCHAR(255) NOT NULL,
  "name" VARCHAR(255) NOT NULL,
  "installed_on" TIMESTAMP,
  "sampling_interval_s" INTEGER,
  "unit" VARCHAR(255),
  "readings" INTEGER REFERENCES "Reading"
);

CREATE TABLE "Reading" (
  "taken_at" TIMESTAMP NOT NULL,
  "value" NUMERIC NOT NULL,
  "qc_flag" BOOLEAN
);

COMMENT ON COLUMN Sensor.name IS '{"x-maps-to":{"schema":"https://schema.org/name"}}';

Look at what came across without you configuring anything. required: true became NOT NULL. range: datetime became TIMESTAMP, range: float became NUMERIC, range: integer became INTEGER, range: boolean became BOOLEAN. The class-to-class range became a foreign-key reference. And slot_uri: schema:name — a CURIE — was expanded through your prefixes: block into the full IRI https://schema.org/name and emitted as a column comment. Meaning travels; it does not evaporate at the format boundary.

The ledger

The same response carries a lossiness array. For this input it has exactly one entry:

{
  "kind": "ConstraintRelaxation",
  "path": "Element[SensorUnit]",
  "explanation": "Postgres has no inline enum; emitted as VARCHAR (the allowed-value constraint is not enforced)."
}

That is the habit worth building on call one: success: true means the call ran, not that nothing changed. The ledger is the exact, reviewable list of what the target format could not hold. Here it is telling you something you would otherwise discover in production — the UnitEnum values are still documented in your LinkML, but the Postgres column will accept any string.

Every record has one of four kinds, and they mean precisely what they say:

  • StructuralDrop — something had no home in the target and was left out.
  • TypeApproximation — a value or type was represented by a close-but-not-exact target type.
  • ConstraintRelaxation — a rule (required, max length, enum) could not be enforced and was relaxed.
  • SemanticNarrowing — meaning was narrowed or guessed.

An empty array is a clean conversion. Anything else is a checklist. Change "vendor":"postgres" to "vendor":"mysql" in the body above and that single record disappears, because MySQL does have an inline enum and the coder emits one.

The response also carries a third key you should not ignore: plan. It is the executed mapping as a JSON artifact — a list of operations, each stamped with its kind and its origin (Inferred here) — and it can be stored, reviewed, and replayed later against the same source for a byte-identical result.

Send it back out as LinkML

Change one field and the same call round-trips the schema:

jq -n --rawfile s sensor.yaml \
  '{sourceFormat:"linkml", sourceSchema:$s,
    targetFormat:"linkml",
    targetHintFormat:"linkml", targetHintSchema:$s,
    mapping:{kind:"inferred"}}' \
| curl -s "https://coremodels.example.com/graph/transform/schema/map/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @- | jq -r '.schema'

What comes back is your document, with every fact intact: the schema id, the prefixes, imports, default_range, both descriptions, class_uri: schema:Thing, slot_uri: schema:name, identifier: true, required: true, multivalued: true, range: datetime, minimum_value: 1, the enum with meaning: qudt:DEG_C, and the valueless PERCENT: entry. Two cosmetic differences are worth knowing about because they are deliberate: the prefixes: block comes back sorted (linkml, qudt, schema) and classes are separated by a blank line. Our encoder writes YAML by hand with a fixed key order and two-space indent, so the same input always produces the same bytes — which is what makes exported LinkML safe to commit and diff.

Note also that range: datetime came back as datetime, not as some normalized spelling. LinkML has several temporal built-ins (date, datetime, time, date_or_datetime) that all collapse onto one internal type; the coder keeps the verbatim wire range alongside so re-export is exact rather than merely equivalent.

Putting it in a project

The stateless route is the fastest path to a conversion. When you want the schema to live in CoreModels — governed, searchable, exportable to every other format we encode — import it:

jq -n --rawfile s sensor.yaml '{format:"linkml", schema:$s}' \
| curl -s "https://coremodels.example.com/graph/transform/schema/import/$PROJECT_ID" \
    -H "Authorization: Bearer $TOKEN" \
    -H "Content-Type: application/json" \
    -d @-

That route writes, so it requires Admin on the project, and it answers { "success": true, "lossiness": [], "errors": [], "projectId": "..." }. Each LinkML class becomes a Type, each attribute an Element, each enum a Taxonomy whose permissible values are its terms — and the class_uri, slot_uri, and meaning CURIEs become the same mapsTo meaning links every other CoreModels export reads. One naming note so nothing surprises you when you open the project: Element labels keep your original names (sensor_id), while internal node ids are camelCase composites of class and slot (SensorSensorId), because CoreModels node ids must be alphanumeric.

Going the other way is POST graph/transform/schema/export/{projectId} with the body { "format": "linkml" } — a Viewer-role read that never writes, safe to hand to an analyst account or a build agent.

Where to go next

You have now seen the whole contract in miniature: a real conversion, a plan you can keep, and an honest account of the difference. The same source document can be pointed at any format we encode — JSON Schema, Avro, ShEx, JSON-LD, OWL, an Apache Ossie semantic model, a Bitol ODCS contract, proto3, or a Synapse-ready draft-07 schema — by changing targetFormat alone. Every route, role, and per-format option is written up in the Schema Transformation API reference in the CoreModels docs.