diff --git a/gdb/NEWS b/gdb/NEWS
index 93bc9c6a2c0..10975dbb27b 100644
--- a/gdb/NEWS
+++ b/gdb/NEWS
@@ -297,6 +297,11 @@ info main
      exact string passed by the user to these commands; the path will
      have been partially resolved to an absolute path.
 
+  ** A new executable_changed event registry is available.  This event
+     emits ExecutableChangedEvent objects, which have 'progspace' (a
+     gdb.Progspace) and 'reload' (a Boolean) attributes.  This event
+     is emitted when gdb.Progspace.executable_filename changes.
+
 *** Changes in GDB 13
 
 * MI version 1 is deprecated, and will be removed in GDB 14.
diff --git a/gdb/doc/python.texi b/gdb/doc/python.texi
index 206cf6b4e18..9f7c7cb076a 100644
--- a/gdb/doc/python.texi
+++ b/gdb/doc/python.texi
@@ -3893,6 +3893,38 @@
 The @code{gdb.TargetConnection} that is being removed.
 @end defvar
 
+@item events.executable_changed
+Emit @code{gdb.ExecutableChangedEvent} which indicates that the
+@code{gdb.Progspace.executable_filename} has changed.
+
+Changed can mean that either, the value in
+@code{gdb.Progspace.executable_filename} has changed to a new path, or
+the executable pointed to by @code{gdb.Progspace.executable_filename}
+has changed on disk, and @value{GDBN} has reloaded it.
+
+@defvar ExecutableChangedEvent.progspace
+The @code{gdb.Progspace} in which the current executable has changed.
+The path to the updated executable will be visible in
+@code{gdb.Progspace.executable_filename} (@pxref{Progspaces In Python}).
+@end defvar
+@defvar ExecutableChangedEvent.reload
+This attribute will be @code{True} if the value of
+@code{gdb.Progspace.executable_filename} didn't change, but the file
+pointed to instead changed on disk, and @value{GDBN} reloaded it.
+
+When this attribute is @code{False}, the value in
+@code{gdb.Progspace.executable_filename} was changed to point to a new
+file.
+@end defvar
+
+Remember that @value{GDBN} tracks the executable file, and the symbol
+file separately, these are visible as
+@code{gdb.Progspace.executable_filename} and
+@code{gdb.Progspace.filename} respectively.  When using the @kbd{file}
+command, @value{GDBN} updates both of these fields, but the executable
+file is updated first, so when this event is emitted, the executable
+filename will have changed, but the symbol filename might still hold
+its previous value.
 @end table
 
 @node Threads In Python
diff --git a/gdb/python/py-all-events.def b/gdb/python/py-all-events.def
index aa28f2c6f8b..04a12e1bdf0 100644
--- a/gdb/python/py-all-events.def
+++ b/gdb/python/py-all-events.def
@@ -42,3 +42,4 @@ GDB_PY_DEFINE_EVENT(breakpoint_modified)
 GDB_PY_DEFINE_EVENT(before_prompt)
 GDB_PY_DEFINE_EVENT(gdb_exiting)
 GDB_PY_DEFINE_EVENT(connection_removed)
+GDB_PY_DEFINE_EVENT(executable_changed)
diff --git a/gdb/python/py-event-types.def b/gdb/python/py-event-types.def
index 395d6c07447..b862094650d 100644
--- a/gdb/python/py-event-types.def
+++ b/gdb/python/py-event-types.def
@@ -125,3 +125,8 @@ GDB_PY_DEFINE_EVENT_TYPE (connection,
 			  "ConnectionEvent",
 			  "GDB connection added or removed object",
 			  event_object_type);
+
+GDB_PY_DEFINE_EVENT_TYPE (executable_changed,
+			  "ExecutableChangedEvent",
+			  "GDB executable changed event",
+			  event_object_type);
diff --git a/gdb/python/py-progspace.c b/gdb/python/py-progspace.c
index 1319978d34a..082509b2b5b 100644
--- a/gdb/python/py-progspace.c
+++ b/gdb/python/py-progspace.c
@@ -26,6 +26,8 @@
 #include "arch-utils.h"
 #include "solib.h"
 #include "block.h"
