Jaco van der Laan

Automatically Generating yEd Diagrams from Metadata with Python

20/09/2025  ·  Data Architecture  ·  Data Engineering  ·  Data Modeling

article diagramming lineage metadata visualization
‹ ›

How to turn metadata into lineage diagrams, ERDs, and business-friendly mappings — and let yEd handle the layout.

Summary

Creating lineage, impact analysis, and ERD diagrams by hand is slow and fragile. In this article, I show how to generate GraphML with Python and open it directly in yEd (free). With one click of yEd’s autolayout, your metadata becomes a clean, professional diagram.

We’ll cover:

  • Why yEd is a great (free) visualization engine
  • A tiny Python “package” (helper module) for building GraphML
  • Examples: Lineage, ERD with crow’s-foot, and combined ERD + Lineage with swimlanes
  • Optionality (0..N) vs mandatory (1..N) using dashed vs solid edges
  • Demo code you can copy-paste

Why yEd?

yEd Graph Editor is a free, desktop graph tool from yWorks. It loads GraphML (XML for graphs), supports thousands of nodes, and has world-class layout algorithms (Hierarchical, Orthogonal, Organic, Circular, etc.). Because GraphML is just text, we can generate it directly from DuckDB or any metadata store — and let yEd handle the visual magic.

Inspiration

I first explored code-generated GraphML at ASN Bank (De Volksbank).
Credit to Juan Jose van der Linden for sparking the idea: don’t draw diagrams by hand — generate them from metadata.

A Tiny Python “Package”: yed_graphml

To make this easy to reuse, here’s a small helper module you can drop into your project (e.g., yed_graphml.py). It wraps the yFiles GraphML tags and gives you simple functions for nodes, edges, groups (swimlanes), labels, colors, dashed/solid styles, positions, and legends.

Features

  • yFiles GraphML: emits <y:ShapeNode>, <y:PolyLineEdge>, <y:GroupNode>, etc.
  • Styling: colors, dashed/solid, borders, arrowheads
  • Labels: node labels, edge end-labels (for crow’s-foot 1 vs N)
  • Groups/Swimlanes: wrap nodes in logical sections (Raw, Stage, Integrated, ERD)
  • Positions: optional x/y geometry for sensible defaults before autolayout
  • Legend helper: drop a labeled box into the canvas
  • Plain Python (no external deps), plus optional duckdb for metadata queries

Installation: copy the snippet below into _yed_graphml.py_ and import it in your scripts.

yed_graphml.py — minimal helper for yEd GraphML

import xml.sax.saxutils as saxutils
def esc(t):
    return saxutils.escape(t if t is not None else "")
