diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index ed87571f00b035dddce3b2a66184133d1da737d4..ad34fce7044a8855f9b11a882fdac6fb52623b0b 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -20,6 +20,7 @@ add_library(pyarb MODULE
     config.cpp
     context.cpp
     domain_decomposition.cpp
+    error.cpp
     event_generator.cpp
     identifiers.cpp
     morphology.cpp
diff --git a/python/domain_decomposition.cpp b/python/domain_decomposition.cpp
index 3b43d215c840cef56457f1781a4dc35d3f3ac067..a87710130b3f20a4fffbbe6b8ae400f564f8a3aa 100644
--- a/python/domain_decomposition.cpp
+++ b/python/domain_decomposition.cpp
@@ -10,6 +10,7 @@
 #include <arbor/load_balance.hpp>
 
 #include "context.hpp"
+#include "error.hpp"
 #include "recipe.hpp"
 #include "strprintf.hpp"
 
@@ -104,7 +105,13 @@ void register_domain_decomposition(pybind11::module& m) {
     // The Python recipe has to be shimmed for passing to the function that takes a C++ recipe.
     m.def("partition_load_balance",
         [](std::shared_ptr<py_recipe>& recipe, const context_shim& ctx, arb::partition_hint_map hint_map) {
-            return arb::partition_load_balance(py_recipe_shim(recipe), ctx.context, std::move(hint_map));
+            try {
+                return arb::partition_load_balance(py_recipe_shim(recipe), ctx.context, std::move(hint_map));
+            }
+            catch (...) {
+                py_reset_and_throw();
+                throw;
+            }
         },
         "Construct a domain_decomposition that distributes the cells in the model described by recipe\n"
         "over the distributed and local hardware resources described by context.\n"
diff --git a/python/error.cpp b/python/error.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..a4d8c48a7436693f3c218a2eaa8a50ed9560d04d
--- /dev/null
+++ b/python/error.cpp
@@ -0,0 +1,18 @@
+#include <pybind11/pybind11.h>
+
+#include "error.hpp"
+
+namespace pyarb {
+
+std::exception_ptr py_exception;
+std::mutex py_callback_mutex;
+
+void py_reset_and_throw() {
+    if (py_exception) {
+        std::exception_ptr copy = py_exception;
+        py_exception = nullptr;
+        std::rethrow_exception(copy);
+    }
+}
+
+} // namespace pyarb
diff --git a/python/error.hpp b/python/error.hpp
index f3b0cb6eda738f856e6e58f2e233139910995438..d475f2fa64eb6d81316dc80e5e576cee0e1c04e0 100644
--- a/python/error.hpp
+++ b/python/error.hpp
@@ -1,10 +1,14 @@
 #pragma once
 
+#include <mutex>
 #include <stdexcept>
 #include <string>
 
 namespace pyarb {
 
+extern std::exception_ptr py_exception;
+extern std::mutex py_callback_mutex;
+
 // Python wrapper errors
 
 struct pyarb_error: std::runtime_error {
@@ -19,4 +23,26 @@ void assert_throw(bool pred, const char* msg) {
     if (!pred) throw pyarb_error(msg);
 }
 
+// This function resets a python exception to nullptr
+// and rethrows a copy of the set python exception.
+// It should be used in serial code
+// just before handing control back to Python. 
+void py_reset_and_throw();
+
+template <typename L>
+auto try_catch_pyexception(L func, const char* msg){
+    std::lock_guard<std::mutex> g(py_callback_mutex);
+    try {
+        if(!py_exception) {
+            return func();
+        }
+        else {
+            throw pyarb_error(msg);
+        }
+    }
+    catch (pybind11::error_already_set& e) {
+        py_exception = std::current_exception();
+        throw;
+    }
+}
 } // namespace pyarb
diff --git a/python/recipe.cpp b/python/recipe.cpp
index 0f50326996c34c5f7a8c126b95aad3d54214898a..35283549326e2ea1d4e16ba02f93fb16d4d8732e 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -24,8 +24,9 @@ namespace pyarb {
 // The py::recipe::cell_decription returns a pybind11::object, that is
 // unwrapped and copied into a arb::util::unique_any.
 arb::util::unique_any py_recipe_shim::get_cell_description(arb::cell_gid_type gid) const {
-    pybind11::gil_scoped_acquire guard;
-    return convert_cell(impl_->cell_description(gid));
+    return try_catch_pyexception(
+                [&](){ return convert_cell(impl_->cell_description(gid)); },
+                "Python error already thrown");
 }
 
 arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlocation loc) {
@@ -44,7 +45,7 @@ arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlo
     return arb::probe_info{id, pkind, probe};
 };
 
-std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid_type gid) const {
+std::vector<arb::event_generator> convert_gen(std::vector<pybind11::object> pygens, arb::cell_gid_type gid) {
     using namespace std::string_literals;
     using pybind11::isinstance;
     using pybind11::cast;
@@ -52,9 +53,6 @@ std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid
     // Aquire the GIL because it must be held when calling isinstance and cast.
     pybind11::gil_scoped_acquire guard;
 
-    // Get the python list of pyarb::event_generator_shim from the python front end.
-    auto pygens = impl_->event_generators(gid);
-
     std::vector<arb::event_generator> gens;
     gens.reserve(pygens.size());
 
@@ -75,7 +73,11 @@ std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid
     return gens;
 }
 
-// TODO: implement py_recipe_shim::probe_info
+std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid_type gid) const {
+    return try_catch_pyexception(
+                [&](){ return convert_gen(impl_->event_generators(gid), gid); },
+                "Python error already thrown");
+}
 
 std::string con_to_string(const arb::cell_connection& c) {
     return util::pprintf("<arbor.connection: source ({},{}), destination ({},{}), delay {}, weight {}>",
diff --git a/python/recipe.hpp b/python/recipe.hpp
index 3e3492424701795493df5780863b8320bd38538f..ce1ef326f26d2952a64e2ffc67c9679d0b94bd2a 100644
--- a/python/recipe.hpp
+++ b/python/recipe.hpp
@@ -122,8 +122,10 @@ public:
 
     py_recipe_shim(std::shared_ptr<py_recipe> r): impl_(std::move(r)) {}
 
+    const char* msg = "Python error already thrown";
+
     arb::cell_size_type num_cells() const override {
-        return impl_->num_cells();
+        return try_catch_pyexception([&](){ return impl_->num_cells(); }, msg);
     }
 
     // The pyarb::recipe::cell_decription returns a pybind11::object, that is
@@ -131,40 +133,40 @@ public:
     arb::util::unique_any get_cell_description(arb::cell_gid_type gid) const override;
 
     arb::cell_kind get_cell_kind(arb::cell_gid_type gid) const override {
-        return impl_->cell_kind(gid);
+        return try_catch_pyexception([&](){ return impl_->cell_kind(gid); }, msg);
     }
 
     arb::cell_size_type num_sources(arb::cell_gid_type gid) const override {
-        return impl_->num_sources(gid);
+        return try_catch_pyexception([&](){ return impl_->num_sources(gid); }, msg);
     }
 
     arb::cell_size_type num_targets(arb::cell_gid_type gid) const override {
-        return impl_->num_targets(gid);
+        return try_catch_pyexception([&](){ return impl_->num_targets(gid); }, msg);
     }
 
     arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
-        return impl_->num_gap_junction_sites(gid);
+        return try_catch_pyexception([&](){ return impl_->num_gap_junction_sites(gid); }, msg);
     }
 
     std::vector<arb::event_generator> event_generators(arb::cell_gid_type gid) const override;
 
     std::vector<arb::cell_connection> connections_on(arb::cell_gid_type gid) const override {
-        return impl_->connections_on(gid);
+        return try_catch_pyexception([&](){ return impl_->connections_on(gid); }, msg);
     }
 
     std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type gid) const override {
-        return impl_->gap_junctions_on(gid);
+        return try_catch_pyexception([&](){ return impl_->gap_junctions_on(gid); }, msg);
     }
 
     arb::cell_size_type num_probes(arb::cell_gid_type gid) const override {
-        return impl_->num_probes(gid);
+        return try_catch_pyexception([&](){ return impl_->num_probes(gid); }, msg);
     }
 
     arb::probe_info get_probe(arb::cell_member_type id) const override {
-        return impl_->get_probe(id);
+        return try_catch_pyexception([&](){ return impl_->get_probe(id); }, msg);
     }
 
-    // TODO: wrap
+    // TODO: wrap and make thread safe
     arb::util::any get_global_properties(arb::cell_kind kind) const override {
         if (kind==arb::cell_kind::cable) {
             arb::cable_cell_global_properties gprop;
diff --git a/python/simulation.cpp b/python/simulation.cpp
index 60137614ae81f1c3a5bc0fb0f15a39a46aeb48bf..03b019063fe726a491392f3e686fdc1792373d66 100644
--- a/python/simulation.cpp
+++ b/python/simulation.cpp
@@ -22,7 +22,13 @@ void register_simulation(pybind11::module& m) {
         // before forwarding it to the arb::recipe constructor.
         .def(pybind11::init(
             [](std::shared_ptr<py_recipe>& rec, const arb::domain_decomposition& decomp, const context_shim& ctx) {
-                return new arb::simulation(py_recipe_shim(rec), decomp, ctx.context);
+                try {
+                    return new arb::simulation(py_recipe_shim(rec), decomp, ctx.context);
+                }
+                catch (...) {
+                    py_reset_and_throw();
+                    throw;
+                }
             }),
             // Release the python gil, so that callbacks into the python recipe don't deadlock.
             pybind11::call_guard<pybind11::gil_scoped_release>(),