LinkML in Ten Minutes

by Vlad Korolev

Boston Python Meetup — October 2026

Orientation

Let's build a Pokémon app.

Zelda, a developer in a hoodie, risograph comic panel
  • My name is Zelda
Zelda, a developer in a hoodie, risograph comic panel
  • My name is Zelda
  • A developer
Zelda, a developer in a hoodie, risograph comic panel
  • My name is Zelda
  • A developer
  • A Pokémon app
Zelda, a developer in a hoodie, risograph comic panel
Pokémon is a trademark of Nintendo. Used here for illustration only.
  • My name is Zelda
  • A developer
  • A Pokémon app
  • So let's get started
Zelda, a developer in a hoodie, risograph comic panel
Pokémon is a trademark of Nintendo. Used here for illustration only.
Zelda, a developer in a hoodie, risograph comic panel
  • Let's get started
Zelda, a developer in a hoodie, risograph comic panel
ER diagram: Species has many Moves and many Abilities
  • Let's get started
  • Data model
Zelda, a developer in a hoodie, risograph comic panel
ER diagram: Species has many Moves and many Abilities
  • Let's get started
  • Data model
  • Keep it simple

Django model = the schema. One owner.

ER diagram: Species has many Moves and many Abilities
  • SQL tables — Django
  • Python model — Django
  • Frontend forms — Django

One file. One owner.

class Habitat(models.Model):
    name = models.CharField(max_length=100)

class Ability(models.Model):
    name = models.CharField(max_length=100)

class Move(models.Model):
    name = models.CharField(max_length=100)

class Species(models.Model):
    name = models.CharField(max_length=200)
    weight_kg = models.FloatField()
    habitat = models.ForeignKey(Habitat, on_delete=models.CASCADE)
    abilities = models.ManyToManyField(Ability)
    moves = models.ManyToManyField(Move)
3-panel comic: Zelda ships the app under PyLadies and Boston Python posters, a crowd cheers using it, a bag of money arrives

The Pokédex app takes off

Zelda swamped and exhausted, alone at her laptop: "Success outgrows one developer"
Zelda swamped and exhausted, alone at her laptop: "Success outgrows one developer"
a lightbulb moment hits Zelda: "I need more people to help me"
Zelda swamped and exhausted, alone at her laptop: "Success outgrows one developer"
a lightbulb moment hits Zelda: "I need more people to help me"
Zelda, Amy, Ned and James at their own desks in a roomy office: "A small team, building together"

New architecture

ReactReact
a card grid user interface
Frontend
FastAPI
PydanticPydanticSQLAlchemy
Backend
PostgreSQLPostgres
three related tables
Database

New architecture

ReactReact
a card grid user interface
Frontend
FastAPI
PydanticPydanticSQLAlchemy
Backend
PostgreSQLPostgres
three related tables
Database
New architecture, new challenges.

The UI is what users touch. The type is ours to define.

interface Species {
  id: number;
  name: string;
  weightKg: number;
  habitat: Habitat;
  moves: Move[];
}

Nothing is valid until Pydantic says so.

class Species(BaseModel):
    id: int
    name: str
    weight_kg: float
    habitat: Habitat
    moves: list[Move]

If it's not normalized in the database, it isn't real.

CREATE TABLE species (
  id INT PRIMARY KEY,
  name TEXT,
  weight REAL,
  habitat_id INT REFERENCES habitat(id)
);

alt text showing several teams each holding up a competing model of the same record and arguing,

Everybody's model is the right one

alt text showing the DBA standing triumphant while the frontend and backend developers look defeated,

The loudest team wins.

Because if it's not normalized in the database, it isn't real.

alt text showing the Python developer standing triumphant while the frontend developer and the DBA look defeated,

The loudest team wins.

Or because nothing is valid until Pydantic says so.

alt text showing the frontend developer standing triumphant while the Python developer and the DBA look defeated,

The loudest team wins.

Or because the UI is what users touch.

Things grow

3-panel comic: the app spreads to more users, a new wet-lab wing of bioreactors opens, partners sign on beside an Android control panel

New architecture, take two

ReactReact
web app
Frontend
AndroidAndroid
lab floor tablets
Control panels
HTTP
FastAPI
PydanticPydanticSQLAlchemyOpenAPIPartners
Backend
SQL
RPC
PostgreSQLPostgres
three related tables
Database
Wet lab
LIMS *Sequencer *Process control
Lab
* run by partners and contractors — their schemas are not ours to change

New architecture, take two

ReactReact
web app
Frontend
AndroidAndroid
lab floor tablets
Control panels
HTTP
FastAPI
PydanticPydanticSQLAlchemyOpenAPIPartners
Backend
SQL
RPC
PostgreSQLPostgres
three related tables
Database
Wet lab
LIMS *Sequencer *Process control
Lab

