diff --git a/doc/reference.rst b/doc/reference.rst
index 8c7ccc2dc0ddba97b756203751260431cd24c593..d18c2c5e1e9c22386620af4398b8448f860de211 100644
--- a/doc/reference.rst
+++ b/doc/reference.rst
@@ -1,2 +1,4 @@
 .. automodule:: pytools
 .. automodule:: pytools.datatable
+
+.. automodule:: pytools.graphviz
diff --git a/pytools/graph.py b/pytools/graph.py
index 69421d37c730fe159e648ab348e74516ce78a61d..00bac77fb69e1bd37af6a42c82ee06379e107fd4 100644
--- a/pytools/graph.py
+++ b/pytools/graph.py
@@ -43,6 +43,7 @@ Graph Algorithms
 .. autofunction:: compute_transitive_closure
 .. autofunction:: contains_cycle
 .. autofunction:: compute_induced_subgraph
+.. autofunction:: as_graphviz_dot
 .. autofunction:: validate_graph
 .. autofunction:: is_connected
 
@@ -410,6 +411,63 @@ def compute_induced_subgraph(graph: Mapping[NodeT, Set[NodeT]],
 # }}}
 
 
+# {{{ as_graphviz_dot
+
+def as_graphviz_dot(graph: GraphT[NodeT],
+                    node_labels: Optional[Callable[[NodeT], str]] = None,
+                    edge_labels: Optional[Callable[[NodeT, NodeT], str]] = None) \
+                    -> str:
+    """
+    Create a visualization of the graph *graph* in the
+    `dot <http://graphviz.org/>`__ language.
+
+    :arg node_labels: An optional function that returns node labels
+        for each node.
+
+    :arg edge_labels: An optional function that returns edge labels
+        for each pair of nodes.
+
+    :returns: A string in the `dot <http://graphviz.org/>`__ language.
+    """
+    from pytools import UniqueNameGenerator
+    id_gen = UniqueNameGenerator(forced_prefix="mynode")
+
+    from pytools.graphviz import dot_escape
+
+    if node_labels is None:
+        node_labels = lambda x: str(x)
+
+    if edge_labels is None:
+        edge_labels = lambda x, y: ""
+
+    node_to_id = {}
+
+    for node, targets in graph.items():
+        if node not in node_to_id:
+            node_to_id[node] = id_gen()
+        for t in targets:
+            if t not in node_to_id:
+                node_to_id[t] = id_gen()
+
+    # Add nodes
+    content = "\n".join(
+        [f'{node_to_id[node]} [label="{dot_escape(node_labels(node))}"];'
+         for node in node_to_id.keys()])
+
+    content += "\n"
+
+    # Add edges
+    content += "\n".join(
+        [f"{node_to_id[node]} -> {node_to_id[t]} "
+         f'[label="{dot_escape(edge_labels(node, t))}"];'
+         for (node, targets) in graph.items()
+         for t in targets])
+
+    return f"digraph mygraph {{\n{ content }\n}}\n"
+
+# }}}
+
+
 # {{{ validate graph
 
 def validate_graph(graph: GraphT[NodeT]) -> None:
diff --git a/pytools/graphviz.py b/pytools/graphviz.py
new file mode 100644
index 0000000000000000000000000000000000000000..3340b7bc0d582507971a0547efb735c4d34a0498
--- /dev/null
+++ b/pytools/graphviz.py
@@ -0,0 +1,133 @@
+__copyright__ = """
+Copyright (C) 2013 Andreas Kloeckner
+Copyright (C) 2014 Matt Wala
+"""
+
+__license__ = """
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+"""
+
+__doc__ = """
+Dot helper functions
+====================
+
+.. autofunction:: dot_escape
+.. autofunction:: show_dot
+"""
+
+from typing import Optional
+
+import html
+import logging
+logger = logging.getLogger(__name__)
+
+
+# {{{ graphviz / dot interactive show
+
+def dot_escape(s: str) -> str:
+    """
+    Escape the string *s* for compatibility with the
+    `dot <http://graphviz.org/>`__ language, particularly
+    backslashes and HTML tags.
+
+    :arg s: The input string to escape.
+
+    :returns: *s* with special characters escaped.
+    """
+    # "\" and HTML are significant in graphviz.
+    return html.escape(s.replace("\\", "\\\\"))
+
+
+def show_dot(dot_code: str, output_to: Optional[str] = None) -> Optional[str]:
+    """
+    Visualize the graph represented by *dot_code*.
+
+    :arg dot_code: An instance of :class:`str` in the `dot <http://graphviz.org/>`__
+        language to visualize.
+    :arg output_to: An instance of :class:`str` that can be one of:
+
+        - ``"xwindow"`` to visualize the graph as an
+          `X window <https://en.wikipedia.org/wiki/X_Window_System>`_.
+        - ``"browser"`` to visualize the graph as an SVG file in the
+          system's default web-browser.
+        - ``"svg"`` to store the dot code as an SVG file on the file system.
+          Returns the path to the generated SVG file.
+
+        Defaults to ``"xwindow"`` if X11 support is present, otherwise defaults
+        to ``"browser"``.
+
+    :returns: Depends on *output_to*. If ``"svg"``, returns the path to the
+        generated SVG file, otherwise returns ``None``.
+    """
+
+    from tempfile import mkdtemp
+    import subprocess
+    temp_dir = mkdtemp(prefix="tmp_pytools_dot")
+
+    dot_file_name = "code.dot"
+
+    from os.path import join
+    with open(join(temp_dir, dot_file_name), "w") as dotf:
+        dotf.write(dot_code)
+
+    # {{{ preprocess 'output_to'
+
+    if output_to is None:
+        with subprocess.Popen(["dot", "-T?"],
+                              stdout=subprocess.PIPE,
+                              stderr=subprocess.PIPE
+                              ) as proc:
+            assert proc.stderr, ("Could not execute the 'dot' program. "
+                                 "Please install the 'graphviz' package and "
+                                 "make sure it is in your $PATH.")
+            supported_formats = proc.stderr.read().decode()
+
+            if " x11 " in supported_formats:
+                output_to = "xwindow"
+            else:
+                output_to = "browser"
+
+    # }}}
+
+    if output_to == "xwindow":
+        subprocess.check_call(["dot", "-Tx11", dot_file_name], cwd=temp_dir)
+    elif output_to in ["browser", "svg"]:
+        svg_file_name = "code.svg"
+        subprocess.check_call(["dot", "-Tsvg", "-o", svg_file_name, dot_file_name],
+                              cwd=temp_dir)
+
+        full_svg_file_name = join(temp_dir, svg_file_name)
+        logger.info("show_dot: svg written to '%s'", full_svg_file_name)
+
+        if output_to == "svg":
+            return full_svg_file_name
+        else:
+            assert output_to == "browser"
+
+            from webbrowser import open as browser_open
+            browser_open("file://" + full_svg_file_name)
+    else:
+        raise ValueError("`output_to` can be one of 'xwindow', 'browser', or 'svg',"
+                         f" got '{output_to}'")
+
+    return None
+# }}}
+
+
+# vim: foldmethod=marker
diff --git a/test/test_graph_tools.py b/test/test_graph_tools.py
index 2f14f1ada23809ef84e6c57a7cfa5bcd85170742..f9814ea0c0f98c347e7ffed4a15bcf7327a3025b 100644
--- a/test/test_graph_tools.py
+++ b/test/test_graph_tools.py
@@ -294,6 +294,39 @@ def test_prioritized_topological_sort():
     assert len(dep_graph) == 0
 
 
+def test_as_graphviz_dot():
+    graph = {"A": ["B", "C"],
+             "B": [],
+             "C": ["A"]}
+
+    from pytools.graph import as_graphviz_dot, NodeT
+
+    def edge_labels(n1: NodeT, n2: NodeT) -> str:
+        if n1 == "A" and n2 == "B":
+            return "foo"
+
+        return ""
+
+    def node_labels(node: NodeT) -> str:
+        if node == "A":
+            return "foonode"
+
+        return str(node)
+
+    res = as_graphviz_dot(graph, node_labels=node_labels, edge_labels=edge_labels)
+
+    assert res == \
+"""digraph mygraph {
+mynodeid [label="foonode"];
+mynodeid_0 [label="B"];
+mynodeid_1 [label="C"];
+mynodeid -> mynodeid_0 [label="foo"];
+mynodeid -> mynodeid_1 [label=""];
+mynodeid_1 -> mynodeid [label=""];
+}
+"""
+
+
 def test_reverse_graph():
     graph = {
         "a": frozenset(("b", "c")),