diff --git a/NEWS b/NEWS
index daa9ff5..91d269b 100644
--- a/NEWS
+++ b/NEWS
@@ -12,6 +12,8 @@ Post-v2.6.0
        - put_dhcp_opts and put_dhcp_optsv6 actions may now be traced.
      * Support for managing SSL and remote connection configuration in
        northbound and southbound databases.
+     * New appctl "inject-pkt" command in ovn-controller that allows
+       packets to be injected into the connected OVS instance.
    - Fixed regression in table stats maintenance introduced in OVS
      2.3.0, wherein the number of OpenFlow table hits and misses was
      not accurate.
diff --git a/ovn/controller/ofctrl.c b/ovn/controller/ofctrl.c
index c79660b..9c4019b 100644
--- a/ovn/controller/ofctrl.c
+++ b/ovn/controller/ofctrl.c
@@ -17,6 +17,7 @@
 #include "bitmap.h"
 #include "byte-order.h"
 #include "dirs.h"
+#include "dp-packet.h"
 #include "flow.h"
 #include "hash.h"
 #include "lflow.h"
@@ -70,6 +71,9 @@ static void ovn_flow_destroy(struct ovn_flow *);
 /* OpenFlow connection to the switch. */
 static struct rconn *swconn;
 
+/* Symbol table for OVN expressions. */
+static struct shash symtab;
+
 /* Last seen sequence number for 'swconn'.  When this differs from
  * rconn_get_connection_seqno(rconn), 'swconn' has reconnected. */
 static unsigned int seqno;
@@ -152,6 +156,7 @@ ofctrl_init(struct group_table *group_table)
     tx_counter = rconn_packet_counter_create();
     hmap_init(&installed_flows);
     ovs_list_init(&flow_updates);
+    ovn_init_symtab(&symtab);
     groups = group_table;
 }
 
@@ -544,6 +549,7 @@ ofctrl_destroy(void)
     rconn_destroy(swconn);
     ovn_flow_table_destroy(&installed_flows);
     rconn_packet_counter_destroy(tx_counter);
+    shash_destroy(&symtab);
 }
 
 int64_t
@@ -1067,3 +1073,92 @@ ofctrl_put(struct hmap *flow_table, struct shash *pending_ct_zones,
         cur_cfg = nb_cfg;
     }
 }
+
+/* Looks up the logical port with the name 'port_name' in 'br_int_'.  If
+ * found, returns true and sets '*portp' to the OpenFlow port number
+ * assigned to the port.  Otherwise, returns false. */
+static bool
+ofctrl_lookup_port(const void *br_int_, const char *port_name,
+                   unsigned int *portp)
+{
+    const struct ovsrec_bridge *br_int = br_int_;
+
+    for (int i = 0; i < br_int->n_ports; i++) {
+        const struct ovsrec_port *port_rec = br_int->ports[i];
+        for (int j = 0; j < port_rec->n_interfaces; j++) {
+            const struct ovsrec_interface *iface_rec = port_rec->interfaces[j];
+            const char *iface_id = smap_get(&iface_rec->external_ids,
+                                            "iface-id");
+
+            if (iface_id && !strcmp(iface_id, port_name)) {
+                if (!iface_rec->n_ofport) {
+                    continue;
+                }
+
+                int64_t ofport = iface_rec->ofport[0];
+                if (ofport < 1 || ofport > ofp_to_u16(OFPP_MAX)) {
+                    continue;
+                }
+                *portp = ofport;
+                return true;
+            }
+        }
+    }
+
+    return false;
+}
+
+/* Generates a packet described by 'flow_s' in the syntax of an OVN
+ * logical expression and injects it into 'br_int'.  The flow
+ * description must contain an ingress logical port that is present on
+ * 'br_int'.
+ *
+ * Returns NULL if successful, otherwise an error message that the caller
+ * must free(). */
+char *
+ofctrl_inject_pkt(const struct ovsrec_bridge *br_int, const char *flow_s,
+                  const struct shash *addr_sets)
+{
+    struct flow uflow;
+    char *error = expr_parse_microflow(flow_s, &symtab, addr_sets,
+                                       ofctrl_lookup_port, br_int, &uflow);
+    if (error) {
+        return error;
+    }
+
+    /* The physical OpenFlow port was stored in the logical ingress
+     * port, so put it in the correct location for a flow structure. */
+    uflow.in_port.ofp_port = uflow.regs[MFF_LOG_INPORT - MFF_REG0];
+    uflow.regs[MFF_LOG_INPORT - MFF_REG0] = 0;
+
+    if (!uflow.in_port.ofp_port) {
+        return xstrdup("ingress port not found on hypervisor.");
+    }
+
+    uint64_t packet_stub[128 / 8];
+    struct dp_packet packet;
+    dp_packet_use_stub(&packet, packet_stub, sizeof packet_stub);
+    flow_compose(&packet, &uflow);
+
+    uint64_t ofpacts_stub[1024 / 8];
+    struct ofpbuf ofpacts = OFPBUF_STUB_INITIALIZER(ofpacts_stub);
+    enum ofp_version version = rconn_get_version(swconn);
+    struct ofpact_resubmit *resubmit = ofpact_put_RESUBMIT(&ofpacts);
+    resubmit->in_port = OFPP_IN_PORT;
+    resubmit->table_id = 0;
+
+    struct ofputil_packet_out po = {
+        .packet = dp_packet_data(&packet),
+        .packet_len = dp_packet_size(&packet),
+        .buffer_id = UINT32_MAX,
+        .in_port = uflow.in_port.ofp_port,
+        .ofpacts = ofpacts.data,
+        .ofpacts_len = ofpacts.size,
+    };
+    enum ofputil_protocol proto = ofputil_protocol_from_ofp_version(version);
+    queue_msg(ofputil_encode_packet_out(&po, proto));
+    dp_packet_uninit(&packet);
+    ofpbuf_uninit(&ofpacts);
+
+    return NULL;
+}
diff --git a/ovn/controller/ofctrl.h b/ovn/controller/ofctrl.h
index 79f0cb7..bf5ba01 100644
--- a/ovn/controller/ofctrl.h
+++ b/ovn/controller/ofctrl.h
@@ -23,11 +23,12 @@
 #include "ovsdb-idl.h"
 
 struct controller_ctx;
