diff --git a/NEWS b/NEWS
index b92cec532..f30b859fd 100644
--- a/NEWS
+++ b/NEWS
@@ -7,6 +7,8 @@ Post-v3.3.0
    - The primary development branch has been renamed from 'master' to 'main'.
      The OVS tree remains hosted on GitHub.
      https://github.com/openvswitch/ovs.git
+   - Python:
+     * Add custom transaction support to the Idl via add_op().
 
 
 v3.3.0 - 16 Feb 2024
diff --git a/python/ovs/db/idl.py b/python/ovs/db/idl.py
index a80da84e7..1220e4cc6 100644
--- a/python/ovs/db/idl.py
+++ b/python/ovs/db/idl.py
@@ -1703,6 +1703,8 @@ class Transaction(object):
 
         self._inserted_rows = {}  # Map from UUID to _InsertedRow
 
+        self._operations = []
+
     def add_comment(self, comment):
         """Appends 'comment' to the comments that will be passed to the OVSDB
         server when this transaction is committed.  (The comment will be
@@ -1838,7 +1840,7 @@ class Transaction(object):
                                    "rows": [rows]})
 
         # Add updates.
-        any_updates = False
+        any_updates = bool(self._operations)
         for row in self._txn_rows.values():
             if row._changes is None:
                 if row._table.is_root:
@@ -1977,6 +1979,7 @@ class Transaction(object):
         if self.dry_run:
             operations.append({"op": "abort"})
 
+        operations += self._operations
         if not any_updates:
             self._status = Transaction.UNCHANGED
         else:
@@ -1991,6 +1994,19 @@ class Transaction(object):
         self.__disassemble()
         return self._status
 
+    def add_op(self, op):
+        """Add a raw OVSDB operation to the transaction
+
+        This can be useful for re-using the existing Idl connection to take
+        actions that are difficult or expensive to do with the Idl itself. e.g.
+        deleting a bunch of rows on the server that you don't want to store
+        in memory.
+
+        :param op: An "op" for an OVSDB "transact" request (rfc 7047 Sec 5.2)
+        :type op:   dict
+        """
+        self._operations.append(op)
+
     def commit_block(self):
         """Attempts to commit this transaction, blocking until the commit
         either succeeds or fails.  Returns the final commit status, which may
diff --git a/tests/ovsdb-idl.at b/tests/ovsdb-idl.at
index fb568dd82..487545a13 100644
--- a/tests/ovsdb-idl.at
+++ b/tests/ovsdb-idl.at
@@ -2758,6 +2758,33 @@ OVSDB_CHECK_IDL_PERS_UUID_INSERT([simple idl, persistent uuid insert],
   [['This UUID would duplicate a UUID already present within the table or deleted within the same transaction']])
 
 
+OVSDB_CHECK_IDL_PY([simple idl, python, add_op],
+  [],
+  [['insert 1, insert 2, insert 3, insert 1' \
+    'add_op {"op": "delete", "table": "simple", "where": [["i", "==", 1]]}' \
+    'add_op {"op": "insert", "table": "simple", "row": {"i": 2}}, delete 3' \
+    'insert 2, add_op {"op": "update", "table": "simple", "row": {"i": 1}, "where": [["i", "==", 2]]}'
+  ]],
+  [[000: empty
+001: commit, status=success
+002: table simple: i=1 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<1>
+002: table simple: i=1 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<2>
+002: table simple: i=2 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<3>
+002: table simple: i=3 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<4>
+003: commit, status=success
+004: table simple: i=2 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<3>
+004: table simple: i=3 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<4>
+005: commit, status=success
+006: table simple: i=2 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<3>
+006: table simple: i=2 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<5>
+007: commit, status=success
+008: table simple: i=1 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<3>
+008: table simple: i=1 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<5>
+008: table simple: i=1 r=0 b=false s= u=<0> ia=[] ra=[] ba=[] sa=[] ua=[] uuid=<6>
+009: done
+]],[],sort)
+
+
 m4_define([OVSDB_CHECK_IDL_CHANGE_AWARE],
   [AT_SETUP([simple idl, database change aware, online conversion - $1])
    AT_KEYWORDS([ovsdb server idl db_change_aware conversion $1])
diff --git a/tests/test-ovsdb.py b/tests/test-ovsdb.py
index 48f8ee2d7..58829f520 100644
--- a/tests/test-ovsdb.py
+++ b/tests/test-ovsdb.py
@@ -36,6 +36,53 @@ vlog.set_levels_from_string("console:dbg")
 vlog.init(None)
 
 
+OBJ_STR = "_OBJECT_{}_"
+
+
+def substitute_object_text(data, quotechar='"', obj_chars=("{}", "[]")):
+    obj_chars = dict(obj_chars)
+    in_quote = False
+    in_object = []
+    removed_text = []
+    output = ""
+    start = end = 0
+    for i, s in enumerate(data):
+        if not in_object:
+            if not in_quote and s in obj_chars:
+                in_object.append(s)
+                start = i
+            else:
+                output += s
+            if s == quotechar:
+                in_quote = not in_quote
+        elif not in_quote:  # unquoted object
+            if s == in_object[0]:
+                in_object.append(s)
+            elif s == obj_chars[in_object[0]]:
+                in_object.pop()
+                if not in_object:
+                    end = i + 1
+                    removed_text.append(data[start:end])
+                    output += OBJ_STR.format(len(removed_text) - 1)
+    return output, removed_text
+
+
+def recover_object_text_from_list(words, json):
+    if not json:
+        return words
+    results = []
+    i = 0
+    for word in words:
+        while json:
+            if OBJ_STR.format(i) not in word:
+                results.append(word)
+                break
+            replacement = json.pop(0)
+            results.append(word.replace(OBJ_STR.format(i), replacement))
+            i += 1
+    return results
+
+
 def unbox_json(json):
     if type(json) is list and len(json) == 1:
         return json[0]
@@ -377,8 +424,10 @@ def idl_set(idl, commands, step):
     increment = False
     fetch_cmds = []
     events = []
+    commands, data = substitute_object_text(commands)
     for command in commands.split(','):
         words = command.split()
+        words = recover_object_text_from_list(words, data)
         name = words[0]
         args = words[1:]
 
@@ -437,6 +486,12 @@ def idl_set(idl, commands, step):
             s = txn.insert(idl.tables["simple"], new_uuid=args[0],
                            persist_uuid=True)
             s.i = int(args[1])
+        elif name == "add_op":
+            if len(args) != 1:
+                sys.stderr.write('"add_op" command requires 1 argument\n')
+                sys.stderr.write(f"args={args}\n")
+                sys.exit(1)
+            txn.add_op(ovs.json.from_string(args[0]))
         elif name == "delete":
             if len(args) != 1:
                 sys.stderr.write('"delete" command requires 1 argument\n')
