1 of 38

(graphs)-[:ARE]->(everywhere)

So put them in your python!

Exploring Graph Data Types in Modern Python

Dom Weldon

PyCon FR

Samedi 31 Octobre 2026 - 16h30

BSky/GitHub

@DomWeldon

Email

dom.weldon@gmail.com

Les graphes sont partout, alors mettez-les dans votre python!

Une exploration de les types de données en python pour les graphes

2 of 38

Dom Weldon

Ingénieur Logiciel

3 of 38

Dom Weldon

Ingénieur Logiciel

Ingénieur Logiciel

Fullstack

Platform Engineer

Ingénieur Platforme

4 of 38

Dom Weldon

Ingénieur Logiciel

Ingénieur Logiciel

Fullstack

5 of 38

Dom Weldon

Based in

Central London

Platform Engineer

6 of 38

Dom Weldon

Based in

Central London

Contracting for Financial Services

Previous: Consultancy

  • Working in defence / intelligence and Infrastructure
  • Building products with
    • AI / ML
    • Simulation Modelling
    • Optimisation

Past: Academic

  • Graph Databases
  • Geospatial Statistics
  • Molecular Biology
  • Culture of the Cold War

Now: Investment Management

  • Very large City of London firm
  • Working with Quants
  • Meta-programming, graph-theory

Platform Engineer

7 of 38

“Graphs” / « Graphes »

A

C

B

Graph(e)s

Charts / « Graphiques »

Pairwise relationships between entities.

8 of 38

Graph Theory: key terms

A

C

B

G = (V, E)

​

V = { A, B, C }

​

E = { (A, B), (B, C), (C, A) }

Graphs model pairwise relationships between entities.

​

They can be defined as a pair:

edges / arcs

arêtes / liens

nodes / vertices

nœuds / sommets

9 of 38

Types of Graph

A

C

B

A

C

B

A

B

A

B

Undirected

Directed

MultiGraph

Directed

MultiGraph

10 of 38

Types of Graph

A

C

B

A

C

B

A

B

A

B

Undirected

Directed

MultiGraph

Directed

MultiGraph

Cyclical

11 of 38

Types of Graph

A

C

B

A

C

B

A

B

A

B

Undirected

Directed

MultiGraph

Directed

MultiGraph

Cyclical

Acyclical

12 of 38

Graphs are matrices, and vice versa

​

UK

IE

FR

ES

PT

AD

CH

DE

BE

UK

​

499

​

​

​

​

​

​

​

IE

499

​

​

​

​

​

​

​

​

FR

​

​

​

646

​

55

525

418

556

ES

​

​

646

​

1224

63

​

​

​

PT

​

​

​

1224

​

​

​

​

​

AD

​

​

55

63

​

​

​

​

​

CH

​

​

525

​

​

​

​

348

​

DE

​

​

418

​

​

​

348

​

133

BE

​

​

556

​

​

​

​

133

​

Sparse matrix of length of land borders between selected European countries.

13 of 38

Graphs are matrices, and vice versa

​

UK

IE

FR

ES

PT

AD

CH

DE

BE

UK

​

499

​

​

​

​

​

​

​

IE

499

​

​

​

​

​

​

​

​

FR

​

​

​

646

​

55

525

418

556

ES

​

​

646

​

1224

63

​

​

​

PT

​

​

​

1224

​

​

​

​

​

AD

​

​

55

63

​

​

​

​

​

CH

​

​

525

​

​

​

​

348

​

DE

​

​

418

​

​

​

348

​

133

BE

​

​

556

​

​

​

​

133

​

14 of 38

Property Graph Model

A way to model data as a graph, where:

  • Each node has a unique identity within the Graph
  • Each edge can be identified by:
    • A tuple (u, v), in the case of a simple Graph
    • A tuple (u, v, i) in the case of a MultiGraph
  • Each node, and each edge, has a keyed set of properties.

A

C

B

name: “A”

colour: yellow

name: “B”

colour: green

name: “C”

colour: purple

x: true

d: [1,2,3]

x: false

d: [4,5,6]

x: false

xyz: -2.5

15 of 38

(graphs)-[:ARE]-(everywhere)

This is a fun slogan, written in Cypher, a graph query language.

But, it has a deeper meaning.

Graphs are the most fundamental way to understand the world around us¹.

Modelling data well as a graph allows us to write better, more declarative code, that has positive effects across a whole code base.

CREATE (fr:Country {

Name: “France”,

ShortName: “fr”

})-[:BORDERS {

Length_km: 646

}]->(es:Country {

Name: “Spain”,

ShortName: “es”,

});

¹ Disagree? Fight me.