Challenges increase

* run by partners and contractors — their schemas are not ours to change

Then it gets worse

Mobile ships its own Species class. Android doesn't wait for anyone.

class Species {
  String name;
  float weightKg;
}

The API is the contract. Every partner integrates against our schema.

Species:
  type: object
  properties:
    name: {type: string}
    weightKg: {type: number}

Bytes on the wire are the only truth. Protobuf is the real model.

message Species {
  string name = 1;
  float weight_kg = 2;
}

alt text showing the lead developer at a whiteboard announcing that Species height becomes a range with units instead of a bare number,

Schemas change

Height isn't one number anymore. It's a range, and it needs units.

an angry meeting room, everyone talking at once
an angry meeting room, everyone talking at once
each developer alone in a cubicle making the same edit
an angry meeting room, everyone talking at once
each developer alone in a cubicle making the same edit
a laptop showing a 400 Invalid Response error
each developer alone in a cubicle making the same edit
a laptop showing a 400 Invalid Response error
the team at their desks in despair, nobody owns the model
a laptop showing a 400 Invalid Response error
the team at their desks in despair, nobody owns the model
the office at rock bottom, late at night, nobody working
the team at their desks in despair, nobody owns the model
the office at rock bottom, late at night, nobody working
Kevin strides in wearing a linkML t-shirt
the office at rock bottom, late at night, nobody working
Kevin strides in wearing a linkML t-shirt
one schema file fans out into every generated artifact
Kevin strides in wearing a linkML t-shirt
one schema file fans out into every generated artifact
one schema.yaml file fanning out to Pydantic, SQL, TypeScript, SHACL, Protobuf, GraphDB, Scala and Java
one schema file fans out into every generated artifact
one schema.yaml file fanning out to Pydantic, SQL, TypeScript, SHACL, Protobuf, GraphDB, Scala and Java
the whole team celebrating together

Species, as a dataclass

classes:
  Species:
    is_a: NamedIndividual
    description: >-
      A species is a category of
      Pokémon that share common
      features.
    slots:
      - hasColor
      - hasHeight
      - hasWeight
      - hasType
class Species(NamedIndividual):
    """A species is a category of
    Pokémon that share common
    features."""

    hasColor: Optional[Color] = \
        Field(default=None)
    hasHeight: Optional[Quantity] = \
        Field(default=None)
    hasWeight: Optional[Quantity] = \
        Field(default=None)
    hasType: Optional[list[Type]] = \
        Field(default=None)

Species, as Pydantic

classes:
  Species:
    is_a: NamedIndividual
    slots:
      - hasColor
      - hasHeight
      - hasWeight
      - hasType

slots:
  hasHeight:
    range: Quantity
    inlined: true
  hasWeight:
    range: Quantity
    inlined: true
class Species(NamedIndividual):
    """A species is a category of
    Pokemon that share common
    features."""

    hasColour: Optional[str] = \
        Field(default=None)
    hasHeight: Optional[Quantity] = \
        Field(default=None)
    hasWeight: Optional[Quantity] = \
        Field(default=None)
    hasType: Optional[list[Type]] = \
        Field(default=None)

Species, as SQL

slots:
  hasColor:
    slot_uri: pokemon:hasColour
    range: Color
    inlined: true
  hasHeight:
    range: Quantity
    inlined: true
  hasWeight:
    range: Quantity
    inlined: true
CREATE TABLE "Species" (
  "hasColour" TEXT,
  "hasShape" TEXT,
  "hasGenus" TEXT,
  "hasCatchRate" INTEGER,
  id TEXT NOT NULL,
  name TEXT NOT NULL,
  "hasHeight_id" TEXT,
  "hasWeight_id" TEXT,
  PRIMARY KEY (id),
  FOREIGN KEY("hasHeight_id")
    REFERENCES "Quantity" (id),
  FOREIGN KEY("hasWeight_id")
    REFERENCES "Quantity" (id)
);

Species, as Protobuf

classes:
  Species:
    slots:
      - hasColor
      - hasHeight
      - hasWeight
      - hasType
      - mayHaveAbility
message Species {
  uri id = 0;
  string name = 0;
  colour hasColour = 0;
  quantity hasHeight = 0;
  quantity hasWeight = 0;
  repeated type hasType = 0;
  repeated ability
    mayHaveAbility = 0;
}

Species, as TypeScript

slots:
  hasHeight:
    range: Quantity
    inlined: true
    description: >-
      How tall a species is, as a
      quantity with unit.
  hasWeight:
    range: Quantity
    inlined: true
    description: >-
      How heavy a species is, as a
      quantity with unit.