+struct group_table;
 struct hmap;
 struct match;
 struct ofpbuf;
 struct ovsrec_bridge;
-struct group_table;
+struct shash;
 
 /* Interface for OVN main loop. */
 void ofctrl_init(struct group_table *group_table);
@@ -43,6 +44,9 @@ struct ovn_flow *ofctrl_dup_flow(struct ovn_flow *source);
 
 void ofctrl_ct_flush_zone(uint16_t zone_id);
 
+char *ofctrl_inject_pkt(const struct ovsrec_bridge *br_int,
+                        const char *flow_s, const struct shash *addr_sets);
+
 /* Flow table interfaces to the rest of ovn-controller. */
 void ofctrl_add_flow(struct hmap *desired_flows, uint8_t table_id,
                      uint16_t priority, uint64_t cookie,
diff --git a/ovn/controller/ovn-controller.8.xml b/ovn/controller/ovn-controller.8.xml
index 9f4dad1..dda26b3 100644
--- a/ovn/controller/ovn-controller.8.xml
+++ b/ovn/controller/ovn-controller.8.xml
@@ -300,6 +300,26 @@
       <dd>
         Lists each local logical port and its connection tracking zone.
       </dd>
+
+      <dt><code>inject-pkt</code> <var>microflow</var></dt>
+      <dd>
+      <p>
+        Injects <var>microflow</var> into the connected Open vSwitch
+        instance.  <var>microflow</var> must contain an ingress logical
+        port (<code>inport</code> argument) that is present on the Open
+        vSwitch instance.
+      </p>
+
+      <p>
+        The <var>microflow</var> argument describes the packet whose
+        forwarding is to be simulated, in the syntax of an OVN logical
+        expression, as described in <code>ovn-sb</code>(5), to express
+        constraints.  The parser understands prerequisites; for example,
+        if the expression refers to <code>ip4.src</code>, there is no
+        need to explicitly state <code>ip4</code> or <code>eth.type ==
+        0x800</code>.
+      </p>
+      </dd>
       </dl>
     </p>
 
diff --git a/ovn/controller/ovn-controller.c b/ovn/controller/ovn-controller.c
index 2533899..36dbcdf 100644
--- a/ovn/controller/ovn-controller.c
+++ b/ovn/controller/ovn-controller.c
@@ -59,6 +59,7 @@ VLOG_DEFINE_THIS_MODULE(main);
 
 static unixctl_cb_func ovn_controller_exit;
 static unixctl_cb_func ct_zone_list;
+static unixctl_cb_func inject_pkt;
 
 #define DEFAULT_BRIDGE_NAME "br-int"
 #define DEFAULT_PROBE_INTERVAL_MSEC 5000
@@ -67,6 +68,13 @@ static void update_probe_interval(struct controller_ctx *);
 static void parse_options(int argc, char *argv[]);
 OVS_NO_RETURN static void usage(void);
 
+/* Pending packet to be injected into connected OVS. */
+struct pending_pkt {
+    /* Setting 'conn' indicates that a request is pending. */
+    struct unixctl_conn *conn;
+    char *flow_s;
+};
+
 static char *ovs_remote;
 
 struct local_datapath *
@@ -546,6 +554,9 @@ main(int argc, char *argv[])
                              ct_zone_list, &ct_zones);
 
     struct shash addr_sets = SHASH_INITIALIZER(&addr_sets);
