diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 9a000b3e987640b707004b02eb5d1b0027961f73..3f4611f76a3ff5ac4df500d4dc85e8f960bcfe05 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -24,7 +24,6 @@ add_library(pyarb MODULE
     pyarb.cpp
     recipe.cpp
     schedule.cpp
-    strings.cpp
 )
 
 target_link_libraries(pyarb PRIVATE arbor pybind11::module)
diff --git a/python/context.cpp b/python/context.cpp
index bab2b27813d147661440edb427f00000201350eb..19eb59efcd23ba26eaf05c2fdcee34db3be946ec 100644
--- a/python/context.cpp
+++ b/python/context.cpp
@@ -11,7 +11,7 @@
 #include "context.hpp"
 #include "conversion.hpp"
 #include "error.hpp"
-#include "strings.hpp"
+#include "strprintf.hpp"
 
 #ifdef ARB_MPI_ENABLED
 #include "mpi.hpp"
@@ -19,6 +19,18 @@
 
 namespace pyarb {
 
+std::ostream& operator<<(std::ostream& o, const context_shim& ctx) {
+    auto& c = ctx.context;
+    const bool gpu = arb::has_gpu(c);
+    const bool mpi = arb::has_mpi(c);
+    return
+        o << "<context: threads " << arb::num_threads(c)
+          << ", gpu " << (gpu? "yes": "no")
+          << ", mpi " << (mpi? "yes": "no")
+          << " ranks " << arb::num_ranks(c)
+          << ">";
+}
+
 // A Python shim that holds the information that describes an arb::proc_allocation.
 struct proc_allocation_shim {
     arb::util::optional<int> gpu_id = {};
@@ -51,23 +63,9 @@ struct proc_allocation_shim {
     }
 };
 
-// Helper template for printing C++ optional types in Python.
-// Prints either the value, or None if optional value is not set.
-template <typename T>
-std::string to_string(const arb::util::optional<T>& o) {
-    if (!o) return "None";
-
-    std::stringstream s;
-    s << *o;
-    return s.str();
-}
-
-std::string proc_alloc_string(const proc_allocation_shim& a) {
-    std::stringstream s;
-    s << "<hardware resource allocation: threads " << a.num_threads
-      << ", gpu id " << to_string(a.gpu_id);
-    s << ">";
-    return s.str();
+std::ostream& operator<<(std::ostream& o, const proc_allocation_shim& alloc) {
+    return o << "<hardware resource allocation: threads " << alloc.num_threads
+      << ", gpu id " << alloc.gpu_id << ">";
 }
 
 void register_contexts(pybind11::module& m) {
@@ -90,8 +88,8 @@ void register_contexts(pybind11::module& m) {
             "Corresponds to the integer index used to identify GPUs in CUDA API calls.")
         .def_property_readonly("has_gpu", &proc_allocation_shim::has_gpu,
             "Whether a GPU is being used (True/False).")
-        .def("__str__", &proc_alloc_string)
-        .def("__repr__", &proc_alloc_string);
+        .def("__str__",  util::to_string<proc_allocation_shim>)
+        .def("__repr__", util::to_string<proc_allocation_shim>);
 
     // context
     pybind11::class_<context_shim> context(m, "context", "An opaque handle for the hardware resources used in a simulation.");
@@ -166,8 +164,8 @@ void register_contexts(pybind11::module& m) {
             "The number of distributed domains (equivalent to the number of MPI ranks).")
         .def_property_readonly("rank", [](const context_shim& ctx){return arb::rank(ctx.context);},
             "The numeric id of the local domain (equivalent to MPI rank).")
-        .def("__str__", [](const context_shim& c){return context_string(c.context);})
-        .def("__repr__", [](const context_shim& c){return context_string(c.context);});
+        .def("__str__", util::to_string<context_shim>)
+        .def("__repr__", util::to_string<context_shim>);
 }
 
 } // namespace pyarb
diff --git a/python/conversion.hpp b/python/conversion.hpp
index ad0ae582d5fa24477863144fae2f404012fe7b85..6db53d8ec4287be78cdfc9112d108e5809dfb649 100644
--- a/python/conversion.hpp
+++ b/python/conversion.hpp
@@ -5,6 +5,7 @@
 #include <pybind11/stl.h>
 
 #include <arbor/util/optional.hpp>
+
 #include "error.hpp"
 
 // from https://pybind11.readthedocs.io/en/stable/advanced/cast/stl.html?highlight=boost%3A%3Aoptional#c-17-library-containers
diff --git a/python/identifiers.cpp b/python/identifiers.cpp
index 3aae5a61c8790d798a825c97e804e1139dc23f96..78824eff83f07e1d46bc5c614917e6d9cbda89ba 100644
--- a/python/identifiers.cpp
+++ b/python/identifiers.cpp
@@ -1,13 +1,16 @@
+#include <ostream>
 #include <string>
 
 #include <arbor/common_types.hpp>
 
 #include <pybind11/pybind11.h>
 
-#include "strings.hpp"
+#include "strprintf.hpp"
 
 namespace pyarb {
 
+using util::pprintf;
+
 void register_identifiers(pybind11::module& m) {
     using namespace pybind11::literals;
 
@@ -36,8 +39,8 @@ void register_identifiers(pybind11::module& m) {
             "The global identifier of the cell.")
         .def_readwrite("index", &arb::cell_member_type::index,
             "Cell-local index of the item.")
-        .def("__str__",  &cell_member_string)
-        .def("__repr__", &cell_member_string);
+        .def("__str__", [](arb::cell_member_type m) {return pprintf("<cell member: gid {}, index {}>", m.gid, m.index);})
+        .def("__repr__",[](arb::cell_member_type m) {return pprintf("<cell member: gid {}, index {}>", m.gid, m.index);});
 
     pybind11::enum_<arb::cell_kind>(m, "cell_kind",
         "Enumeration used to identify the cell kind, used by the model to group equal kinds in the same cell group.")
diff --git a/python/mpi.cpp b/python/mpi.cpp
index 15f1b5df9d582ed756f155b3dc0f0d8aa06a2762..6afb861a59e2ef13710fb51fce0cd1021bcb4a5e 100644
--- a/python/mpi.cpp
+++ b/python/mpi.cpp
@@ -1,9 +1,11 @@
 #include <sstream>
 #include <string>
 
+#include <pybind11/pybind11.h>
+
 #include <arbor/version.hpp>
 
-#include <pybind11/pybind11.h>
+#include "strprintf.hpp"
 
 #ifdef ARB_MPI_ENABLED
 #include <mpi.h>
@@ -84,13 +86,13 @@ int mpi_is_finalized() {
 // Define the stringifier for mpi_comm_shim here, to minimise the ifdefication
 // elsewhere in this wrapper code.
 
-std::string mpi_comm_string(const mpi_comm_shim& c) {
-    std::stringstream s;
-
-    s << "<mpi_comm: ";
-    if (c.comm==MPI_COMM_WORLD) s << "MPI_COMM_WORLD>";
-    else s << c.comm << ">";
-    return s.str();
+std::ostream& operator<<(std::ostream& o, const mpi_comm_shim& c) {
+    if (c.comm==MPI_COMM_WORLD) {
+        return o << "<mpi communicator: MPI_COMM_WORLD>";
+    }
+    else {
+        return o << "<mpi communicator: " << c.comm << ">";
+    }
 }
 
 void register_mpi(pybind11::module& m) {
@@ -100,8 +102,8 @@ void register_mpi(pybind11::module& m) {
     mpi_comm
         .def(pybind11::init<>())
         .def(pybind11::init([](pybind11::object o){return mpi_comm_shim(o);}))
-        .def("__str__", &mpi_comm_string)
-        .def("__repr__", &mpi_comm_string);
+        .def("__str__",  util::to_string<mpi_comm_shim>)
+        .def("__repr__", util::to_string<mpi_comm_shim>);
 
     m.def("mpi_init", &mpi_init, "Initialize MPI with MPI_THREAD_SINGLE, as required by Arbor.");
     m.def("mpi_finalize", &mpi_finalize, "Finalize MPI (calls MPI_Finalize)");
diff --git a/python/recipe.cpp b/python/recipe.cpp
index 8bb0980ea3818844e599101c13f679d3374b2d3a..413c00be8727676cdfc71fc1901abd6acb40d079 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -15,6 +15,7 @@
 
 #include "error.hpp"
 #include "event_generator.hpp"
+#include "strprintf.hpp"
 #include "recipe.hpp"
 
 namespace pyarb {
@@ -111,20 +112,14 @@ std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid
 
 // TODO: implement py_recipe_shim::probe_info
 
-std::string connection_string(const arb::cell_connection& c) {
-    std::stringstream s;
-    s << "<connection: (" << c.source.gid << "," << c.source.index << ")"
-      << " -> (" << c.dest.gid << "," << c.dest.index << ")"
-      << " , delay " << c.delay << ", weight " << c.weight << ">";
-    return s.str();
+std::string con_to_string(const arb::cell_connection& c) {
+    return util::pprintf("<connection: ({},{}) -> ({},{}), delay {}, weight {}>",
+         c.source.gid, c.source.index, c.dest.gid, c.dest.index, c.delay, c.weight);
 }
 
-std::string gap_junction_connection_string(const arb::gap_junction_connection& gc) {
-    std::stringstream s;
-    s << "<connection: (" << gc.local.gid << "," << gc.local.index << ")"
-    << " -> (" << gc.peer.gid << "," << gc.peer.index << ")"
-    << " , conductance " << gc.ggap << ">";
-    return s.str();
+std::string gj_to_string(const arb::gap_junction_connection& gc) {
+    return util::pprintf("<gap junction: ({},{}) <-> ({},{}), conductance {}>",
+         gc.local.gid, gc.local.index, gc.peer.gid, gc.peer.index, gc.ggap);
 }
 
 void register_recipe(pybind11::module& m) {
@@ -157,8 +152,8 @@ void register_recipe(pybind11::module& m) {
             "The weight of the connection (unit: S⋅cm⁻²).")
         .def_readwrite("delay", &arb::cell_connection::delay,
             "The delay time of the connection (unit: ms).")
-        .def("__str__", &connection_string)
-        .def("__repr__", &connection_string);
+        .def("__str__",  &con_to_string)
+        .def("__repr__", &con_to_string);
 
     // Gap Junction Connections
     pybind11::class_<arb::gap_junction_connection> gap_junction_connection(m, "gap_junction_connection",
@@ -182,8 +177,8 @@ void register_recipe(pybind11::module& m) {
             "Other half of the gap junction connection.")
         .def_readwrite("ggap", &arb::gap_junction_connection::ggap,
             "Gap junction conductance (unit: μS).")
-        .def("__str__", &gap_junction_connection_string)
-        .def("__repr__", &gap_junction_connection_string);
+        .def("__str__",  &gj_to_string)
+        .def("__repr__", &gj_to_string);
 
     // Recipes
     pybind11::class_<py_recipe,
diff --git a/python/schedule.cpp b/python/schedule.cpp
index 2b81a148df5a746366a92ce8e2494b0b5617e147..f295b85c65c1c76ebcf90197db38e9b0ec479c0e 100644
--- a/python/schedule.cpp
+++ b/python/schedule.cpp
@@ -6,9 +6,28 @@
 
 #include "conversion.hpp"
 #include "schedule.hpp"
+#include "strprintf.hpp"
 
 namespace pyarb {
 
+std::ostream& operator<<(std::ostream& o, const regular_schedule_shim& x) {
+    return o << "<regular_schedule: tstart "
+             << x.tstart << " ms, dt "
+             << x.dt << " ms, tstop "
+             << x.tstop << " ms>";
+}
+
+std::ostream& operator<<(std::ostream& o, const explicit_schedule_shim& e) {
+    o << "<explicit_schedule: times [";
+    return util::csv(o, e.times) << "] ms>";
+};
+
+std::ostream& operator<<(std::ostream& o, const poisson_schedule_shim& p) {
+    return o << "<poisson_schedule: tstart " << p.tstart << " ms"
+             << ", freq " << p.freq << " Hz"
+             << ", seed " << p.seed << ">";
+};
+
 //
 // regular_schedule shim
 //
@@ -148,8 +167,8 @@ void register_schedules(pybind11::module& m) {
             "No events delivered after this time (in ms).")
         .def_property("dt", &regular_schedule_shim::get_dt, &regular_schedule_shim::set_dt,
             "The interval between time points (in ms).")
-        ; //.def("__str__", &schedule_regular_string)
-        //.def("__repr__",&schedule_regular_string);
+        .def("__str__",  util::to_string<regular_schedule_shim>)
+        .def("__repr__", util::to_string<regular_schedule_shim>);
 
     // Explicit schedule
     pybind11::class_<explicit_schedule_shim> explicit_schedule(m, "explicit_schedule",
@@ -164,8 +183,8 @@ void register_schedules(pybind11::module& m) {
             "  times: A list of times (in ms, default []).")
         .def_property("times", &explicit_schedule_shim::get_times, &explicit_schedule_shim::set_times,
             "A list of times (in ms).")
-        ; //.def("__str__", &schedule_explicit_string)
-        //.def("__repr__",&schedule_explicit_string);
+        .def("__str__",  util::to_string<explicit_schedule_shim>)
+        .def("__repr__", util::to_string<explicit_schedule_shim>);
 
     // Poisson schedule
     pybind11::class_<poisson_schedule_shim> poisson_schedule(m, "poisson_schedule",
@@ -184,53 +203,9 @@ void register_schedules(pybind11::module& m) {
             "The expected frequency (in Hz).")
         .def_readwrite("seed", &poisson_schedule_shim::seed,
             "The seed for the random number generator.")
-        ; //.def("__str__", &schedule_poisson_string)
-        //.def("__repr__",&schedule_poisson_string);
+        .def("__str__",  util::to_string<poisson_schedule_shim>)
+        .def("__repr__", util::to_string<poisson_schedule_shim>);
 }
 
 
 }
-/*
-// Helper template for printing C++ optional types in Python.
-// Prints either the value, or None if optional value is not set.
-template <typename T>
-std::string to_string(const arb::util::optional<T>& o, std::string unit) {
-    if (!o) return "None";
-
-    std::stringstream s;
-    s << *o << " " << unit;
-    return s.str();
-}
-
-std::string schedule_regular_string(const regular_schedule_shim& r) {
-    std::stringstream s;
-    s << "<regular_schedule: "
-      << "tstart " << to_string(r.tstart, "ms") << ", "
-      << "dt " << r.dt << " ms, "
-      << "tstop " << to_string(r.tstop, "ms") << ">";
-    return s.str();
-};
-
-std::string schedule_explicit_string(const explicit_schedule_shim& e) {
-    std::stringstream s;
-    s << "<explicit_schedule: times [";
-    bool first = true;
-    for (auto t: e.times) {
-        if (!first) {
-            s << " ";
-        }
-        s << t;
-        first = false;
-    }
-    s << "] ms>";
-    return s.str();
-};
-
-std::string schedule_poisson_string(const poisson_schedule_shim& p) {
-    std::stringstream s;
-    s << "<poisson_schedule: tstart " << p.tstart << " ms"
-      << ", freq " << p.freq << " Hz"
-      << ", seed " << p.seed << ">";
-    return s.str();
-};
-*/
diff --git a/python/strings.cpp b/python/strings.cpp
deleted file mode 100644
index a2eb0e4ecdc03ded63aab4705fb243edb0534f7a..0000000000000000000000000000000000000000
--- a/python/strings.cpp
+++ /dev/null
@@ -1,30 +0,0 @@
-#include <sstream>
-#include <string>
-
-#include <arbor/common_types.hpp>
-#include <arbor/context.hpp>
-
-#include "strings.hpp"
-
-namespace pyarb {
-
-std::string cell_member_string(const arb::cell_member_type& m) {
-    std::stringstream s;
-    s << "<cell_member: gid " << m.gid
-      << ", index " << m.index << ">";
-    return s.str();
-}
-
-std::string context_string(const arb::context& c) {
-    std::stringstream s;
-    const bool gpu = arb::has_gpu(c);
-    const bool mpi = arb::has_mpi(c);
-    s << "<context: threads " << arb::num_threads(c)
-      << ", gpu " << (gpu? "yes": "None")
-      << ", distributed " << (mpi? "MPI": "local")
-      << " ranks " << arb::num_ranks(c)
-      << ">";
-    return s.str();
-}
-
-} // namespace pyarb
diff --git a/python/strings.hpp b/python/strings.hpp
deleted file mode 100644
index 0e378af476c17269adc5f5f7574c77c2492a72a9..0000000000000000000000000000000000000000
--- a/python/strings.hpp
+++ /dev/null
@@ -1,14 +0,0 @@
-#pragma once
-
-#include <string>
-
-#include <arbor/common_types.hpp>
-#include <arbor/context.hpp>
-
-// Utilities for generating string representations of types.
-namespace pyarb {
-
-std::string cell_member_string(const arb::cell_member_type&);
-std::string context_string(const arb::context&);
-
-} // namespace pyarb
diff --git a/python/strprintf.hpp b/python/strprintf.hpp
new file mode 100644
index 0000000000000000000000000000000000000000..431f83d2de6ae0010ebe0a85693382589022cfdd
--- /dev/null
+++ b/python/strprintf.hpp
@@ -0,0 +1,190 @@
+#pragma once
+
+// printf-like routines that return std::string.
+
+#include <cstdio>
+#include <memory>
+#include <string>
+#include <sstream>
+#include <system_error>
+#include <utility>
+#include <vector>
+
+#include <arbor/util/optional.hpp>
+
+namespace pyarb {
+namespace util {
+
+// Use ADL to_string or std::to_string, falling back to ostream formatting:
+
+namespace impl_to_string {
+    using std::to_string;
+
+    template <typename T, typename = void>
+    struct select {
+        static std::string str(const T& value) {
+            std::ostringstream o;
+            o << value;
+            return o.str();
+        }
+    };
+
+    // Can be eplaced with std::void_t in c++17.
+    template <typename ...Args>
+    using void_t = void;
+
+    template <typename T>
+    struct select<T, void_t<decltype(to_string(std::declval<T>()))>> {
+        static std::string str(const T& v) {
+            return to_string(v);
+        }
+    };
+}
+
+template <typename T>
+std::string to_string(const T& value) {
+    return impl_to_string::select<T>::str(value);
+}
+
+// Use snprintf to format a string, with special handling for standard
+// smart pointer types and strings.
+
+namespace impl {
+    template <typename X>
+    X sprintf_arg_translate(const X& x) { return x; }
+
+    inline const char* sprintf_arg_translate(const std::string& x) { return x.c_str(); }
+
+    template <typename T, typename Deleter>
+    T* sprintf_arg_translate(const std::unique_ptr<T, Deleter>& x) { return x.get(); }
+
+    template <typename T>
+    T* sprintf_arg_translate(const std::shared_ptr<T>& x) { return x.get(); }
+}
+
+template <typename... Args>
+std::string strprintf(const char* fmt, Args&&... args) {
+    thread_local static std::vector<char> buffer(1024);
+
+    for (;;) {
+        int n = std::snprintf(buffer.data(), buffer.size(), fmt, impl::sprintf_arg_translate(std::forward<Args>(args))...);
+        if (n<0) {
+            throw std::system_error(errno, std::generic_category());
+        }
+        else if ((unsigned)n<buffer.size()) {
+            return std::string(buffer.data(), n);
+        }
+        buffer.resize(2*n);
+    }
+}
+
+template <typename... Args>
+std::string strprintf(const std::string& fmt, Args&&... args) {
+    return strprintf(fmt.c_str(), std::forward<Args>(args)...);
+}
+
+// Substitute instances of '{}' in the format string with the following parameters,
+// using default std::ostream formatting.
+
+namespace impl {
+    inline void pprintf_(std::ostringstream& o, const char* s) {
+        o << s;
+    }
+
+    template <typename T, typename... Tail>
+    void pprintf_(std::ostringstream& o, const char* s, T&& value, Tail&&... tail) {
+        const char* t = s;
+        while (*t && !(t[0]=='{' && t[1]=='}')) {
+            ++t;
+        }
+        o.write(s, t-s);
+        if (*t) {
+            o << std::forward<T>(value);
+            pprintf_(o, t+2, std::forward<Tail>(tail)...);
+        }
+    }
+}
+
+template <typename... Args>
+std::string pprintf(const char *s, Args&&... args) {
+    std::ostringstream o;
+    impl::pprintf_(o, s, std::forward<Args>(args)...);
+    return o.str();
+}
+
+namespace impl {
+
+    template <typename Seq>
+    struct sepval {
+        const Seq& seq_;
+        const char* sep_;
+
+        sepval(const Seq& seq, const char* sep): seq_(seq), sep_(sep) {}
+
+        friend std::ostream& operator<<(std::ostream& o, const sepval& s) {
+            bool first = true;
+            for (auto& x: s.seq_) {
+                if (!first) o << s.sep_;
+                first = false;
+                o << x;
+            }
+            return o;
+        }
+    };
+
+    template <typename Seq>
+    struct sepval_lim {
+        const Seq& seq_;
+        const char* sep_;
+        unsigned count_;
+
+        sepval_lim(const Seq& seq, const char* sep, unsigned count): seq_(seq), sep_(sep), count_(count) {}
+
+        friend std::ostream& operator<<(std::ostream& o, const sepval_lim& s) {
+            bool first = true;
+            unsigned n = s.count_;
+            for (auto& x: s.seq_) {
+                if (!first) {
+                    o << s.sep_;
+                    first = false;
+                }
+                if (!n) {
+                    return o << "...";
+                }
+                --n;
+                o << x;
+            }
+            return o;
+        }
+    };
+}
+
+template <typename Seq>
+std::ostream& sepval(std::ostream& o, const char* sep, const Seq& seq) {
+    return o << impl::sepval<Seq>(seq, sep);
+}
+
+template <typename Seq>
+std::ostream& sepval(std::ostream& o, const char* sep, const Seq& seq, unsigned n) {
+    return o << impl::sepval_lim<Seq>(seq, sep, n);
+}
+
+template <typename Seq>
+std::ostream& csv(std::ostream& o, const Seq& seq) {
+    return o << impl::sepval<Seq>(seq, ", ");
+}
+
+template <typename Seq>
+std::ostream& csv(std::ostream& o, const Seq& seq, unsigned n) {
+    return o << impl::sepval_lim<Seq>(seq, ", ", n);
+}
+
+} // namespace util
+
+template <typename T>
+std::ostream& operator<<(std::ostream& o, const arb::util::optional<T>& x) {
+    return o << (x? util::to_string(*x): "None");
+}
+
+} // namespace pyarb
+
diff --git a/python/test/unit_distributed/test_contexts_arbmpi.py b/python/test/unit_distributed/test_contexts_arbmpi.py
index 947eb45858100c06caec7b68048a638421e8df33..c999c343cba880b7a2c2f7bf0628e3e31df3aa7c 100644
--- a/python/test/unit_distributed/test_contexts_arbmpi.py
+++ b/python/test/unit_distributed/test_contexts_arbmpi.py
@@ -44,7 +44,7 @@ class Contexts_arbmpi(unittest.TestCase):
         comm = arb.mpi_comm()
 
         # test that by default communicator is MPI_COMM_WORLD
-        self.assertEqual(str(comm), '<mpi_comm: MPI_COMM_WORLD>')
+        self.assertEqual(str(comm), '<mpi communicator: MPI_COMM_WORLD>')
 
     def test_context_arbmpi(self):
         comm = arb.mpi_comm()
diff --git a/python/test/unit_distributed/test_contexts_mpi4py.py b/python/test/unit_distributed/test_contexts_mpi4py.py
index c93171751ae3f7b310838b72bbbc6bed2bb8f9bd..2a58c8bcc61e477a137168e2c063766cb445cb5b 100644
--- a/python/test/unit_distributed/test_contexts_mpi4py.py
+++ b/python/test/unit_distributed/test_contexts_mpi4py.py
@@ -37,7 +37,7 @@ class Contexts_mpi4py(unittest.TestCase):
         comm = arb.mpi_comm(mpi.COMM_WORLD)
 
         # test that set communicator is MPI_COMM_WORLD
-        self.assertEqual(str(comm), '<mpi_comm: MPI_COMM_WORLD>')
+        self.assertEqual(str(comm), '<mpi communicator: MPI_COMM_WORLD>')
 
     def test_context_mpi4py(self):
         comm = arb.mpi_comm(mpi.COMM_WORLD)