HEADER = """<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<graphml xmlns="http://graphml.graphdrawing.org/xmlns"
         xmlns:y="http://www.yworks.com/xml/graphml"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://graphml.graphdrawing.org/xmlns
           http://www.yworks.com/xml/schema/graphml/1.1/ygraphml.xsd">
  <key id="d0" for="node" yfiles.type="nodegraphics"/>
  <key id="d1" for="edge" yfiles.type="edgegraphics"/>
  <graph id="G" edgedefault="directed">
"""
FOOTER = """
  </graph>
</graphml>"""
def node(node_id, label, fill="#FFFFFF", x=None, y=None, w=170, h=50,
         shape="roundrectangle", align="center", autosize=False, border="#2D3640"):
    geo = ""
    if x is not None and y is not None:
        geo = f'<y:Geometry x="{x}" y="{y}" width="{w}" height="{h}"/>'
    else:
        geo = f'<y:Geometry width="{w}" height="{h}"/>'
    auto = ' autoSizePolicy="content"' if autosize else ""
    return f"""
    <node id="{esc(node_id)}">
      <data key="d0">
        <y:ShapeNode>
          {geo}
          <y:Fill color="{esc(fill)}" transparent="false"/>
          <y:BorderStyle color="{esc(border)}" type="line" width="1.0"/>
          <y:NodeLabel alignment="{esc(align)}"{auto}>{esc(label)}</y:NodeLabel>
          <y:Shape type="{esc(shape)}"/>
        </y:ShapeNode>
      </data>
    </node>
"""
def edge(edge_id, source, target, color="#000000", dashed=False, width=1.2,
         arrow_source="none", arrow_target="standard",
         src_label=None, tgt_label=None):
    line_type = "dashed" if dashed else "line"
    src_lbl = (f'<y:EdgeLabel alignment="center" distance="10.0" '
               f'modelName="twoPos" preferredPlacement="source">{esc(src_label)}</y:EdgeLabel>') if src_label else ""
    tgt_lbl = (f'<y:EdgeLabel alignment="center" distance="10.0" '
               f'modelName="twoPos" preferredPlacement="target">{esc(tgt_label)}</y:EdgeLabel>') if tgt_label else ""
    return f"""
    <edge id="{esc(edge_id)}" source="{esc(source)}" target="{esc(target)}">
      <data key="d1">
        <y:PolyLineEdge>
          <y:LineStyle color="{esc(color)}" type="{line_type}" width="{width}"/>
          <y:Arrows source="{esc(arrow_source)}" target="{esc(arrow_target)}"/>
          {src_lbl}
          {tgt_lbl}
        </y:PolyLineEdge>
      </data>
    </edge>
"""
def open_group(group_id, title, x=0, y=0, w=400, h=300, fill="#F7F7F7"):
    return f"""
    <node id="{esc(group_id)}" yfiles.foldertype="group">
      <data key="d0">
        <y:ProxyAutoBoundsNode>
          <y:Realizers active="0">
            <y:GroupNode>
              <y:Geometry x="{x}" y="{y}" width="{w}" height="{h}"/>
              <y:Fill color="{esc(fill)}" transparent="false"/>
              <y:BorderStyle color="#A0A0A0" type="dashed" width="1.0"/>
              <y:NodeLabel alignment="center" modelName="internal" modelPosition="t">{esc(title)}</y:NodeLabel>
              <y:Shape type="roundrectangle"/>
            </y:GroupNode>
          </y:Realizers>
        </y:ProxyAutoBoundsNode>
      </data>
      <graph edgedefault="directed">
"""
def close_group():
    return """
      </graph>
    </node>
"""
def legend(node_id, text, x=0, y=0, w=260, h=140):
    return node(node_id, text, fill="#FFFFFF", x=x, y=y, w=w, h=h, shape="rectangle", align="left")
def write_graphml(nodes_xml, edges_xml, path):
    content = HEADER + "".join(nodes_xml) + "".join(edges_xml) + FOOTER
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)
    return path

Example 1 — Lineage (Raw → Stage → Integrated)

from yed_graphml import node, edge, write_graphml
nodes = [
    node("raw_customers", "customers_raw", "#DAE8FC"),
    node("raw_orders", "orders_raw", "#DAE8FC"),
    node("stg_customers", "customers_stage", "#FFF2CC"),
    node("stg_orders", "orders_stage", "#FFF2CC"),
    node("int_sales", "sales", "#D5F5E3"),
]
edges = [
    edge("e1", "raw_customers", "stg_customers"),
    edge("e2", "raw_orders", "stg_orders"),
    edge("e3", "stg_customers", "int_sales"),
    edge("e4", "stg_orders", "int_sales"),
]
write_graphml(nodes, edges, "lineage_demo.graphml")

Open lineage_demo.graphml in yEd → Layout → Hierarchical.

Example 2 — ERD with Crow’s-Foot + Colors + Optionality

We emulate crow’s-foot using end labels (1 near parent, N near child). Solid = mandatory (1..N). Dashed = optional (0..N).

from yed_graphml import node, edge, write_graphml

