graphviz
graphviz is a small Python package that builds DOT language source code and shells out to the Graphviz command line tools to draw it. You create a Digraph or Graph object, call .node() and .edge() to describe the picture, and the object keeps a plain text DOT string you can print, save, or render to PDF, PNG, or SVG. Rendering runs the dot binary as a subprocess, so the drawing quality and the layout algorithms are Graphviz's, not Python's. It also knows how to show itself inside Jupyter, which is why so many tree and pipeline visualisations in notebooks route through it.
The shortest path from Python objects to a decent-looking static diagram, as long as you can install the Graphviz binaries alongside it. Treat it as a typed front end for the DOT language rather than a graph library, and reach for networkx or pydot the moment you need to analyse or re-read a graph.
Use it if
- You need a static diagram of something structural (a state machine, a dependency graph, a decision tree, an ER sketch) and you want the layout computed for you instead of placing boxes by hand
- You are rendering DOT strings that some other tool already produced, for example sklearn.tree.export_graphviz output, a Django model graph, or a build system dependency dump
- You work in Jupyter and want the graph to appear inline: the objects implement the notebook display protocol, so returning a Digraph from a cell draws it
- You want the DOT source itself as an artifact you can diff, commit, and hand to any other Graphviz tool later
- You cannot install the Graphviz system binaries. pip install graphviz gets you a string builder and nothing else; without dot on PATH every .render() and .pipe() call raises ExecutableNotFound, which bites hardest in slim Docker images, AWS Lambda, and locked-down CI runners
- You need a graph data structure rather than a drawing. There is no traversal, no shortest path, no cycle detection, and no way to read a rendered graph back; it emits text and forgets it. networkx is the library for that
- You need to parse existing .dot files into objects you can edit. graphviz.Source holds the file contents as an opaque string, so editing means string surgery; pydot actually parses DOT into a node and edge model
- You need exact control of node positions or an interactive, pannable, clickable graph. Layout is whatever the chosen Graphviz engine decides, and the output is a flat image file
- You want a project on a fast release cadence. It is still on a 0.x version and classified Development Status 4 (Beta) after more than a decade, version 0.21 shipped in June 2025, and it is effectively one maintainer with about 10 open issues
Setup reality
Two installs, not one. pip install graphviz gives you the Python layer; you also need the Graphviz distribution itself (apt install graphviz, brew install graphviz, or the Windows installer) with the dot executable on PATH, and the Windows installer historically did not add it for you. In Docker that means an extra apt-get line and roughly 100MB of system packages you may not have budgeted for. On conda the package name differs: python-graphviz pulls the graphviz binaries as a dependency, plain graphviz on PyPI does not. Rendering spawns a subprocess per call, so tight loops over hundreds of small graphs are slow for reasons no amount of Python tuning will fix, and errors from dot arrive as CalledProcessError with the tool's stderr attached rather than as a Python-level explanation.
Patterns
Build a directed graph and inspect the DOT sourcebuild-a-directed-graph
import graphviz
dot = graphviz.Digraph("pipeline", comment="ETL stages")
dot.node("extract", "Extract")
dot.node("transform", "Transform")
dot.node("load", "Load")
dot.edge("extract", "transform")
dot.edge("transform", "load")
print(dot.source)Nothing runs the dot binary yet. Building and printing .source works even on a machine with no Graphviz installed, which makes it the safe thing to unit test.
Render to PNG or PDF on diskrender-to-a-file
import graphviz
dot = graphviz.Digraph(format="png")
dot.edge("a", "b")
path = dot.render("build/graph", cleanup=True)
print(path) # build/graph.png
# or name the output file directly:
dot.render(outfile="build/graph.svg")render() writes the .gv source file next to the image and leaves it there unless you pass cleanup=True. Passing outfile= infers the format from the extension and overrides format=.
Get the image bytes without touching diskrender-in-memory
import graphviz
dot = graphviz.Digraph()
dot.edge("a", "b")
png_bytes = dot.pipe(format="png")
svg_text = dot.pipe(format="svg", encoding="utf-8")
return_response(png_bytes, content_type="image/png")pipe() returns bytes unless you pass encoding, then it returns str. This is the call to use in a web handler; render() would litter the server's filesystem.
Fail clearly when the dot executable is absenthandle-missing-binary
import graphviz
try:
graphviz.version()
except graphviz.ExecutableNotFound:
raise SystemExit(
"Graphviz is not installed. Try: apt-get install graphviz"
)
try:
dot.render("out/graph")
except graphviz.CalledProcessError as exc:
print("dot rejected the source:", exc.stderr)ExecutableNotFound means the binary is missing; CalledProcessError means dot ran and complained, usually about bad DOT syntax in a label you built from user data.
Style the whole graph, or one elementset-graph-node-edge-attributes
import graphviz
dot = graphviz.Digraph(
graph_attr={"rankdir": "LR", "bgcolor": "white"},
node_attr={"shape": "box", "style": "rounded,filled", "fillcolor": "#eef"},
edge_attr={"arrowhead": "vee"},
)
dot.node("err", "Failure", fillcolor="#fdd")
dot.edge("ok", "err", color="red", label="retry")
# change defaults partway through:
dot.attr("node", shape="ellipse")
dot.node("end", "Done")dot.attr('node', ...) changes the default for every node added after that line, not the ones already added. Order matters here in a way that trips people up.
Group nodes into a boxed clustercluster-subgraphs
import graphviz
dot = graphviz.Digraph()
with dot.subgraph(name="cluster_api") as c:
c.attr(label="API tier", style="filled", color="lightgrey")
c.node("web")
c.node("auth")
with dot.subgraph(name="cluster_db") as c:
c.attr(label="Storage")
c.node("postgres")
dot.edge("web", "postgres")The name must start with the literal prefix cluster_ or Graphviz draws no box at all. This is an upstream DOT rule, not a Python one, and it fails silently.
Switch layout engine for non-hierarchical graphschoose-layout-engine
import graphviz
# dot: layered, good for DAGs. neato/fdp: spring layouts for messy graphs.
g = graphviz.Graph(engine="neato")
g.edge("a", "b")
g.edge("b", "c")
g.edge("c", "a")
g.render("out/mesh", format="svg")
# one-off override without rebuilding the object:
g.render("out/mesh_fdp", engine="fdp")graphviz.Graph is undirected and writes -- edges; Digraph writes ->. Mixing an engine that ignores rankdir (like neato) with rankdir=LR silently does nothing.
Stop user text from being read as DOT markupescape-untrusted-labels
import graphviz
user_label = "<b>not html</b> | pipe \\l backslash"
dot = graphviz.Digraph()
dot.node("n1", graphviz.escape(user_label))
# opposite direction: keep a label literal that starts with '<'
dot.node("n2", graphviz.nohtml("<config>"))Labels wrapped in angle brackets are parsed as HTML-like markup and backslash sequences such as \\l and \\N are layout directives, so unescaped user input either breaks the render or quietly changes the drawing.
Draw record nodes and attach edges to fieldsrecord-nodes-with-ports
import graphviz
s = graphviz.Digraph(node_attr={"shape": "record"})
s.node("users", "{users|<id> id\\l|<email> email\\l}")
s.node("orders", "{orders|<id> id\\l|<user_id> user_id\\l}")
s.edge("orders:user_id", "users:id")The <name> markers define ports, and you reference them as node:port in edges. Curly braces flip the record from horizontal to vertical, which is what makes it look like a table.
Render DOT text produced somewhere elserender-existing-dot-source
import graphviz
from sklearn import tree
dot_text = tree.export_graphviz(clf, out_file=None, filled=True)
src = graphviz.Source(dot_text)
src.render("out/tree", format="pdf", cleanup=True)
# or straight from a file on disk
src = graphviz.Source.from_file("deps.gv")Source is a thin wrapper around the text. It cannot add or remove nodes; if you need to edit the graph, parse it with pydot instead of doing regex work on the string.
Show a graph inline in a notebookdisplay-in-jupyter
import graphviz
graphviz.set_jupyter_format("png") # default is svg
dot = graphviz.Digraph()
dot.edge("a", "b")
dot # last expression in the cell renders inlineSVG is sharper but some notebook viewers and PDF exports drop it, so switching to png is the usual fix when a shared notebook shows blank output.
Unflatten a wide, shallow graphreduce-graph-width
import graphviz
dot = graphviz.Digraph()
for i in range(12):
dot.edge("root", f"leaf{i}")
narrow = dot.unflatten(stagger=3)
narrow.render("out/narrow", format="png")unflatten shells out to the separate unflatten binary and returns a new Source object, so it needs that tool present too and does not modify the original graph.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| pydot | PyPI | You need to read existing DOT files into an editable object model, not only write new ones. |
| pygraphviz | PyPI | You want layout computed in-process through the Graphviz C library and access to the resulting node coordinates, and you can live with building a C extension. |
| networkx | PyPI | The graph is a data structure you need to analyse and traverse, with drawing as a side task. |
| diagrams | PyPI | You want cloud architecture pictures with vendor icons and would rather not write DOT attributes yourself. |