+#include "py-event.h"
+#include "observable.h"
 
 struct pspace_object
 {
@@ -592,9 +594,61 @@ gdbpy_is_progspace (PyObject *obj)
   return PyObject_TypeCheck (obj, &pspace_object_type);
 }
 
+/* Emit an ExecutableChangedEvent event to REGISTRY.  Return 0 on success,
+   or a negative value on error.  PSPACE is the program_space in which the
+   current executable has changed, and RELOAD_P is true if the executable
+   path stayed the same, but the file on disk changed, or false if the
+   executable path actually changed.  */
+
+static int
+emit_executable_changed_event (eventregistry_object *registry,
+			       struct program_space *pspace, bool reload_p)
+{
+  gdbpy_ref<> event_obj
+    = create_event_object (&executable_changed_event_object_type);
+  if (event_obj == nullptr)
+    return -1;
+
+  gdbpy_ref<> py_pspace = pspace_to_pspace_object (pspace);
+  if (py_pspace == nullptr
+      || evpy_add_attribute (event_obj.get (), "progspace",
+			     py_pspace.get ()) < 0)
+    return -1;
+
+  gdbpy_ref<> py_reload_p (PyBool_FromLong (reload_p ? 1 : 0));
+  if (py_reload_p == nullptr
+      || evpy_add_attribute (event_obj.get (), "reload",
+			     py_reload_p.get ()) < 0)
+    return -1;
+
+  return evpy_emit_event (event_obj.get (), registry);
+}
+
+/* Listener for the executable_changed observable, this is called when the
+   current executable within PSPACE changes.  RELOAD_P is true if the
+   executable path stayed the same but the file changed on disk.  RELOAD_P
+   is false if the executable path was changed.  */
+
+static void
+gdbpy_executable_changed (struct program_space *pspace, bool reload_p)
+{
+  if (!gdb_python_initialized)
+    return;
+
+  gdbpy_enter enter_py;
+
+  if (!evregpy_no_listeners_p (gdb_py_events.executable_changed))
+    if (emit_executable_changed_event (gdb_py_events.executable_changed,
+				       pspace, reload_p) < 0)
+      gdbpy_print_stack ();
+}
+
 static int CPYCHECKER_NEGATIVE_RESULT_SETS_EXCEPTION
 gdbpy_initialize_pspace (void)
 {
+  gdb::observers::executable_changed.attach (gdbpy_executable_changed,
+					     "py-progspace");
+
   if (PyType_Ready (&pspace_object_type) < 0)
     return -1;
 
diff --git a/gdb/testsuite/gdb.python/py-exec-file.exp b/gdb/testsuite/gdb.python/py-exec-file.exp
index 14e5088af1c..5ad3cd7e50f 100644
--- a/gdb/testsuite/gdb.python/py-exec-file.exp
+++ b/gdb/testsuite/gdb.python/py-exec-file.exp
@@ -35,11 +35,59 @@ if {[build_executable "failed to prepare second executable" \
 set binfile1 [gdb_remote_download host $binfile1]
 set binfile2 [gdb_remote_download host $binfile2]
 
+# Setup a Python function to listen for the executable changed event.
+proc setup_exec_change_handler {} {
+    gdb_py_test_silent_cmd \
+	[multi_line \
+	     "python" \
+	     "def reset_state():" \
+	     "   global exec_changed_state" \
+	     "   exec_changed_state = \[0, None, None\]" \
+	     "end" ] \
+	"build reset_state function" 0
+
+    gdb_py_test_silent_cmd \
+	[multi_line \
+	     "python" \
+	     "def executable_changed(event):" \
+	     "   global exec_changed_state" \
+	     "   exec_changed_state\[0\] += 1" \
+	     "   exec_changed_state\[1\] = event.progspace.executable_filename" \
+	     "   exec_changed_state\[2\] = event.reload" \
+	     "end" ] \
+	"build executable_changed function" 0
+
+    gdb_test_no_output -nopass "python reset_state()"
+    gdb_test_no_output "python gdb.events.executable_changed.connect(executable_changed)"
+}
+
+# Check the global Python state that is updated when the
+# executable_changed event occurs, and then reset the global state.
+# FILENAME is a string, the name of the new executable file.  RELOAD
+# is a string, which should be 'True' or 'False', and represents if
+# the executable file was reloaded, or changed.
+proc check_exec_change { filename_re reload testname } {
+    if { $filename_re ne "None" } {
+	set filename_re "'$filename_re'"
+    }
+    if { $filename_re eq "None" && $reload eq "None" } {
+	set count 0
+    } else {
+	set count 1
+    }
+    gdb_test "python print(exec_changed_state)" \
+	"\\\[$count, $filename_re, $reload\\\]" \
+	$testname
+    gdb_test_no_output -nopass "python reset_state()"
+}
+
 # Check that the executable_filename is set correctly after using the
 # 'file' command.
 with_test_prefix "using 'file' command" {
     clean_restart
 
+    setup_exec_change_handler
+
     gdb_test "python print(gdb.current_progspace().executable_filename)" \
 	"None" \
 	"check executable_filename when no file is loaded"
@@ -51,6 +99,9 @@ with_test_prefix "using 'file' command" {
 	"[string_to_regexp $binfile1]" \
 	"check executable_filename when first executable is loaded"
 
+    check_exec_change [string_to_regexp $binfile1] False \
+	"check executable_changed state after first executable was loaded"
+
     gdb_test "file $binfile2" \
 	"Reading symbols from [string_to_regexp $binfile2]\\.\\.\\..*" \
 	"load second executable" \
@@ -59,42 +110,91 @@ with_test_prefix "using 'file' command" {
 	"[string_to_regexp $binfile2]" \
 	"check executable_filename when second executable is loaded"
 
+    check_exec_change [string_to_regexp $binfile2] False \
+	"check executable_changed state after second executable was loaded"
+
     gdb_unload
     gdb_test "python print(gdb.current_progspace().executable_filename)" \
 	"None" \
 	"check executable_filename after unloading file"
+
+    check_exec_change None False \
+	"check executable_changed state after unloading the executable"
 }
 
 # Check that the executable_filename is correctly set when we only set
 # the exec-file.
 with_test_prefix "using 'exec-file' command" {
     clean_restart
+
+    setup_exec_change_handler
+
     gdb_test_no_output "exec-file $binfile1" \
 	"load first executable"
     gdb_test "python print(gdb.current_progspace().executable_filename)" \
 	"[string_to_regexp $binfile1]" \
 	"check executable_filename when first executable is loaded"
 
+    check_exec_change [string_to_regexp $binfile1] False \
+	"check executable_changed state after first executable was loaded"
+
     gdb_test_no_output "exec-file $binfile2" \
 	"load second executable"
     gdb_test "python print(gdb.current_progspace().executable_filename)" \
 	"[string_to_regexp $binfile2]" \
 	"check executable_filename when second executable is loaded"
 
+    check_exec_change [string_to_regexp $binfile2] False \
+	"check executable_changed state after second executable was loaded"
+
     gdb_test "exec-file" "No executable file now\\."
     gdb_test "python print(gdb.current_progspace().executable_filename)" \
 	"None" \
 	"check executable_filename after unloading file"
+
+    check_exec_change None False \
+	"check executable_changed state after unloading the executable"
 }
 
 # Check that setting the symbol-file doesn't cause the
 # executable_filename to be set.
 with_test_prefix "using 'symbol-file' command" {
     clean_restart
+
+    setup_exec_change_handler
+
     gdb_test "symbol-file $binfile1" \
 	"Reading symbols from [string_to_regexp $binfile1]\\.\\.\\..*" \
 	"load first executable"
     gdb_test "python print(gdb.current_progspace().executable_filename)" \
 	"None" \
 	"check executable_filename after setting symbol-file"
+
+    check_exec_change None None \
+	"check executable_changed state after setting symbol-file"
+}
+
+# Check the executable_changed event when the executable changes on disk.
+with_test_prefix "exec changes on disk" {
+    clean_restart $binfile1
+
+    setup_exec_change_handler
+
+    runto_main
+
+    gdb_test_no_output "shell sleep 1" \
+	"ensure executable is at least 1 second old"
+
+    gdb_test "shell touch ${binfile1}" "" \
+	"update the executable on disk"
+
+    runto_main
+
+    # There is currently an issue where the executable_changed event
+    # will trigger twice during an inferior restart.  This should be
+    # fixed in the next commit, at which point this kfail can be
+    # removed.
+    setup_kfail "????" *-*-*
+    check_exec_change [string_to_regexp $binfile1] True \
+	"check executable_changed state after exec changed on disk"
 }
