Skip to main content

anywidget-graph

Interactive graph visualization for Python notebooks.

Works with Marimo, Jupyter, VS Code, Colab, anywhere anywidget runs.

anywidget-graph demo

Features

  • Universal: One widget, every notebook environment
  • Backend-agnostic: Grafeo, Neo4j, NetworkX, pandas, or raw dicts
  • Interactive: Pan, zoom, click, drag, pin, expand neighbors, box select
  • Customizable: Colors, sizes, layouts, dark mode
  • Exportable: HTML, JSON

Installation

uv add anywidget-graph

Optional extras:

uv add "anywidget-graph[networkx]"   # NetworkX support
uv add "anywidget-graph[pandas]"     # pandas support
uv add "anywidget-graph[grafeo]"     # Grafeo backend
uv add "anywidget-graph[cosmosdb]"   # CosmosDB / Gremlin support

Quick Start

from anywidget_graph import Graph

graph = Graph.from_dict(
    {
        "nodes": [
            {"id": "alice", "label": "Alice", "group": "person"},
            {"id": "bob", "label": "Bob", "group": "person"},
            {"id": "paper", "label": "Graph Theory", "group": "document"},
        ],
        "edges": [
            {"source": "alice", "target": "bob", "label": "knows"},
            {"source": "alice", "target": "paper", "label": "authored"},
        ],
    }
)

graph

Data Sources

Dictionary

graph = Graph.from_dict({"nodes": [{"id": "a"}, {"id": "b"}], "edges": [{"source": "a", "target": "b"}]})

Direct initialization

graph = Graph(
    nodes=[{"id": "a", "label": "Alice"}, {"id": "b", "label": "Bob"}],
    edges=[{"source": "a", "target": "b", "label": "KNOWS"}],
)

Cypher results (Neo4j)

from neo4j import GraphDatabase

driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))

with driver.session() as session:
    result = session.run("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT 100")
    graph = Graph.from_cypher(result)

GQL results

graph = Graph.from_gql(result)

SPARQL results

from rdflib import Graph as RDFGraph

g = RDFGraph()
g.parse("data.ttl")
result = g.query("SELECT ?s ?p ?o WHERE { ?s ?p ?o }")
graph = Graph.from_sparql(result)

Gremlin results (CosmosDB, TinkerPop)

graph = Graph.from_gremlin(result)

GraphQL results

graph = Graph.from_graphql(
    response.json(),
    nodes_path="data.characters.results",
    id_field="id",
    label_field="name",
)

NetworkX

import networkx as nx

G = nx.karate_club_graph()
graph = Graph.from_networkx(G)

pandas DataFrames

import pandas as pd

nodes_df = pd.DataFrame({"id": ["alice", "bob"], "group": ["person", "person"]})
edges_df = pd.DataFrame({"source": ["alice"], "target": ["bob"], "weight": [1.0]})

graph = Graph.from_dataframe(nodes_df, edges_df)

Interactivity

Events

graph = Graph.from_dict(data)


@graph.on_node_click
def handle_node(node_id, node_data):
    print(f"Clicked: {node_id}")


@graph.on_edge_click
def handle_edge(edge_data):
    print(f"Edge: {edge_data['label']}")


@graph.on_selection
def handle_selection(node_ids):
    print(f"Selected: {node_ids}")

Selection

graph.selected_nodes  # Current selection (list of IDs)
graph.selection_mode = "box"  # Switch to box-select mode

Node expansion

graph.expand_node("alice")  # Fetch and merge neighbors (requires backend)

Node pinning

graph.pin_nodes(["alice", "bob"])  # Pin at current positions
graph.unpin_nodes(["alice"])  # Release back to layout
graph.toggle_pin("bob")  # Toggle pin state
graph.unpin_all()  # Unpin everything

Clear

graph.clear()  # Remove all nodes, edges, pins, and selection

Linked lanes and live append

Show two graphs side by side in one canvas, each laid out in its own lane, with curved edges between them, and grow them while a process runs:

graph = Graph(
    lanes=[{"id": "source", "title": "Source graph"}, {"id": "target", "title": "Target graph"}],
    nodes=[{"id": "a", "lane": "source"}, {"id": "b", "lane": "source"}, {"id": "x", "lane": "target"}],
    edges=[{"source": "a", "target": "b"}, {"source": "x", "target": "a", "cross": True}],
)
graph.append(nodes=[{"id": "y", "lane": "target"}], edges=[{"source": "y", "target": "b"}])
graph.remove(nodes=["x"])  # fades out, nothing else moves
graph.pulse_nodes = ["b"]  # a soft pulse on these nodes; [] stops it
graph.theme = {
    "background": "#0d1416",
    "panel": "#121b1e",
    "text": "#dbe7e5",
    "muted": "#8ea3a3",
    "border": "#1e2b2f",
    "accent": "#5bb8a9",
}
  • lanes: lanes left to right; a node's lane picks one (a node without a known lane goes to the first). Edges between lanes (or marked cross) are drawn as soft curves above both lanes and light up when either end is hovered or selected; a search also shows a match's partners in the other lanes. Without lanes the widget behaves as before.
  • append(nodes, edges): adds items without a re-layout; existing nodes keep their positions, a new node is placed at the height of its neighbours in other lanes, where nodes of the same neighbours stack as rows one label line apart with their labels shown (or next to its neighbours in its lane; without lanes, next to its neighbours inside the area already drawn). The items are also merged into nodes and edges, so to_json(), to_html() and a widget shown again include them (a host that sets append_batch itself gets the merged lists written back by the widget). Changing lanes or a style keeps appended items; setting nodes or edges to other data replaces everything drawn. New items fade in and edges draw from source to target, append_stagger_ms (default 60) apart; a large batch is kept under 1.5 s. append_animation="none" turns the animation off.
  • remove(nodes, edges): takes nodes (with every edge touching them) and edges out without a re-layout; they fade out and leave nodes and edges. An edge is {"source", "target"} (every edge between those ends) or with a "label" (that edge only). A host that sets append_batch itself puts them under remove: {"seq": n, "nodes": [...], "edges": [...], "remove": {"nodes": [ids], "edges": [...]}}; removals come first, so one batch can re-sample (an item removed and sent again stays where it is).
  • Lane options: width is a lane's share of the standard width (default 1); arrange: "column" stacks the lane's nodes in the middle of its band (for a few roots, such as repositories, whose edges then fan out into the next lane); action: True makes a lane without nodes that shows a button, with the host's icon (SVG markup, where currentColor takes the accent colour, or an image URL), or glyph (a list of colours drawn as stacked bars), else a neutral "open" icon; caption is text under it. An edge whose end is an action lane's id (and no node) is drawn as a curve into its button, like the cross-lane curves. The camera frames all lanes, and the lanes stretch to the canvas's shape.
  • lane_action: set to {"lane": <id>, "seq": <n>} when an action lane's glyph is clicked, so the host can respond (open a panel, switch a view). seq counts on from the value the model holds, so every click is a change, also in a widget shown twice.
  • pulse_nodes: ids that pulse softly.
  • totals: for a host that sends a sample of a larger graph, {"nodes": {"<type>": n}, "edges": {"<type>": n}} (types as the schema panel groups them: a node's first label, an edge's type). The count badge and the schema panel show "shown / total"; a type the sample lacks shows as 0, a type without a total shows its count (and counts as shown in full).
  • theme: host colours (background, panel, text, muted, border, accent) that replace the widget's light and dark defaults; a key left out (or theme = {}) brings the default back.
  • _features: the widget sets the capabilities it supports when it renders, so a host can detect an older build.

Styling

Property-based coloring

graph = Graph.from_dict(
    data,
    color_field="group",  # Color nodes by field
    color_scale="viridis",  # Scale: viridis, plasma, inferno, magma, cividis, turbo
    size_field="score",  # Size nodes by field
    size_range=[5, 30],  # Min/max node size
)

Edge styling

graph.edge_color_field = "type"
graph.edge_color_scale = "plasma"
graph.edge_size_field = "weight"
graph.edge_size_range = [1, 8]

Layouts

Graph.from_dict(data, layout="force")  # ForceAtlas2 (default)
Graph.from_dict(data, layout="circular")
Graph.from_dict(data, layout="random")

Options