+    struct pending_pkt pending_pkt = { .conn = NULL };
+    unixctl_command_register("inject-pkt", "MICROFLOW", 1, 1, inject_pkt,
+                             &pending_pkt);
 
     /* Main loop. */
     exiting = false;
@@ -620,6 +631,7 @@ main(int argc, char *argv[])
 
                 ofctrl_put(&flow_table, &pending_ct_zones,
                            get_nb_cfg(ctx.ovnsb_idl));
+
                 hmap_destroy(&flow_table);
                 if (ctx.ovnsb_idl_txn) {
                     int64_t cur_cfg = ofctrl_get_cur_cfg();
@@ -629,10 +641,32 @@ main(int argc, char *argv[])
                 }
             }
 
+            if (pending_pkt.conn) {
+                char *error = ofctrl_inject_pkt(br_int, pending_pkt.flow_s,
+                                                &addr_sets);
+                if (error) {
+                    unixctl_command_reply_error(pending_pkt.conn, error);
+                    free(error);
+                } else {
+                    unixctl_command_reply(pending_pkt.conn, NULL);
+                }
+                pending_pkt.conn = NULL;
+                free(pending_pkt.flow_s);
+            }
+
             update_sb_monitors(ctx.ovnsb_idl, chassis,
                                &local_lports, &local_datapaths);
         }
 
+        /* If we haven't handled the pending packet insertion
+         * request, the system is not ready. */
+        if (pending_pkt.conn) {
+            unixctl_command_reply_error(pending_pkt.conn,
+                                        "ovn-controller not ready.");
+            pending_pkt.conn = NULL;
+            free(pending_pkt.flow_s);
+        }
+
         mcgroup_index_destroy(&mcgroups);
         lport_index_destroy(&lports);
         ldatapath_index_destroy(&ldatapaths);
@@ -649,7 +683,7 @@ main(int argc, char *argv[])
         unixctl_server_run(unixctl);
 
         unixctl_server_wait(unixctl);
-        if (exiting) {
+        if (exiting || pending_pkt.conn) {
             poll_immediate_wake();
         }
 
@@ -849,6 +883,20 @@ ct_zone_list(struct unixctl_conn *conn, int argc OVS_UNUSED,
     ds_destroy(&ds);
 }
 
+static void
+inject_pkt(struct unixctl_conn *conn, int argc OVS_UNUSED,
+           const char *argv[], void *pending_pkt_)
+{
+    struct pending_pkt *pending_pkt = pending_pkt_;
+
+    if (pending_pkt->conn) {
+        unixctl_command_reply_error(conn, "already pending packet injection");
+        return;
+    }
+    pending_pkt->conn = conn;
+    pending_pkt->flow_s = xstrdup(argv[1]);
+}
+
 /* Get the desired SB probe timer from the OVS database and configure it into
  * the SB database. */
 static void
diff --git a/ovn/controller/pinctrl.h b/ovn/controller/pinctrl.h
index af3c4b0..230580b 100644
--- a/ovn/controller/pinctrl.h
+++ b/ovn/controller/pinctrl.h
@@ -21,10 +21,10 @@
 
 #include "openvswitch/meta-flow.h"
 
+struct controller_ctx;
 struct hmap;
 struct lport_index;
 struct ovsrec_bridge;
-struct controller_ctx;
 struct sbrec_chassis;
 
 void pinctrl_init(void);