export interface Species
    extends NamedIndividual {
  /** A Pokémon has a color */
  hasColor?: Color,
  /** How tall a species is, as a
   * quantity with unit. */
  hasHeight?: Quantity,
  /** How heavy a species is, as a
   * quantity with unit. */
  hasWeight?: Quantity,
  hasType?: Type[],
}

The generated docs for Quantity

Not covered today

  • Validators
  • Loaders
  • Schema Automator
  • Data Harmonizer
  • ...and a lot more

LinkML next to JSON Schema and OpenAPI

Capability LinkML JSON Schema OpenAPI
Validates data Yes Yes No (describes APIs, not records)
Generates code (Pydantic, SQL, etc.) Yes, many targets No, validation only Yes, client/server stubs only
Generates human-readable docs Yes, built in No, external tooling needed Yes, built in

From bad record to tightened schema, in four steps

  1. Bad record: weight_kg: "heavy" lands in the data.
  2. LinkML validation error: ValueError: 'heavy' is not a valid float for slot 'weight_kg'.
  3. Fix: the record is corrected to weight_kg: 12.4.
  4. Tightened schema: a minimum_value: 0 constraint on weight_kg stops the next bad record before it lands.

Where LinkML came from

  • Born inside the Monarch Initiative, a cross-species disease-and-phenotype data project
  • Lead author: Sierra Moxon, Lawrence Berkeley National Laboratory (BBOP)
  • First released in 2021

Who runs it now

  • Community-driven, Apache-2.0 licensed
  • Core maintainers (GitHub): cmungall, sierra-moxon, dalito, sujaypatil96, turbomam
  • Backed by Berkeley Lab's BBOP group and the Monarch Initiative collaboration — Jackson Laboratory, EMBL-EBI, and others
  • Connected to the NIH NCATS Biomedical Data Translator program

LinkML on GitHub

  • ⭐ 645 stars · 🍴 193 forks
  • 4,889 commits on main
  • 766 open issues · 105 open pull requests
  • Apache-2.0 license, Python

linkml/linkml, as of October 2026.

Who already runs on LinkML

Generated documentation, for free

The LinkML community

alt text showing the LinkML tutorial's contributors at ISMB 2024,

Please join

Live demo: validating a record

$ linkml-validate -s schema/sample.yaml data/bad-record.yaml
ValueError: 'heavy' is not a valid float for slot 'weight_kg'

Fallback if the terminal or the network drops: assets/asciinema/linkml-validate.cast

Live demo: generating every target

$ linkml-generate pydantic schema/sample.yaml > models.py
$ linkml-generate sqlddl   schema/sample.yaml > schema.sql
$ linkml-generate shacl    schema/sample.yaml > shapes.ttl

Fallback if the terminal or the network drops: assets/asciinema/linkml-generators.cast

Keep exploring

?????

Thank You

Title slide. No header bar here; the deck introduces itself first.

Orientation act target: ~2 minutes. Comic: comic-orientation, caption above.

Meet the protagonist. Hold on the portrait for a beat before the first line lands.

Reveal 1 of 4. She introduces herself.

Reveal 2 of 4. She writes software for a living.

Reveal 3 of 4. The thing she wants to build: a Pokémon app.

Reveal 4 of 4. The call to action that opens the build.

Reveal 1 of 3. Zelda moves to the left; the right half stays empty until the data model arrives.

Reveal 2 of 3. First real decision: the data model. A Species, the Moves it knows, the Abilities it has.

Reveal 3 of 3. Three entities, two relationships. Nothing clever.

The data store needs a model and a schema before it can hold anything. A small app skips that question: build it with Django and the framework's own model class is the de facto schema. Django turns that one file into the SQL tables, the Python objects, and the frontend forms. One owner, one source of truth. No schema background needed for the rest of this talk.

Comic: comic-success, 3-panel strip, captions baked into the art. Deploy, crowd adopts it, money follows.

Panel 1 of 3. She's drowning in notifications, bug reports, and feature requests, alone. The empty cells hold the other two panels' places, so nothing shifts as they appear.

Panel 2 of 3. The idea lands: she can't do this alone anymore.

Panel 3 of 3. Zelda, Amy, Ned and James, each at their own desk with room to work — a small team now builds the Pokédex app together.

Growing Pains act target: ~3 minutes. The team splits the app: React on the front, FastAPI in the middle, Postgres at the back.

Four places now describe the same Species. Nobody agreed which one is the schema.

Frontend (React), backend (FastAPI/Pydantic), and the database each own a different version of the same Species record, and each team is certain its layer is the one that should define the model. Three definitions of one record, drifting the moment any one of them changes.

Comic: comic-complication-argument, different teams each insisting their model is the real one.