16 of 38

Graphs in Python

Python does not have an intrinsic graph data type.

Instead we have to use libraries. In the remainder of this talk we’ll explore two libraries:

  • NetworkX
  • RustworkX

Lots of discussion of graphs in python focuses around doing network analysis, which is not specifically the focus of this talk.

Focus is on using graphs as a data type in your everyday engineering-focused code, and why doing this is a Good Thing™ for your codebase.

17 of 38

Graphs in Python

Python does not have an intrinsic graph data type.

Instead we have to use libraries. In the remainder of this talk we’ll explore two libraries:

  • NetworkX
  • RustworkX

Lots of discussion of graphs in python focuses around doing network analysis, which is not specifically the focus of this talk.

Focus is on using graphs as a data type in your everyday engineering-focused code, and why doing this is a Good Thing™ for your codebase.

18 of 38

Using Graphs in Python: NetworkX

Most established and widely used set of tools for working with graph data types in python.

“Pandas for graphs”

$ uv add networkx types-networkx

Main package

Separately managed and bundled set of type stubs, providing reasonably good coverage for most use cases

(type hints for graphs are limited by the constraints of the current typing system, but PEPs 827 and 849 have the potential to make much more powerful use cases possible in typed graphs!)

19 of 38

NetworkX: the basics

A

C

B

A

C

B

A

B

A

B

nx.Graph()

nx.DiGraph()

nx.MultiGraph()

nx.MultiDiGraph()

import networkx as nx

G = nx.Graph()

20 of 38

NetworkX: the basics

>>> import networkx as nx

>>> G = nx.DiGraph()

>>> G.add_node("A", colour="yellow", name="A")

>>> G.nodes

['A']

>>>G.nodes["A"]

{'colour': 'yellow', 'name': 'A'}

>>> G.add_edge("A", "B", x=False, d=[4,5,6])

>>> G.nodes

NodeView(('A', 'B'))

>>> G.edges

OutEdgeView([('A', 'B')])

>>> G.edges[("A", "B")]

{'x': False, 'd': [4, 5, 6]}

>>> G["A"]["B"]

{'x': False, 'd': [4, 5, 6]}

>>> G.edges["A", "B"]["y"] = {1: 2}

​

​

A

C

B

name: “A”

colour: yellow

name: “B”

colour: green

name: “C”

colour: purple

x: true

d: [1,2,3]

x: false

d: [4,5,6]

x: false

xyz: -2.5

Nodes can be any hashable object.

They have a set of properties that you can access via subscription on G.nodes. These are dict[str, Any].

Edges are added similarly, and also have properties.

You can access edges either via G.edges or using a double subscription on G.

All properties are mutable

21 of 38

NetworkX: the basics

>>> import networkx as nx

>>> G = nx.DiGraph()

>>> G.add_node("A", colour="yellow", name="A")

>>> print(G.nodes)

['A']

>>> print(G.nodes["A"])

{'colour': 'yellow', 'name': 'A'}

>>> G.add_edge("A", "B", x=False, d=[4,5,6])

>>> G.nodes

NodeView(('A', 'B'))

>>> G.edges

OutEdgeView([('A', 'B')])

>>> G.edges[("A", "B")]

{'x': False, 'd': [4, 5, 6]}

>>> G["A"]["B"]

{'x': False, 'd': [4, 5, 6]}

>>> G.edges["A", "B"]["y"] = {1: 2}

​

​

A

C

B

name: “A”

colour: yellow

name: “B”

colour: green

name: “C”

colour: purple

x: true

d: [1,2,3]

x: false

d: [4,5,6]

x: false

xyz: -2.5

Nodes can be any hashable object.

They have a set of properties that you can access via subscription on G.nodes. These are dict[str, Any].

Edges are added similarly, and also have properties.

You can access edges either via G.edges or using a double subscription on G.

All properties are mutable

The NetworkX documentation and code refers to these values on nodes, edges, etc. as ‘attributes’ or ‘attrs’, but they are never accessed like attributes - i.e., through the getattr(obj, name) pattern - only ever via subscription, hence why I (correctly) call them properties.

22 of 38

NetworkX: the basics

import networkx as nx

​

G = nx.DiGraph()

G.add_nodes_from([

(

"A",

{

"name": "A",

"colour": "yellow"

},

),

(

"B",

{

"name": "B",

"colour": "green",

}

),

(

"C",

{

"name": "C",

"colour": "purple"

}

),

])