entities = [
    node("cust", "customers\n-\nPK customer_id\nname\nemail", "#CFE8FF",
         w=240, h=150, shape="rectangle", align="left", autosize=True),
    node("ord", "orders\n-\nPK order_id\nFK customer_id → customers.customer_id\norder_date\ntotal_amount",
         "#FFF2CC", w=300, h=180, shape="rectangle", align="left", autosize=True),
    node("line", "order_lines\n-\nPK order_line_id\nFK order_id → orders.order_id\nproduct_id\nquantity\nprice",
         "#D5F5E3", w=300, h=180, shape="rectangle", align="left", autosize=True),
]
rels = [
    # customers (1) -> (N) orders - mandatory: solid
    edge("r1", "cust", "ord", color="#2D3640", dashed=False, src_label="1", tgt_label="N", arrow_target="none"),
    # orders (1) -> (N) order_lines - optional: dashed (0..N)
    edge("r2", "ord", "line", color="#2D3640", dashed=True, src_label="0..1", tgt_label="0..N", arrow_target="none"),
]
write_graphml(entities, rels, "erd_crowsfoot_optional.graphml")

Example 3 — ERD + Lineage Together (Different Edge Styles)

  • Lineage: solid black
  • ERD FKs: dashed gray, crow’s-foot labels
  • Legend: included