Comic: comic-loudest-wins. Everybody else translates, by hand, forever.

Same beat, different winner. Which model wins is an accident of who argued hardest, not of which one is right.

Third winner, same losers. Rotate the winner and nothing improves — the model still lives in one team's head.

Short divider. The product, the team and the surfaces all expand from here.

More growth. The product spreads, a wet lab opens to actually grow the creatures, and outside partners plug in. Each of those is a new surface with its own idea of what a Species is.

Same diagram as before, now buckling. The web app and the Android control panels are two separate surfaces, both talking HTTP to the backend. The backend talks SQL to Postgres and RPC to the wet lab, where LIMS and the sequencer belong to contractors and process control is firmware on the bioreactors.

Same diagram, now with the cost named: every new surface is another place the model gets redefined. The web app and the Android control panels are two separate surfaces, both talking HTTP to the backend. The backend talks SQL to Postgres and RPC to the wet lab, where LIMS and the sequencer belong to contractors and process control is firmware on the bioreactors.

A mobile Pokédex app joins (Android, Java). The API becomes a product other apps integrate against (OpenAPI). Firmware on the actual scanning hardware ships its own wire format (Protobuf). Each one wants its own model, in its own language, under its own control. Who owns the Species record now?

Comic: comic-schema-change. A real schema change lands: height becomes a min-max range in millimetres, not a bare float. Every one of the six independent models — Django, Pydantic, TypeScript, SQL, Java, OpenAPI, Protobuf — needs to catch up by hand.

One change lands. The strip builds up panel by panel as the background warms toward anger.

Seven codebases, and every one of them has to be edited by hand.

Something still breaks: a 400 Invalid Response, because one of the seven definitions didn't get the memo in time.

The argument scrolls off. Nobody owns the model, so nobody can fix it.

Rock bottom. Lowest morale of the talk — hold the beat here before the turn.

The background snaps back to paper the moment LinkML walks in.

One YAML file, every target generated from it.

schema/sample.yaml is the one file that says what a record is. Pydantic model, SQL table, validation rule, Scala, SHACL, GraphDB, Protocol Buffers — all generated, not hand-written.

Every team gets its own generated artifact from the one schema. Nobody hand-translates anymore.

Real excerpts from github.com/vladistan/linkml-pokemon, trimmed for the slide. Left: the LinkML schema. Right: the generated Python dataclass.

Real excerpt from the Pydantic generator target. Same schema, a different Python shape than the dataclass generator: slot_uri aliases (hasColour) and looser typing on ranges the generator doesn't model as classes.

Real excerpt from the SQL DDL generator target. hasHeight and hasWeight become foreign keys into a Quantity table, because the schema marks them inlined ranges, not bare scalars.

Real excerpt from the Protobuf generator target. Same slots, wire format this time — firmware's bytes-on-the-wire model, generated from the same file instead of hand-maintained.

Real excerpt from the TypeScript generator target. The frontend's interface, generated, with the schema's own description text carried over as doc comments.

Live page: the linkml-pokemon generated data dictionary, QuantityValue entry. Same NFR-005 exception as the generators-index iframe — needs network, blanks in static exports or offline, accepted as a risk.

LinkML is bigger than ten minutes. Validators, data loaders, Schema Automator (infer a schema from data), Data Harmonizer, and more all live in the same ecosystem. This talk stays on schema-to-artifact generation; the rest is worth its own talk.

Walkthrough reused from the ISMB 2024 LinkML tutorial.

LinkML grew out of a concrete need inside the Monarch Initiative: too many ad hoc schemas describing the same biomedical data. BBOP is Berkeley Lab's Berkeley Bioinformatics Open-source Projects group.

Monarch itself is a multi-institution collaboration. LinkML inherited that institutional backing rather than starting from a single company or grant.

Numbers checked live against github.com/linkml/linkml. A GitHub repo's counts move daily — state them as a snapshot, not an eternal fact.

MIxS: minimum information standards for genomic and environmental samples. Monarch: cross-species disease and phenotype data. BioLink: a shared vocabulary for biomedical knowledge graphs.

The same schema/sample.yaml that generates code also generates a browsable data dictionary. No separate documentation tool, no drift between the docs and the code.

The presenter has contributed to LinkML's documentation and tooling and is glad to pair with a first-time contributor. Contributors photo from the ISMB 2024 LinkML tutorial.

Four ways in, lowest commitment first: a good-first-issue PR, then office hours, then the recurring community call, then the mailing list and Slack for ongoing conversation.

Demo and Outlinks act target: ~2 minutes. This act compresses first: when the slot runs short, cut straight to the data-dictionary slide and the outlinks slide, dropping both live demos.

Questions divider.

Closing divider.