G.add_edges_from((

("A", "B", {"x": False, "d": [4,5,6]}),

("B", "C", {"x": False, "xyz": -2.5}),

("A", "C", {"x": True, "d": [1,2,3]}),

)

A

C

B

name: “A”

colour: yellow

name: “B”

colour: green

name: “C”

colour: purple

x: true

d: [1,2,3]

x: false

d: [4,5,6]

x: false

xyz: -2.5

>>> G.nodes

NodeView(('A', 'B', 'C'))

>>> G.edges

OutEdgeView([('A', 'B'), ('A', 'C'), ('B', 'C')])

>>> {n: d for n, d in G.nodes(data=True)}

{'A': {'name': 'A', 'colour': 'yellow'},

'B': {'name': 'B', 'colour': 'green'},

'C': {'name': 'C', 'colour': 'purple'},

}

>>> {(u, v): d for u, v, d in G.edges(data=True)}

{('A', 'B'): {'x': False, 'd': [4, 5, 6]},

('A', 'C'): {'x': True, 'd': [1, 2, 3]},

('B', 'C'): {'x': False, 'xyz': -2.5}}

​

23 of 38

NetworkX

Uses various systems-language backends for more complex analysis features on large graphs, but has a stable pure-python implementation that is good for most everyday use.

Includes visualisation features, primarily aimed at network analysis functionality.

  • Nodes must be hashable
  • Properties are implemented as dictionary mappings with string keys.

24 of 38

RustworkX

A different data model: nodes and edges are both referenced by indices, which are integers.

Nodes and edges have data payloads, which can be of any python type.

Data is accessed by NodeIndices or EdgeIndices.

Graphs are MultiGraphs by default.

A

C

B

A

C

B

A

B

A

B

rx.PyGraph(

multigraph=False

)

rx.PyDiGraph(

multigraph=False

)

rx.PyGraph()

rx.PyDiGraph()

import rustworkx as rx

G = rx.PyGraph()

25 of 38

RustworkX: the basics

A

C

B

A

C

B

A

B

A

B

rx.PyGraph(

multigraph=False

)

import rustworkx as rx

G = rx.PyGraph()

rx.PyDiGraph(

multigraph=False

)

rx.PyGraph()

rx.PyDiGraph()

26 of 38

graphlib

27 of 38

Example Use: Database Query

Focus on some real-world examples of using graphs in code.

Scenario:

  • Planning how to structure a database query to insert or update a row
  • Want a function to do this for any table.

28 of 38

class Category(TableBase, table=True):

id: int = sqlmodel.Field(primary_key=True)

name: str

​

​

class Product(TableBase, table=True):

id: int = sqlmodel.Field(primary_key=True)

category: int = sqlmodel.Field(foreign_key="category.id")

name: str

price: float

​

class User(TableBase, table=True):

id: int = sqlmodel.Field(primary_key=True)

email: str

​

class Order(TableBase, table=True):

id: int = sqlmodel.Field(primary_key=True)

user_id: int = sqlmodel.Field(primary_key=True, foreign_key="user.id")

​

class OrderProducts(TableBase, table=True):

order_id: int = sqlmodel.Field(primary_key=True, foreign_key="order.id")

product_id: int = sqlmodel.Field(primary_key=True, foreign_key="product.id")

quantity: int

29 of 38

Constructing Graphs: choosing nodes

Important first step is generally deciding what types to use for a node. I use frozen pydantic models, with a function identify(obj)

import typing

​

import pydantic

​

​

type IdentityKind = typing.Literal["Table"]

​

​

class Identity(pydantic.BaseModel):

kind: IdentityKind

​

model_config = pydantic.ConfigDict(

frozen=True,

)

​

​

class Identifiable[IT: Identity](typing.Protocol):

def __dw_identify__(self) -> IT: ...

​

def identify[IT: Identity](obj: Identifiable[IT]) -> IT:

return obj.__dw_identify__()

By ensuring that nodes have a well defined type, you can create an ID to reference another node easily.

Provides a single interface to identify an object to put in a graph.

30 of 38

Using Graphs: identifying nodes

import typing

​

import pydantic

​

​

type IdentityKind = typing.Literal["Table"]

​

​

class Identity(pydantic.BaseModel):

kind: IdentityKind

​

model_config = pydantic.ConfigDict(

frozen=True,

)

​

​

class Identifiable[IT: Identity](typing.Protocol):

def __dw_identify__(self) -> IT: ...

​

def identify[IT: Identity](obj: Identifiable[IT]) -> IT:

return obj.__dw_identify__()

base.py

class TableIdentity(base.Identity):

kind: base.IdentityKind = "Table"

name: str

​

​

class TableBase(sqlmodel.SQLModel):

​

__table__: typing.ClassVar[sqlmodel.Table]

​

@classmethod

def __dw_identify__(cls) -> TableIdentity:

return TableIdentity(

name=cls.__table__.name

)

​

​

class Category(TableBase, table=True):

id: int = sqlmodel.Field(primary_key=True)

name: str

tables.py

Using a protocol allows easy expansion for multiple types of node.

31 of 38

class TableIdentity(base.Identity):

kind: base.IdentityKind = "Table"

name: str

​

class TableNodeData(typing.TypedDict):

table: typing.NotRequired[sqlmodel.Table]

model: typing.NotRequired[TableBase]

​

class TableEdgeData(typing.TypedDict):

foreign_key: typing.NotRequired[sqlmodel.ForeignKey]

attr: typing.NotRequired[str]

target_attr: typing.NotRequired[str]

​

class TableGraph[

_TI: TableIdentity = TableIdentity,

_TND: TableNodeData = TableNodeData,

_TED: TableEdgeData = TableEdgeData,

](nx.DiGraph): ...

​

We can also type our graphs using NetworkX, but the exact behaviour varies according to your type checker, and requires more recent python functionality (3.14/5).

32 of 38

def sqlmodels_to_G(sqlmodels: typing.Iterable[type[TableBase]]) -> TableGraph:

"""Construct a TableGraph from an iterable of SQLModel objects"""

G = TableGraph()

for model in sqlmodels:

table: sqlmodel.Table = model.__table__

G.add_node(

meta.identify(model),

table=table,

model=model,

)

for fk in table.foreign_keys:

G.add_edge(

identify(model),

TableIdentity(

name=fk.target_tokens.table_name,

),

foreign_key=fk,

attr=fk.parent.name,

target_attr=(fk.column.name)

)

​

return G

Add nodes using our identifiers, give properties to individual nodes and edges.

Taking tree-like data from a set of SQLModel/SQLAlchemy objects and turning them into a more native graph-structure.

33 of 38

G = tables.sqlmodels_to_G(tables.ALL_TABLES)

>>> G.nodes

{

TableIdentity(kind='Table',

name='product'),

TableIdentity(kind='Table',

name='order'),

TableIdentity(kind='Table',

name='orderproducts'),

TableIdentity(kind='Table',

name='category'),

TableIdentity(kind='Table',

name='user')

}

>>> rich.print({(u, v): d for u, v, d in G.edges(data=True)})

{

( TableIdentity(kind='Table', name='product'),

TableIdentity(kind='Table', name='category')): {

'foreign_key': ForeignKey('category.id'),

'attr': 'category',

'target_attr': 'id'

},

( TableIdentity(kind='Table', name='order'),

TableIdentity(kind='Table', name='user')): {

'foreign_key': ForeignKey('user.id'),

'attr': 'user_id',

'target_attr': 'id'

},

( TableIdentity(kind='Table', name='orderproducts'),

TableIdentity(kind='Table', name='order')): {

'foreign_key': ForeignKey('order.id'),

'attr': 'order_id',

'target_attr': 'id'

},

( TableIdentity(kind='Table', name='orderproducts'),

TableIdentity(kind='Table', name='product')): {

'foreign_key': ForeignKey('product.id'),

'attr': 'product_id',

'target_attr': 'id'

}

}

Constructed our graph containing data about our schema.

34 of 38

Using Graphs

def query_planner[TB: TableBase = TableBase](

models: typing.Iterable[TB],

G_tables: TableGraph,

) -> typing.Iterable[TB]:

"""Sort objects in a topologically safe order"""

for gen in list(nx.topological_generations(G_tables))[::-1]:

yield from (

model

for model in models

if identify(model.__class__) in gen

)

​

Use topological sorting to ensure that no model will be inserted before any other model it has a foreign key pointing to.

35 of 38

Graphs as declarative code

Being a software engineer is about much more than writing code.

We should aim to discover as much as possible about the real-world processes we build software for, and to record this information as thoroughly as possible in our code.

Graphs are a powerful tool to allow us to do that.

Being able to understand your problem, and express it as a graph, affords lots of rich possibilities to tackle your business problems and build on them for the future.

36 of 38

Only stdlib tool for working with graphs.

Implements a topological sorter and nothing else.

Originally intended for demonstration purposes for pure python implementations of the method resolution order.

37 of 38

38 of 38

(graphs)-[:ARE]->(everywhere)

So put them in your python!

Exploring Graph Data Types in Modern Python

Dom Weldon

PyCon FR

Samedi 31 Octobre 2026 - 16h30

BSky/GitHub

@DomWeldon

Email

dom.weldon@gmail.com

Les graphes sont partout, alors mettez-les dans votre python!

Une exploration de les types de données en python pour les graphes