diff --git a/arbor/include/arbor/simulation.hpp b/arbor/include/arbor/simulation.hpp
index 0559f40f257cd3032e67ea17996e00f98c55e01d..7e11671935f2957603452f12f828b66215a22d38 100644
--- a/arbor/include/arbor/simulation.hpp
+++ b/arbor/include/arbor/simulation.hpp
@@ -18,6 +18,7 @@
 namespace arb {
 
 using spike_export_function = std::function<void(const std::vector<spike>&)>;
+using epoch_function = std::function<void(double time, double tfinal)>;
 
 // simulation_state comprises private implementation for simulation class.
 class simulation_state;
@@ -57,6 +58,10 @@ public:
     // spike vector.
     void set_local_spike_callback(spike_export_function = spike_export_function{});
 
+    // Register a callback that will be called at the end of each epoch, and at the
+    // start of the simulation.
+    void set_epoch_callback(epoch_function = epoch_function{});
+
     // Add events directly to targets.
     // Must be called before calling simulation::run, and must contain events that
     // are to be delivered at or after the current simulation time.
@@ -68,4 +73,7 @@ private:
     std::unique_ptr<simulation_state> impl_;
 };
 
+// An epoch callback function that prints out a text progress bar.
+ARB_ARBOR_API epoch_function epoch_progress_bar();
+
 } // namespace arb
diff --git a/arbor/simulation.cpp b/arbor/simulation.cpp
index f0a6cccaa303304dbd83d567c1522a2e9ca50bdb..940de4ba2b9d8a55f664440f48138fd757eb54db 100644
--- a/arbor/simulation.cpp
+++ b/arbor/simulation.cpp
@@ -115,6 +115,7 @@ public:
 
     spike_export_function global_export_callback_;
     spike_export_function local_export_callback_;
+    epoch_function epoch_callback_;
 
 private:
     // Record last computed epoch (integration interval).
