diff --git a/CMakeLists.txt b/CMakeLists.txt
index ccdfe0db062bc3ce52c1fca5df02608a4fdf3191..a2d3ce9076b014027a5df4a855b1feb212bbd05b 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -186,7 +186,13 @@ install(TARGETS arbor-config-defs EXPORT arbor-targets)
 # Interface library `arbor-private-deps` collects dependencies, options etc.
 # for the arbor library.
 add_library(arbor-private-deps INTERFACE)
-target_link_libraries(arbor-private-deps INTERFACE arbor-config-defs ext-random123 ${CMAKE_DL_LIBS})
+target_link_libraries(arbor-private-deps INTERFACE arbor-config-defs ext-random123)
+if (UNIX)
+    target_compile_definitions(arbor-config-defs INTERFACE ARB_HAVE_DL)
+    target_link_libraries(arbor-private-deps INTERFACE ${CMAKE_DL_LIBS})
+else()
+    message(FATAL_ERROR "No support for dynamic loading on current platform.")
+endif ()
 install(TARGETS arbor-private-deps EXPORT arbor-targets)
 
 # Interface library `arborenv-private-deps` collects dependencies, options etc.
diff --git a/arbor/arbexcept.cpp b/arbor/arbexcept.cpp
index 185a0d5cbc923d81018f8d81943fb14eb0fd9399..fc4ffe7ec585c20e6b7141d1e705acac35d35e27 100644
--- a/arbor/arbexcept.cpp
+++ b/arbor/arbexcept.cpp
@@ -124,14 +124,16 @@ range_check_failure::range_check_failure(const std::string& whatstr, double valu
 {}
 
 file_not_found_error::file_not_found_error(const std::string &fn)
-    : arbor_exception(pprintf("Could not find file '{}'", fn)),
+    : arbor_exception(pprintf("Could not find readable file at '{}'", fn)),
       filename{fn}
 {}
 
-bad_catalogue_error::bad_catalogue_error(const std::string &fn, const std::string& call)
-    : arbor_exception(pprintf("Error in '{}' while opening catalogue '{}'", call, fn)),
-      filename{fn},
-      failed_call{call}
+bad_catalogue_error::bad_catalogue_error(const std::string& msg)
+    : arbor_exception(pprintf("Error while opening catalogue '{}'", msg))
+{}
+
+bad_catalogue_error::bad_catalogue_error(const std::string& msg, const std::any& pe)
+    : arbor_exception(pprintf("Error while opening catalogue '{}'", msg)), platform_error(pe)
 {}
 
 unsupported_abi_error::unsupported_abi_error(size_t v):
diff --git a/arbor/include/arbor/arbexcept.hpp b/arbor/include/arbor/arbexcept.hpp
index 01d4e91e21bd86c78ab2f3e7b0ae366cd8d1472a..2185c5dfbfef704fded3e0090fa655aa649a0288 100644
--- a/arbor/include/arbor/arbexcept.hpp
+++ b/arbor/include/arbor/arbexcept.hpp
@@ -146,10 +146,11 @@ struct file_not_found_error: arbor_exception {
     std::string filename;
 };
 
+//
 struct bad_catalogue_error: arbor_exception {
-    bad_catalogue_error(const std::string& fn, const std::string& call);
-    std::string filename;
-    std::string failed_call;
+    bad_catalogue_error(const std::string&);
+    bad_catalogue_error(const std::string&, const std::any&);
+    std::any platform_error;
 };
 
 // ABI errors
diff --git a/arbor/mechcat.cpp b/arbor/mechcat.cpp
index 3109f17e3611bade1a152f7f27c2548848adf1ca..829339fd39adf5ce96a543b746accb4c607b1945 100644
--- a/arbor/mechcat.cpp
+++ b/arbor/mechcat.cpp
@@ -5,8 +5,6 @@
 #include <vector>
 #include <cassert>
 
-#include <dlfcn.h>
-
 #include <arbor/version.hpp>
 #include <arbor/arbexcept.hpp>
 #include <arbor/mechcat.hpp>
@@ -17,6 +15,7 @@
 #include "util/rangeutil.hpp"
 #include "util/maputil.hpp"
 #include "util/span.hpp"
+#include "util/dl.hpp"
 
 /* Notes on implementation:
  *
@@ -584,21 +583,18 @@ std::pair<mechanism_ptr, mechanism_overrides> mechanism_catalogue::instance_impl
 
 mechanism_catalogue::~mechanism_catalogue() = default;
 
-static void check_dlerror(const std::string& fn, const std::string& call) {
-    auto error = dlerror();
-    if (error) { throw arb::bad_catalogue_error{fn, call}; }
-}
-
 const mechanism_catalogue& load_catalogue(const std::string& fn) {
     typedef const void* global_catalogue_t();
-
-    auto plugin = dlopen(fn.c_str(), RTLD_LAZY);
-    check_dlerror(fn, "dlopen");
-    assert(plugin);
-
-    auto get_catalogue = (global_catalogue_t*)dlsym(plugin, "get_catalogue");
-    check_dlerror(fn, "dlsym");
-
+    global_catalogue_t* get_catalogue = nullptr;
+    try {
+        auto plugin = dl_open(fn);
+        get_catalogue = dl_get_symbol<global_catalogue_t*>(plugin, "get_catalogue");
+    } catch(dl_error& e) {
+        throw bad_catalogue_error{e.what(), {e}};
+    }
+    if (!get_catalogue) {
+        throw bad_catalogue_error{util::pprintf("Unusable symbol 'get_catalogue' in shared object '{}'", fn)};
+    }
     /* NOTE We do not free the DSO handle here and accept retaining the handles
        until termination since the mechanisms provided by the catalogue may have
        a different lifetime than the actual catalogue itfself. This is not a
diff --git a/arbor/util/dl.hpp b/arbor/util/dl.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..8454036eb75fbb4b34294baab962535c39db407b
--- /dev/null
+++ b/arbor/util/dl.hpp
@@ -0,0 +1,5 @@
+#pragma once
+
+#ifdef ARB_HAVE_DL
+#include "util/dl_platform_posix.hpp"
+#endif
diff --git a/arbor/util/dl_platform_posix.hpp b/arbor/util/dl_platform_posix.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..e2442c15d0191c8beea7676ec46c2a8905eef84f
--- /dev/null
+++ b/arbor/util/dl_platform_posix.hpp
@@ -0,0 +1,52 @@
+#pragma once
+
+#include <fstream>
+#include <string>
+
+#include <dlfcn.h>
+
+#include <arbor/arbexcept.hpp>
+
+#include "util/strprintf.hpp"
+
+namespace arb {
+
+struct dl_error: arbor_exception {
+    dl_error(const std::string& msg): arbor_exception{msg} {}
+};
+
+struct dl_handle {
+    void* dl = nullptr;
+};
+
+inline
+dl_handle dl_open(const std::string& fn) {
+    // Test if we can open file
+    if (!std::ifstream{fn.c_str()}.good()) throw file_not_found_error{fn};
+    // Call once to clear errors not caused by us
+    dlerror();
+    // Try to open shared object
+    auto result = dlopen(fn.c_str(), RTLD_LAZY);
+    // dlopen fails by returning NULL
+    if (!result) throw dl_error{util::pprintf("[POSIX] dl_open failed with: {}", dlerror())};
+    return {result};
+}
+
+template<typename T> inline
+T dl_get_symbol(const dl_handle& handle, const std::string& symbol) {
+    // Call once to clear errors not caused by us
+    dlerror();
+    // Get symbol from shared object, may return NULL if that is what symbol refers to
+    auto result = dlsym(handle.dl, symbol.c_str());
+    // ... so we can only ask for dlerror here
+    if (auto error = dlerror()) throw dl_error{util::pprintf("[POSIX] dl_get_symbol failed with: {}", error)};
+    return reinterpret_cast<T>(result);
+}
+
+inline
+void dl_close(dl_handle& handle) {
+    dlclose(handle.dl);
+    handle.dl = nullptr;
+}
+
+}
diff --git a/python/pyarb.cpp b/python/pyarb.cpp
index 9a6782b87fccf0ae6209c2b516bd155b0e6ef591..85871de6fc0c2216758bf490c05ad0c0de705740 100644
--- a/python/pyarb.cpp
+++ b/python/pyarb.cpp
@@ -3,6 +3,7 @@
 
 #include <arbor/spike.hpp>
 #include <arbor/common_types.hpp>
+#include <arbor/arbexcept.hpp>
 #include <arbor/version.hpp>
 
 #include "pyarb.hpp"
@@ -43,6 +44,9 @@ PYBIND11_MODULE(_arbor, m) {
     m.doc() = "arbor: multicompartment neural network models.";
     m.attr("__version__") = ARB_VERSION;
 
+    // Translate Arbor errors -> Python exceptions.
+    pybind11::register_exception<arb::file_not_found_error>(m, "FileNotFoundError", PyExc_FileNotFoundError);
+
     pyarb::register_cable_loader(m);
     pyarb::register_cable_probes(m, global_ptr);
     pyarb::register_cells(m);
diff --git a/python/test/unit/test_catalogues.py b/python/test/unit/test_catalogues.py
index 4009192ba3a38bbd0c2701b96e3f59103e5eb69d..00cc2ff8afb5e28b7a4a9b56ddc4c9c4e9b5cf47 100644
--- a/python/test/unit/test_catalogues.py
+++ b/python/test/unit/test_catalogues.py
@@ -48,7 +48,7 @@ class recipe(arb.recipe):
 
 class Catalogues(unittest.TestCase):
     def test_nonexistent(self):
-        with self.assertRaises(RuntimeError):
+        with self.assertRaises(FileNotFoundError):
             arb.load_catalogue("_NO_EXIST_.so")
 
     def test_shared_catalogue(self):
diff --git a/test/unit/test_mechcat.cpp b/test/unit/test_mechcat.cpp
index 488534d00ff2e44eda2f0687b9257ce96f0d9af4..4d3d0cd31c0a1375c83fcce0d9e0cfa939f7fc02 100644
--- a/test/unit/test_mechcat.cpp
+++ b/test/unit/test_mechcat.cpp
@@ -280,7 +280,7 @@ TEST(mechcat, names) {
 
 #ifdef USE_DYNAMIC_CATALOGUES
 TEST(mechcat, loading) {
-    EXPECT_THROW(load_catalogue(LIBDIR "/does-not-exist-catalogue.so"), bad_catalogue_error);
+    EXPECT_THROW(load_catalogue(LIBDIR "/does-not-exist-catalogue.so"), file_not_found_error);
     EXPECT_THROW(load_catalogue(LIBDIR "/libarbor.a"), bad_catalogue_error);
     const mechanism_catalogue* cat = nullptr;
     EXPECT_NO_THROW(cat = &load_catalogue(LIBDIR "/dummy-catalogue.so"));