from yed_graphml import node, edge, legend, write_graphml
nodes = [    # Lineage layers    node("lr0", "customers_raw", "#DAE8FC"),    node("lr1", "orders_raw", "#DAE8FC"),    node("ls0", "customers_stage", "#FFF2CC"),    node("ls1", "orders_stage", "#FFF2CC"),    node("li0", "sales", "#D5F5E3"),    # ERD entities    node("ecust", "customers\n-\nPK customer_id\nname\nemail", "#CFE8FF", w=240, h=150, shape="rectangle", align="left", autosize=True),    node("eord", "orders\n-\nPK order_id\nFK customer_id → customers.customer_id\norder_date\ntotal_amount", "#FFF2CC", w=300, h=180, shape="rectangle", align="left", autosize=True),    node("eline", "order_lines\n-\nPK order_line_id\nFK order_id → orders.order_id\nproduct_id\nquantity\nprice", "#D5F5E3", w=300, h=180, shape="rectangle", align="left", autosize=True),    legend("legend", "Legend:\nLineage edges = solid black\nERD FK edges = dashed gray (1/N end labels)\nLayers: Blue=Raw, Yellow=Stage, Green=Integrated", x=0, y=0),]
edges = [    # Lineage (solid)    edge("L0", "lr0", "ls0", color="#000000", dashed=False),    edge("L1", "lr1", "ls1", color="#000000", dashed=False),    edge("L2", "ls0", "li0", color="#000000", dashed=False),    edge("L3", "ls1", "li0", color="#000000", dashed=False),    # ERD (dashed gray, crow's-foot)
    edge("E0", "ecust", "eord", color="#808080", dashed=True, arrow_target="none", src_label="1", tgt_label="N"),
    edge("E1", "eord", "eline", color="#808080", dashed=True, arrow_target="none", src_label="1", tgt_label="N"),
]
write_graphml(nodes, edges, "erd_lineage_combined_styled.graphml")

Open and Layout → Hierarchical (good for flows) or Orthogonal (ERDish).

Example 4 — Swimlanes + Pre-Positioning + Legend

Here we split Raw / Stage / Integrated / ERD Entities into group nodes (swimlanes), pre-place nodes for a sensible initial view, keep the same edge styles, and add a legend.

from yed_graphml import (open_group, close_group, node, edge, legend, write_graphml)
parts = []
# RAW
parts.append(open_group("g_raw", "Raw", x=40, y=40, w=520, h=220))
parts.append(node("lr0", "customers_raw", "#DAE8FC", x=60, y=100, w=170, h=50))
parts.append(node("lr1", "orders_raw", "#DAE8FC", x=260, y=100, w=170, h=50))
parts.append(close_group())
# STAGE
parts.append(open_group("g_stage", "Stage", x=600, y=40, w=520, h=220))
parts.append(node("ls0", "customers_stage", "#FFF2CC", x=620, y=100, w=170, h=50))
parts.append(node("ls1", "orders_stage", "#FFF2CC", x=820, y=100, w=170, h=50))
parts.append(close_group())
# INTEGRATED
parts.append(open_group("g_int", "Integrated", x=1160, y=40, w=520, h=220))
parts.append(node("li0", "sales", "#D5F5E3", x=1180, y=110, w=170, h=50))
parts.append(close_group())
# ERD
parts.append(open_group("g_erd", "ERD Entities", x=40, y=320, w=1280, h=320))
parts.append(node("ecust", "customers\n-\nPK customer_id\nname\nemail", "#CFE8FF",
                  x=60, y=380, w=260, h=160, shape="rectangle", align="left", autosize=True))
parts.append(node("eord", "orders\n-\nPK order_id\nFK customer_id → customers.customer_id\norder_date\ntotal_amount",
                  "#FFF2CC", x=380, y=380, w=300, h=180, shape="rectangle", align="left", autosize=True))
parts.append(node("eline", "order_lines\n-\nPK order_line_id\nFK order_id → orders.order_id\nproduct_id\nquantity\nprice",
                  "#D5F5E3", x=740, y=380, w=300, h=180, shape="rectangle", align="left", autosize=True))
parts.append(close_group())
# Legend
parts.append(legend("legend", "Legend:\nLineage = solid black\nERD FK = dashed gray (crow's-foot labels)\nSwimlanes: Raw / Stage / Integrated / ERD",
                    x=1360, y=60, w=320, h=180))
# Edges
edges = [
    # Lineage
    edge("L0", "lr0", "ls0", color="#000000", dashed=False),
    edge("L1", "lr1", "ls1", color="#000000", dashed=False),
    edge("L2", "ls0", "li0", color="#000000", dashed=False),
    edge("L3", "ls1", "li0", color="#000000", dashed=False),
    # ERD (crow's-foot)
    edge("E0", "ecust", "eord", color="#808080", dashed=True, arrow_target="none", src_label="1", tgt_label="N"),
    edge("E1", "eord", "eline", color="#808080", dashed=True, arrow_target="none", src_label="1", tgt_label="N"),
]

# Write file
from yed_graphml import HEADER, FOOTER
with open("erd_lineage_swimlanes.graphml", "w", encoding="utf-8") as f:
    f.write(HEADER)
    f.write("".join(parts))
    f.write("".join(edges))
    f.write(FOOTER)

Open in yEd — you already have a good layout, but Autolayout refines it while keeping groups meaningful.

(Optional) ERD from DuckDB’s information_schema

You can auto-discover tables and FKs from DuckDB and plug them into the helpers above.

import duckdb
from yed_graphml import node, edge, write_graphml

con = duckdb.connect(":memory:")
con.execute("create table customers (customer_id integer primary key, name varchar, email varchar)")
con.execute("""
create table orders (
  order_id integer primary key,
  customer_id integer references customers(customer_id),
  amount decimal
)""")
tables = [r[0] for r in con.execute("select table_name from information_schema.tables where table_schema='main'").fetchall()]
fks = con.execute("select table_name, foreign_table_name from information_schema.foreign_keys").fetchall()
nodes = [node(t, t, "#DAE8FC") for t in tables]
edges = [edge(f"fk_{i}", fk[1], fk[0], color="#808080", dashed=True, arrow_target="none", src_label="1", tgt_label="N")
         for i, fk in enumerate(fks)]
write_graphml(nodes, edges, "duckdb_erd.graphml")

Wrap-Up

With a few lightweight helpers, you can:

  • Generate GraphML from metadata (DuckDB, catalogs, YAML, LinkML, etc.)
  • Open in yEd (free), click Autolayout, and get clean diagrams
  • Combine ERD + Lineage and add business-friendly visuals (swimlanes, legends, colors)
  • Model cardinality & optionality clearly (1..N vs 0..N)

No manual drawing. Always in sync with metadata.

What’s Next

In a follow-up, I’ll connect this to business-friendly mapping: draw.io/YAML recipes → generate SQL/dbt and diagrams (LinkML/LinkML-Map as the rigorous backbone, with a visual front-end).