@@ -407,10 +408,13 @@ time_type simulation_state::run(time_type tfinal, time_type dt) {
     epoch current = next_epoch(prev, t_interval_);
     epoch next = next_epoch(current, t_interval_);
 
+    if (epoch_callback_) epoch_callback_(current.t0, tfinal);
+
     if (next.empty()) {
         enqueue(current);
         update(current);
         exchange(current);
+        if (epoch_callback_) epoch_callback_(current.t1, tfinal);
     }
     else {
         enqueue(current);
@@ -418,6 +422,7 @@ time_type simulation_state::run(time_type tfinal, time_type dt) {
         g.run([&]() { enqueue(next); });
         g.run([&]() { update(current); });
         g.wait();
+        if (epoch_callback_) epoch_callback_(current.t1, tfinal);
 
         for (;;) {
             prev = current;
@@ -428,6 +433,7 @@ time_type simulation_state::run(time_type tfinal, time_type dt) {
             g.run([&]() { exchange(prev); enqueue(next); });
             g.run([&]() { update(current); });
             g.wait();
+            if (epoch_callback_) epoch_callback_(current.t1, tfinal);
         }
 
         g.run([&]() { exchange(prev); });
@@ -435,6 +441,7 @@ time_type simulation_state::run(time_type tfinal, time_type dt) {
         g.wait();
 
         exchange(current);
+        if (epoch_callback_) epoch_callback_(current.t1, tfinal);
     }
 
     // Record current epoch for next run() invocation.
@@ -558,10 +565,47 @@ void simulation::set_local_spike_callback(spike_export_function export_callback)
     impl_->local_export_callback_ = std::move(export_callback);
 }
 
+void simulation::set_epoch_callback(epoch_function epoch_callback) {
+    impl_->epoch_callback_ = std::move(epoch_callback);
+}
+
 void simulation::inject_events(const cse_vector& events) {
     impl_->inject_events(events);
 }
 
 simulation::~simulation() = default;
 
+ARB_ARBOR_API epoch_function epoch_progress_bar() {
+    struct impl {
+        double t0 = 0;
+        bool first = true;
+
+        void operator() (double t, double tfinal) {
+            constexpr unsigned bar_width = 50;
+            static const std::string bar_buffer(bar_width+1, '-');
+
+            if (first) {
+                first = false;
+                t0 = t;
+            }
+
+            double percentage = (tfinal==t0)? 1: (t-t0)/(tfinal-t0);
+            int val = percentage * 100;
+            int lpad = percentage * bar_width;
+            int rpad = bar_width - lpad;
+            printf("\r%3d%% |%.*s%*s|  %12ums", val, lpad, bar_buffer.c_str(), rpad, "", (unsigned)t);
+
+            if (t==tfinal) {
+                // Print new line and reset counters on the last step.
+                printf("\n");
+                t0 = tfinal;
+                first = true;
+            }
+            fflush(stdout);
+        }
+    };
+
+    return impl{};
+}
+
 } // namespace arb
diff --git a/doc/python/simulation.rst b/doc/python/simulation.rst
index a49315038873ed1f7fab0e7c7c2c8f27b843e46d..7d8b785107d223289aac71d24360f1ae2dd6bf7b 100644
--- a/doc/python/simulation.rst
+++ b/doc/python/simulation.rst
@@ -156,6 +156,10 @@ over the local and distributed hardware resources (see :ref:`pydomdec`). Then, t
         be a NumPy array, with the first column corresponding to sample time and subsequent columns holding
         the value or values that were sampled from that probe at that time.
 
+    .. function:: progress_banner()
+
+        Print a progress bar during simulation, with elapsed miliseconds and percentage of simulation completed.
+
 **Types:**
 
 .. class:: binning
diff --git a/example/drybench/CMakeLists.txt b/example/drybench/CMakeLists.txt
index c407f461d211c3e00b02522c95381b4c472e0fc0..f9cecd75c54b60da294781b68341688bcdd04503 100644
--- a/example/drybench/CMakeLists.txt
+++ b/example/drybench/CMakeLists.txt
@@ -1,4 +1,4 @@
 add_executable(drybench EXCLUDE_FROM_ALL drybench.cpp)
 add_dependencies(examples drybench)
 
-target_link_libraries(drybench PRIVATE arbor arborenv arbor-sup ext-json)
+target_link_libraries(drybench PRIVATE arbor arborenv arbor-sup ${json_library_name})
diff --git a/example/ring/ring.cpp b/example/ring/ring.cpp
index 8e66f2ef2ea9fbb1547d17ab8965768ef42bf5a4..45a3b55be009b6e2dc7e5a66b24ab23451ac916e 100644
--- a/example/ring/ring.cpp
+++ b/example/ring/ring.cpp
@@ -44,9 +44,9 @@ struct ring_params {
     ring_params() = default;
 
     std::string name = "default";
-    unsigned num_cells = 10;
+    unsigned num_cells = 100;
     double min_delay = 10;
-    double duration = 200;
+    double duration = 1000;
     cell_parameters cell;
 };
 
@@ -187,7 +187,10 @@ int main(int argc, char** argv) {
 
         meters.checkpoint("model-init", context);
 
-        std::cout << "running simulation" << std::endl;
+        if (root) {
+            sim.set_epoch_callback(arb::epoch_progress_bar());
+        }
+        std::cout << "running simulation\n" << std::endl;
         // Run the simulation for 100 ms, with time steps of 0.025 ms.
         sim.run(params.duration, 0.025);
 
diff --git a/python/example/network_ring.py b/python/example/network_ring.py
index 6794d2381524090dbfb50bc6132559cefa7544d4..953b31b169c001b3c0a11361fd1b0bbe5cdc4843 100755
--- a/python/example/network_ring.py
+++ b/python/example/network_ring.py
@@ -120,6 +120,7 @@ sim.record(arbor.spike_recording.all)
 handles = [sim.sample((gid, 0), arbor.regular_schedule(0.1)) for gid in range(ncells)]
 
 # (15) Run simulation for 100 ms
+sim.progress_banner()
 sim.run(100)
 print('Simulation finished')
 
diff --git a/python/simulation.cpp b/python/simulation.cpp
index fecae322a987aac894fc02c2af0eb8be30caaba9..b506ff17b96f511c70d2ca77afdc3f557ef05706 100644
--- a/python/simulation.cpp
+++ b/python/simulation.cpp
@@ -169,6 +169,10 @@ public:
             return py::list{};
         }
     }
+
+    void progress_banner() {
+        sim_->set_epoch_callback(arb::epoch_progress_bar());
+    }
 };
 
 void register_simulation(pybind11::module& m, pyarb_global_ptr global_ptr) {
@@ -236,7 +240,9 @@ void register_simulation(pybind11::module& m, pyarb_global_ptr global_ptr) {
             "Remove sampling associated with the given handle.",
             "handle"_a)
         .def("remove_all_samplers", &simulation_shim::remove_sampler,
-            "Remove all sampling on the simulatr.");
+            "Remove all sampling on the simulatr.")
+        .def("progress_banner", &simulation_shim::progress_banner,
+            "Show a text progress bar during simulation.");
 
 }