graph = Graph(
    nodes=nodes,
    edges=edges,
    width=800,  # Widget width (px)
    height=600,  # Widget height (px)
    fill=False,  # Take the host's size and follow resizes (ignores width/height)
    background="#fafafa",  # Background color
    show_labels=True,  # Node labels
    show_edge_labels=False,  # Edge labels
    show_toolbar=True,  # Toolbar visibility
    show_settings=True,  # Settings panel
    show_query_input=True,  # Query input box
    dark_mode=True,  # Dark theme
    show_tooltip=True,  # Hover tooltips
    tooltip_fields=["label", "id"],
    max_nodes=300,  # Limit for node expansion
)

Database Backends

Grafeo (default)

Grafeo runs in one of three modes, picked with grafeo_connection_mode or in the settings panel.

Embedded (Python, the default): queries run in the notebook kernel. Requires the grafeo extra (grafeo>=0.5.44,<0.7).

import grafeo

db = grafeo.GrafeoDB()
graph = Graph(database_backend="grafeo", grafeo_db=db, query_language="gql")

Server: the browser talks to grafeo-server over HTTP (POST /query, GET /db/{name}/schema). The server must allow the notebook's origin, for example grafeo-server --cors-origins http://localhost:8888.

graph = Graph(database_backend="grafeo", grafeo_connection_mode="server", grafeo_server_url="http://localhost:7474")

WASM: the engine runs in the browser. @grafeo-db/wasm (0.5.44) is downloaded from jsDelivr when you click "Initialize WASM", so nothing extra is installed in Python.

graph = Graph(database_backend="grafeo", grafeo_connection_mode="wasm", query_language="gql")

Query languages: GQL, Cypher, Gremlin, GraphQL and SPARQL. Nodes and edges are taken from every result column, including lists (collect(n), variable-length relationships, nodes(p)) and paths (RETURN p); scalar columns are ignored. Run one statement per query: Grafeo rejects several ;-separated statements in one call.

Try it without any setup:

from anywidget_graph.demo import demo_graph

demo_graph()  # WASM mode with a small movie graph loaded

Neo4j (browser-side)

graph = Graph(
    database_backend="neo4j",
    connection_uri="neo4j+s://demo.neo4jlabs.com",
    connection_username="neo4j",
    connection_password="password",
)

Generic backend

graph = Graph(backend=my_backend)  # Any object implementing DatabaseBackend protocol

Export

graph.to_json()  # JSON string with nodes and edges
graph.to_html()  # Self-contained HTML string
graph.to_html(title="My Graph")  # Custom title
graph.save_html("graph.html")  # Write HTML to file

Environment Support

Environment Supported
Marimo Yes
JupyterLab Yes
Jupyter Notebook Yes
VS Code Yes
Google Colab Yes
Databricks Yes

License

Apache-2.0

Metadata

Release files for anywidget-graph 0.4.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for anywidget-graph 0.4.1
File Size Uploaded
anywidget_graph-0.4.1.tar.gz 447.3 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for anywidget-graph 0.4.1
File Interpreter ABI Platform
anywidget_graph-0.4.1-py3-none-any.whl Python 3 none any Details

Total release size: 546.5 kB

Release files / anywidget_graph-0.4.1.tar.gz

Download URL anywidget_graph-0.4.1.tar.gz
Size 447.3 kB
Tags Source
SHA-256 checksum
How to use checksums
8634fd42316c939a9ae7b8087cfd287fd99a67782d654be2674ad914458e5563
BLAKE2b-256 checksum
How to use checksums
8b6490d564f49587f3b211ff142e72c90f61551bcdc8b15c4442b11fd69552f2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release files / anywidget_graph-0.4.1-py3-none-any.whl

Download URL anywidget_graph-0.4.1-py3-none-any.whl
Size 99.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
42ee427738291360e365ca40d54a9a501c8d462d03da44d81669f56bdf7aae61
BLAKE2b-256 checksum
How to use checksums
01fe816bc764e52513006a3072ffe2a00241710caf913ad706ea4b88e6eedf9d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release history Release notifications | RSS feed

0.4.3

2 release files

0.4.2

2 release files

This release

0.4.1 This release

2 release files

0.4.0

2 release files

0.3.1

2 release files

0.3.0

2 release files

0.2.10

2 release files

0.2.9

2 release files

0.2.8

2 release files

0.2.7

2 release files

0.2.6

2 release files

0.2.5

2 release files

0.2.4

2 release files

0.2.3

2 release files

0.2.2

2 release files

0.2.1

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page