diff --git a/python/automake.mk b/python/automake.mk
index 1cda48af5..060d76cb7 100644
--- a/python/automake.mk
+++ b/python/automake.mk
@@ -71,6 +71,7 @@ ovs_flowviz = \
 	python/ovs/flowviz/main.py \
 	python/ovs/flowviz/odp/__init__.py \
 	python/ovs/flowviz/odp/cli.py \
+	python/ovs/flowviz/odp/graph.py \
 	python/ovs/flowviz/odp/html.py \
 	python/ovs/flowviz/odp/tree.py \
 	python/ovs/flowviz/ofp/__init__.py \
diff --git a/python/ovs/flowviz/odp/cli.py b/python/ovs/flowviz/odp/cli.py
index 73fadef95..f358ce1b7 100644
--- a/python/ovs/flowviz/odp/cli.py
+++ b/python/ovs/flowviz/odp/cli.py
@@ -15,6 +15,8 @@
 import click
 
 from ovs.flowviz.main import maincli
+
+from ovs.flowviz.odp.graph import GraphProcessor
 from ovs.flowviz.odp.html import HTMLTreeProcessor
 from ovs.flowviz.odp.tree import ConsoleTreeProcessor
 from ovs.flowviz.process import (
@@ -84,3 +86,24 @@ def html(opts):
     processor = HTMLTreeProcessor(opts)
     processor.process()
     processor.print()
+
+
+@datapath.command()
+@click.option(
+    "-h",
+    "--html",
+    is_flag=True,
+    default=False,
+    show_default=True,
+    help="Output an html file containing the graph",
+)
+@click.pass_obj
+def graph(opts, html):
+    """Print the flows in a graphviz (.dot) format showing the relationship
+    of recirc_ids."""
+    if len(opts.get("filename")) > 1:
+        raise click.BadParameter("Graph format only supports one input file")
+
+    processor = GraphProcessor(opts)
+    processor.process()
+    processor.print(html)
diff --git a/python/ovs/flowviz/odp/graph.py b/python/ovs/flowviz/odp/graph.py
new file mode 100644
index 000000000..4d1fb7493
--- /dev/null
+++ b/python/ovs/flowviz/odp/graph.py
@@ -0,0 +1,481 @@
+# Copyright (c) 2023 Red Hat, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+""" Defines a Datapath Graph using graphviz. """
+import colorsys
+import graphviz
+import random
+
+from ovs.flowviz.odp.html import HTMLTree, HTMLFormatter
+from ovs.flowviz.odp.tree import FlowTree
+from ovs.flowviz.process import FileProcessor
+
+
+class GraphProcessor(FileProcessor):
+    def __init__(self, opts):
+        super().__init__(opts, "odp")
+
+    def start_file(self, name, filename):
+        self.tree = FlowTree()
+
+    def start_thread(self, name):
+        pass
+
+    def stop_thread(self, name):
+        pass
+
+    def process_flow(self, flow, name):
+        self.tree.add(flow, self.opts.get("filter"))
+
+    def process(self):
+        super().process(False)
+
+    def print(self, html):
+        self.tree.build()
+
+        if len(self.tree.all_recirc_nodes) == 0:
+            return
+
+        dpg = DatapathGraph(self.tree, self.opts)
+        if not html:
+            print(dpg.source())
+            return
+
+        html_obj = "<html>"
+        html_obj += "<head>"
+        html_obj += HTMLTree.head()
+        html_obj += "</head>"
+
+        html_obj += "<body>"
+        html_obj += HTMLTree.begin_body(self.opts)
+        html_obj += "<h1> Flow Graph </h1>"
+        html_obj += """<div style="resize:both;
+                                   overflow-y:scroll;
+                                   overflow-x:scroll;
+                                   border: 1px solid;
+                                   width:2000px;
+                                   height:1200px;">"""
+        svg = dpg.pipe(format="svg")
+        html_obj += svg.decode("utf-8")
+        html_obj += "</div>"
+        html_tree = HTMLTree("graph", self.tree, self.opts)
+        html_obj += html_tree.format()
+        html_obj += HTMLTree.end_body()
+        html_obj += "</body>"
+        html_obj += "</html>"
+
+        print(html_obj)
+
+
+class DatapathGraph:
+    """A DatapathGraph is a class that renders a set of datapath flows into
+    graphviz graphs.
+
+    Args:
+        tree: FlowTree
+    """
+
+    ct_styles = {}
+    node_styles = {
+        "default": {
+            "style": {},
+            "desc": "Default",
+        },
+        "match": {
+            "style": {"color": "#0000ff"},
+            "desc": "Flow matches on CT",
+        },
+        "action": {
+            "style": {"color": "#ff0000"},
+            "desc": "Flow(s) has CT as action",
+        },
+    }
+
+    def __init__(self, tree, opts):
+        self._tree = tree
+        self._opts = opts
+
+        style = HTMLFormatter(self._opts).style
+
+        self.bgcolor = (
+            style.get("background").color
+            if style.get("background")
+            else "#f0f0f0"
+        )
+        self.fgcolor = (
+            style.get("default").color if style.get("default") else "black"
+        )
+
+        self._output_nodes = []
+        self._graph = graphviz.Digraph(
+            "DP flows",
+            node_attr={
+                "shape": "rectangle",
+                "fontcolor": self.fgcolor,
+                "color": self.fgcolor,
+            },
+            edge_attr={
+                "color": self.fgcolor,
+            },
+        )
+        self._graph.attr(color=self.fgcolor)
+        self._graph.attr(fontcolor=self.fgcolor)
+        self._graph.attr(bgcolor=self.bgcolor)
+        self._graph.attr(compound="true")
+        self._graph.attr(rankdir="LR")
+        self._graph.attr(ranksep="3")
+
+        self._populate_graph()
+
+    def source(self):
+        """Return the graphviz source representation of the graph."""
+        return self._graph.source
+
+    def pipe(self, *args, **kwargs):
+        """Output the graph based on arguments given to graphviz.pipe."""
+        return self._graph.pipe(*args, **kwargs)
+
+    @classmethod
+    def recirc_cluster_name(cls, node):
+        """Name of the recirculation cluster."""
+        return "cluster_recirc_{}_{}".format(hex(node.recirc), node.in_port)
+
+    @classmethod
+    def inport_cluster_name(cls, inport):
+        """Name of the input port cluster."""
+        return "cluster_inport_{}".format(inport)
+
+    @classmethod
+    def invis_node_name(cls, cluster_name):
+        """Name of the invisible node."""
+        return "invis_{}".format(cluster_name)
+
+    @classmethod
+    def output_node_name(cls, port):
+        """Name of the ouput node."""
+        return "output_{}".format(port)
+
+    @classmethod
+    def block_node_name(cls, block):
+        """Name of the flow block node."""
+        return "flow_block_{}".format(block.flows[0].flow.id)
+
+    def _block_node(self, block):
+        """Returns the dictionary of attributes of a graphviz node that
+        represents the FlowBlock."""
+
+        url = "#block_{}".format(block.flows[0].flow.id)
+
+        # Graphviz supports HTML-ish syntax. Use it to create a table and
+        # place each (summarized) match in each row and the (also summarized)
+        # action in the last one.
+        # A port is added to the actions row called "actions" that helps make
+        # edges start from it.
+        label = (
+            """<<table border="0" """
+            + """cellborder="1" """
+            + """cellspacing="0" """
+            + """cellpadding="4">"""
+        )
+
+        for i, tflow in enumerate(block.flows):
+            style = "default"
+
+            # Full representation of the flow to be used in the tooltip.
+            full = (
+                tflow.flow.section("info").string
+                + " "
+                + tflow.flow.section("match").string
+            )
+
+            # Summarized match: comma separated list of match keys with
+            # their ommitted with "..." or "---" depending on whether the
+            # match is the same for the entire block or not.
+            flowstr = ""
+            equal_keys = [m[1].key for m in block.equal_match]
+            for m in tflow.flow.match_kv:
+                fill = "..."
+                if m.key in equal_keys:
+                    fill = "---"
+
+                flowstr += (
+                    m.meta.kstring + m.meta.delim + fill + m.meta.end_delim
+                )
+
+                if m.key == "ct_state" and m.value != "0/0":
+                    style = "match"
+
+                flowstr += ","
+
+            flowstr.strip(",")
+            color = self.node_styles.get(style)["style"].get(
+                "color", self.fgcolor
+            )
+
+            label += f"""<tr>
+            <td tooltip="{full}" href="{url}" color="{color}">{flowstr}</td>
+            </tr>"""
+
+        # Add a row for the action.
+        fullact = "actions: " + block.flows[0].flow.section("actions").string
+        actstr = ",".join([a.key for a in block.flows[0].flow.actions_kv])
+
+        has_ct_action = bool(
+            next(
+                filter(
+                    lambda x: x.key in ["ct", "ct_clear"],
+                    block.flows[0].flow.actions_kv,
+                ),
+                None,
+            )
+        )
+
+        style = "action" if has_ct_action else "default"
+
+        color = self.node_styles.get(style)["style"].get("color", self.fgcolor)
+
+        label += f"""<tr>
+            <td cellpadding="4"
+                border="2"
+                href="{url}"
+                tooltip="{fullact}"
+                color="{color}"
+                port="actions">"""
+        label += f"<B>actions:</B>  {actstr}"
+        label += "</td></tr>"
+
+        label += "</table>>"
+
+        return {
+            "name": self.block_node_name(block),
+            "label": label,
+            "fontsize": "10",
+            "nojustify": "true",
+            "URL": url,
+        }
+
+    def _create_recirc_cluster(self, node):
+        """Create a cluster for the RecircNode."""
+
+        cluster_name = self.recirc_cluster_name(node)
+
+        label = "<<B>[recirc 0x{:0x} in_port {}]</B>>".format(
+            node.recirc, node.in_port
+        )
+
+        cluster = self._graph.subgraph(name=cluster_name, comment=label)
+        with cluster as sg:
+            sg.attr(rankdir="TB")
+            sg.attr(ranksep="0.02")
+            sg.attr(label=label)
+            sg.attr(margin="5")
+            self._add_blocks_to_graph(sg, node.visible_blocks())
+
+        self.processed_recircs.append((node.recirc, node.in_port))
+
+    def _add_blocks_to_graph(self, graph, blocks):
+        """Add FlowBlock objects in interable to the graph."""
+
+        # Create an invisible node and an edge to the first block so that
+        # it ends up at the top of the cluster.
+        invis = self.invis_node_name(graph.name)
+        graph.node(invis)
+        graph.node(
+            invis,
+            color=self.bgcolor,
+            len="0",
+            shape="point",
+            width="0",
+            height="0",
+        )
+        first = True
+
+        for block in blocks:
+            graph.node(**self._block_node(block))
+            if first:
+                with graph.subgraph() as c:
+                    c.attr(rank="same")
+                    c.edge(self.block_node_name(block), invis, style="invis")
+                first = False
+
+            # Determine next hop based on block actions.
+            self._set_next_node_from_block(block)
+
+    def _set_next_node_from_block(self, block):
+        """Create edges to other nodes based on the block's next RecircNodes
+        and special actions."""
+        created = False
+
+        # Start edges from the "actions" port of the block node.
+        name = self.block_node_name(block) + ":actions"
+
+        # Deal with RecircNodes first.
+        for node in block.next_recirc_nodes:
+            # If the target recirculation cluster has not yet been created,
+            # do it now.
+            if (node.recirc, node.in_port) not in self.processed_recircs:
+                self._create_recirc_cluster(node)
+
+            cname = self.recirc_cluster_name(node)
+            self._graph.edge(
+                name,
+                self.invis_node_name(cname),
+                lhead=cname,
+                _attributes={"weight": "20"},
+            )
+            created = True
+
+        # Then, deal with special actions.
+        created |= self._set_next_node_from_actions(
+            name, block.flows[0].flow.actions
+        )
+
+        if not created:
+            self._graph.edge(name, "end")
+
+    def _set_next_node_from_actions(self, name, actions):
+        """Create edges to other nodes based on the action list."""
+        created = False
+
+        for action in actions:
+            key, value = next(iter(action.items()))
+            if key == "check_pkt_len":
+                created |= self._set_next_node_from_actions(
+                    name, value.get("gt")
+                )
+                created |= self._set_next_node_from_actions(
+                    name, value.get("le")
+                )
+            elif key == "sample":
+                created |= self._set_next_node_from_actions(
+                    name, value.get("actions")
+                )
+            elif key == "clone":
+                created |= self._set_next_node_from_actions(
+                    name, value.get("actions")
+                )
+            else:
+                created |= self._set_next_node_action(name, key, value)
+        return created
+
+    def _set_next_node_action(self, name, action_name, action_obj):
+        """Based on the action object, set the next node."""
+        if action_name == "output":
+            port = action_obj.get("port")
+            if port not in self._output_nodes:
+                self._output_nodes.append(port)
+            self._graph.edge(
+                name, self.output_node_name(port), _attributes={"weight": "1"}
+            )
+            return True
+        elif action_name in ["drop", "userspace", "controller"]:
+            if action_name not in self._output_nodes:
+                self._output_nodes.append(action_name)
+            self._graph.edge(name, action_name, _attributes={"weight": "1"})
+            return True
+        elif action_name == "ct":
+            zone = action_obj.get("zone", 0)
+            node_name = "CT zone {}".format(action_obj.get("zone", "default"))
+            if zone not in self.ct_styles:
+                # Pick a random (highly saturated) color.
+                (r, g, b) = colorsys.hsv_to_rgb(random.random(), 1, 1)
+                color = "#%02x%02x%02x" % (
+                    int(r * 255),
+                    int(g * 255),
+                    int(b * 255),
+                )
+                self.ct_styles[zone] = color
+                self._graph.node(node_name, color=color)
+
+            color = self.ct_styles[zone]
+            self._graph.edge(name, node_name, style="dashed", color=color)
+            name = node_name
+            return True
+        return False
+
+    def _populate_graph(self):
+        """Populate the the internal graph."""
+        self.processed_recircs = []
+
+        # RecircNode clusters are created recursively when an edge is found
+        # pointing to them. Therefore, starting with recirc = 0 nodes.
+        for node in self._tree.recirc_nodes.get(0).values():
+            if node.visible:
+                self._create_recirc_cluster(node)
+
+        # Create an input node that points to each input subgraph
+        # They are all inside an anonymous subgraph so that they can be
+        # alligned.
+        with self._graph.subgraph() as s:
+            s.attr(rank="same")
+            for inport, node in self._tree.recirc_nodes.get(0).items():
+                if not node.visible:
+                    continue
+
+                node_name = "input_{}".format(inport)
+                cluster_name = self.recirc_cluster_name(node)
+                s.node(
+                    node_name,
+                    shape="Mdiamond",
+                    label="input port {}".format(inport),
+                )
+                self._graph.edge(
+                    node_name,
+                    self.invis_node_name(cluster_name),
+                    lhead=cluster_name,
+                    _attributes={"weight": "20"},
+                )
+
+        # Create the output nodes in a subgraph so that they are aligned.
+        with self._graph.subgraph() as s:
+            for port in self._output_nodes:
+                s.attr(rank="same")
+                if port == "drop":
+                    s.node(
+                        "drop",
+                        shape="Msquare",
+                        color="red",
+                        label="DROP",
+                        rank="sink",
+                    )
+                elif port == "controller":
+                    s.node(
+                        "controller",
+                        shape="Msquare",
+                        color="blue",
+                        label="CONTROLLER",
+                        rank="sink",
+                    )
+                elif port == "userspace":
+                    s.node(
+                        "userspace",
+                        shape="Msquare",
+                        color="blue",
+                        label="CONTROLLER",
+                        rank="sink",
+                    )
+                else:
+                    s.node(
+                        self.output_node_name(port),
+                        shape="Msquare",
+                        color="green",
+                        label="Port {}".format(port),
+                        rank="sink",
+                    )
+
+        # Print node style legend.
+        with self._graph.subgraph(name="cluster_legend") as s:
+            s.attr(label="Legend")
+            for style in self.node_styles.values():
+                s.node(name=style.get("desc"), _attributes=style.get("style"))
diff --git a/python/setup.py.template b/python/setup.py.template
index 5d48980c5..415d5c0df 100644
--- a/python/setup.py.template
+++ b/python/setup.py.template
@@ -106,7 +106,7 @@ setup_args = dict(
                     'dns': ['unbound'],
                     'flow': flow_extras_require,
                     'flowviz':
-                        [*flow_extras_require, 'click', 'rich'],
+                        [*flow_extras_require, 'click', 'rich', 'graphviz'],
                     },
     scripts=["ovs/flowviz/ovs-flowviz"],
     data_files=["ovs/flowviz/ovs-flowviz.conf"],
