diff --git a/arbor/include/arbor/morph/primitives.hpp b/arbor/include/arbor/morph/primitives.hpp
index ae58c511f7eb2cb0afd076b01a3c5f5c7e1eb92a..52f9ea295b4d754682ca18b0d6aa6311e0ee4297 100644
--- a/arbor/include/arbor/morph/primitives.hpp
+++ b/arbor/include/arbor/morph/primitives.hpp
@@ -55,11 +55,11 @@ struct mlocation {
     // The relative position on the branch ∈ [0,1].
     double pos;
 
-    // branch ≠ npos and 0 ≤ pos ≤ 1
-    friend bool test_invariants(const mlocation&);
     friend std::ostream& operator<<(std::ostream&, const mlocation&);
 };
 
+// branch ≠ npos and 0 ≤ pos ≤ 1
+bool test_invariants(const mlocation&);
 ARB_DEFINE_LEXICOGRAPHIC_ORDERING(mlocation, (a.branch,a.pos), (b.branch,b.pos));
 
 using mlocation_list = std::vector<mlocation>;
diff --git a/doc/py_common.rst b/doc/py_common.rst
index 5d282680e4accdb95304fdaba06712894efd2ffb..11cf1cb5882705581dd1aa255e160a4b7c178fa3 100644
--- a/doc/py_common.rst
+++ b/doc/py_common.rst
@@ -78,3 +78,19 @@ The types defined below are used as identifiers for cells and members of cell-lo
             import arbor
 
             kind = arbor.cell_kind.cable
+
+Morphology
+----------
+
+.. class:: location
+
+    Construct a location specification with the :attr:`branch` id and the relative :attr:`position` on the branch ∈ [0.,1.], where 0. means proximal and 1. distal.
+
+    .. attribute:: branch
+
+        The id of the branch.
+
+    .. attribute:: position
+
+        The relative position (from 0., proximal, to 1., distal) on the branch.
+
diff --git a/doc/py_recipe.rst b/doc/py_recipe.rst
index ff1ae8584e7be2e9fd19d175e57ba0c4a4201bb6..2d7f63cfe2c287180b79db99b3ca7d77a6100747 100644
--- a/doc/py_recipe.rst
+++ b/doc/py_recipe.rst
@@ -81,12 +81,45 @@ Details on why Arbor uses recipes and general best practices can be found in :re
 
         By default returns 0.
 
+    .. function:: num_probes(gid)
+
+        The number of probes attached to the cell with :attr:`arbor.cell_member.gid`.
+
+        By default returns 0.
+
     .. function:: num_gap_junction_sites(gid)
 
         Returns the number of gap junction sites on :attr:`arbor.cell_member.gid`.
 
         By default returns 0.
 
+    .. function:: get_probe(id)
+
+        Returns the probe(s) to allow monitoring.
+
+        By default throws a runtime error. If :func:`num_probes`
+        returns a non-zero value, this must also be overridden.
+
+.. class:: probe
+
+        Describes the cell probe's information.
+
+.. function:: cable_probe(kind, id, location)
+
+        Returns the description of a probe at an :class:`arbor.location` on a cable cell with :attr:`id` available for monitoring data of ``voltage`` or ``current`` :attr:`kind`.
+
+        An example of a probe on a cable cell for measuring voltage at the soma reads as follows:
+
+    .. container:: example-code
+
+        .. code-block:: python
+
+            import arbor
+
+            id    = arbor.cell_member(0, 0) # cell 0, probe 0
+            loc   = arbor.location(0, 0)    # at the soma
+            probe = arbor.cable_probe('voltage', id, loc)
+
 .. class:: connection
 
     Describes a connection between two cells:
@@ -388,3 +421,11 @@ helpers in cell_parameters and make_cable_cell for building cells are used.
                     sched = arbor.explicit_schedule([1])
                     return [arbor.event_generator(arbor.cell_member(0,0), 0.1, sched)]
                 return []
+
+            # Define one probe (for measuring voltage at the soma) on the cell.
+            def num_probes(self, gid):
+                return 1
+
+            def get_probe(self, id):
+                loc = arbor.location(0, 0) # at the soma
+                return arbor.cable_probe('voltage', id, loc)
diff --git a/doc/py_simulation.rst b/doc/py_simulation.rst
index 23b711b2f3d719650299ff0dfcb2c178c052e2fd..d2a6c5d2e12d8cef9d86dc4feca822ef1ec3bf92 100644
--- a/doc/py_simulation.rst
+++ b/doc/py_simulation.rst
@@ -175,3 +175,103 @@ In order to analyze the simulation output spikes can be recorded.
 >>> <arbor.spike: source (7,0), time 89.1529 ms>
 >>> <arbor.spike: source (8,0), time 101.641 ms>
 >>> <arbor.spike: source (9,0), time 114.125 ms>
+
+Recording samples
+-----------------
+
+Definitions
+***********
+
+probe
+    A location or component of a cell that is available for monitoring (see :attr:`arbor.recipe.num_probes`, :attr:`arbor.recipe.get_probe` and :attr:`arbor.cable_probe` as references).
+
+sample/record
+    A record of data corresponding to the value at a specific *probe* at a specific time.
+
+sampler/sample recorder
+    A function that receives a sequence of *sample* records.
+
+Samples and sample recorders
+****************************
+
+In order to analyze the data collected from an :class:`arbor.probe` the samples can be recorded.
+
+**Types**:
+
+.. class:: sample
+
+    .. attribute:: time
+
+        The sample time [ms] at a specific probe.
+
+    .. attribute:: value
+
+        The sample record at a specific probe.
+
+.. class:: sampler
+
+    .. function:: sampler()
+
+        Initialize the sample recorder.
+
+    .. function:: samples(probe_id)
+
+        A list of the recorded samples of a probe with probe id.
+
+**Sampling interface**:
+
+.. function:: attach_sampler(sim, dt)
+
+    Attach a sample recorder to an arbor simulation.
+    The recorder will record all samples from a regular sampling interval [ms] (see :class:`arbor.regular_schedule`) matching all probe ids.
+
+.. function:: attach_sampler(sim, dt, probe_id)
+
+    Attach a sample recorder to an arbor simulation.
+    The recorder will record all samples from a regular sampling interval [ms] (see :class:`arbor.regular_schedule`) matching one probe id.
+
+.. container:: example-code
+
+    .. code-block:: python
+
+        import arbor
+
+        # Instatitate the simulation.
+        sim = arbor.simulation(recipe, decomp, context)
+
+        # Build the sample recorder on cell 0 and probe 0 with regular sampling interval of 0.1 ms
+        pid = arbor.cell_member(0,0) # cell 0, probe 0
+        sampler = arbor.attach_sampler(sim, 0.1, pid)
+
+        # Run the simulation for 100 ms
+        sim.run(100)
+
+        # Print the sample times and values
+        for sa in sampler.samples(pid):
+            print(sa)
+
+>>> <arbor.sample: time 0 ms,       value -65>
+>>> <arbor.sample: time 0.1 ms,     value -64.9981>
+>>> <arbor.sample: time 0.2 ms,     value -64.9967>
+>>> <arbor.sample: time 0.3 ms,     value -64.9956>
+>>> <arbor.sample: time 0.4 ms,     value -64.9947>
+>>> <arbor.sample: time 0.475 ms,   value -64.9941>
+>>> <arbor.sample: time 0.6 ms,     value -64.9932>
+>>> <arbor.sample: time 0.675 ms,   value -64.9927>
+>>> <arbor.sample: time 0.8 ms,     value -64.992>
+>>> <arbor.sample: time 0.9 ms,     value -64.9916>
+>>> <arbor.sample: time 1 ms,       value -64.9912>
+>>> <arbor.sample: time 1.1 ms,     value -62.936>
+>>> <arbor.sample: time 1.2 ms,     value -59.2284>
+>>> <arbor.sample: time 1.3 ms,     value -55.8485>
+>>> <arbor.sample: time 1.375 ms,   value -53.663>
+>>> <arbor.sample: time 1.475 ms,   value -51.0649>
+>>> <arbor.sample: time 1.6 ms,     value -47.9543>
+>>> <arbor.sample: time 1.7 ms,     value -45.1928>
+>>> <arbor.sample: time 1.8 ms,     value -41.7243>
+>>> <arbor.sample: time 1.875 ms,   value -38.2573>
+>>> <arbor.sample: time 1.975 ms,   value -31.576>
+>>> <arbor.sample: time 2.1 ms,     value -17.2756>
+>>> <arbor.sample: time 2.2 ms,     value 0.651031>
+>>> <arbor.sample: time 2.275 ms,   value 15.0592>
+
diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index 063c514686850990201c865c9854ac32eecb5733..ed87571f00b035dddce3b2a66184133d1da737d4 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -22,10 +22,12 @@ add_library(pyarb MODULE
     domain_decomposition.cpp
     event_generator.cpp
     identifiers.cpp
+    morphology.cpp
     mpi.cpp
     profiler.cpp
     pyarb.cpp
     recipe.cpp
+    sampling.cpp
     schedule.cpp
     simulation.cpp
     spikes.cpp
diff --git a/python/example/ring.py b/python/example/ring.py
index d4710218b2e21252ecdaef8293b7db891a1f3a79..aec4f5cbaccd6eb405e569599f1b30a2f9dd326b 100644
--- a/python/example/ring.py
+++ b/python/example/ring.py
@@ -1,5 +1,6 @@
 import sys
 import arbor
+import matplotlib.pyplot as plt
 
 class ring_recipe (arbor.recipe):
 
@@ -44,6 +45,13 @@ class ring_recipe (arbor.recipe):
             return [arbor.event_generator(arbor.cell_member(0,0), 0.1, sched)]
         return []
 
+    # Define one probe (for measuring voltage at the soma) on each cell.
+    def num_probes(self, gid):
+        return 1
+
+    def get_probe(self, id):
+        loc = arbor.location(0, 0) # at the soma
+        return arbor.cable_probe('voltage', id, loc)
 
 context = arbor.context(threads=4, gpu_id=None)
 print(context)
@@ -51,7 +59,7 @@ print(context)
 meters = arbor.meter_manager()
 meters.start(context)
 
-recipe = ring_recipe(100)
+recipe = ring_recipe(10)
 print(f'{recipe}')
 
 meters.checkpoint('recipe-create', context)
@@ -71,17 +79,42 @@ print(f'{decomp}')
 meters.checkpoint('load-balance', context)
 
 sim = arbor.simulation(recipe, decomp, context)
-print(f'{sim} finished')
 
 meters.checkpoint('simulation-init', context)
 
-recorder = arbor.attach_spike_recorder(sim)
+spike_recorder = arbor.attach_spike_recorder(sim)
 
-sim.run(1000)
+pid = arbor.cell_member(0,0) # cell 0, probe 0
+# Attach a sampler to the voltage probe on cell 0.
+# Sample rate of 1 sample every ms.
+sampler = arbor.attach_sampler(sim, 1, pid)
+
+sim.run(100)
+print(f'{sim} finished')
 
 meters.checkpoint('simulation-run', context)
 
 print(f'{arbor.meter_report(meters, context)}')
 
-for s in recorder.spikes:
-    print(s)
+for sp in spike_recorder.spikes:
+    print(sp)
+
+print('voltage samples for probe id ', end = '')
+print(pid, end = '')
+print(':')
+
+time = []
+value = []
+for sa in sampler.samples(pid):
+    print(sa)
+    time.append(sa.time)
+    value.append(sa.value)
+
+# plot the recorded voltages over time
+fig, ax = plt.subplots()
+ax.plot(time, value)
+ax.set(xlabel='time (ms)', ylabel='voltage (mV)', title='ring demo')
+ax.legend(['voltage'])
+plt.xlim(0,100)
+ax.grid()
+fig.savefig("voltages.png", dpi=300)
diff --git a/python/morphology.cpp b/python/morphology.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..6b995db640c0bdd79d71bc519cb1993c0fc1bb0a
--- /dev/null
+++ b/python/morphology.cpp
@@ -0,0 +1,33 @@
+#include <pybind11/pybind11.h>
+
+#include <arbor/morph/primitives.hpp>
+
+#include "error.hpp"
+#include "strprintf.hpp"
+
+namespace pyarb {
+
+void register_morphology(pybind11::module& m) {
+    using namespace pybind11::literals;
+
+    // segment location
+    pybind11::class_<arb::mlocation> location(m, "location",
+        "A location on a cable cell.");
+    location
+        .def(pybind11::init(
+            [](arb::msize_t branch, double pos) {
+                const arb::mlocation mloc{branch, pos};
+                pyarb::assert_throw(arb::test_invariants(mloc), "invalid location");
+                return mloc;
+            }),
+            "branch"_a, "position"_a,
+            "Construct a location specification holding:\n"
+            "  branch:   The id of the branch.\n"
+            "  position: The relative position (from 0., proximal, to 1., distal) on the branch.\n")
+        .def_readonly("branch",  &arb::mlocation::branch,  "The id of the branch.")
+        .def_readonly("position", &arb::mlocation::pos, "The relative position on the branch (∈ [0.,1.], where 0. means proximal and 1. distal).")
+        .def("__str__", [](arb::mlocation l) {return util::pprintf("<arbor.location: branch {}, position {}>", l.branch, l.pos);})
+        .def("__repr__", [](arb::mlocation l) {return util::pprintf("<arbor.location: branch {}, position {}>", l.branch, l.pos);});
+
+}
+} // namespace pyarb
diff --git a/python/pyarb.cpp b/python/pyarb.cpp
index 7f5bdd71529c7f51243494b3619108f63c4002a8..e17cbdfd919c6c68e948d6a4ad5b4c1cbc191703 100644
--- a/python/pyarb.cpp
+++ b/python/pyarb.cpp
@@ -12,8 +12,10 @@ void register_contexts(pybind11::module& m);
 void register_domain_decomposition(pybind11::module& m);
 void register_event_generators(pybind11::module& m);
 void register_identifiers(pybind11::module& m);
+void register_morphology(pybind11::module& m);
 void register_profiler(pybind11::module& m);
 void register_recipe(pybind11::module& m);
+void register_sampling(pybind11::module& m);
 void register_schedules(pybind11::module& m);
 void register_simulation(pybind11::module& m);
 void register_spike_handling(pybind11::module& m);
@@ -33,8 +35,10 @@ PYBIND11_MODULE(arbor, m) {
     pyarb::register_domain_decomposition(m);
     pyarb::register_event_generators(m);
     pyarb::register_identifiers(m);
+    pyarb::register_morphology(m);
     pyarb::register_profiler(m);
     pyarb::register_recipe(m);
+    pyarb::register_sampling(m);
     pyarb::register_schedules(m);
     pyarb::register_simulation(m);
     pyarb::register_spike_handling(m);
diff --git a/python/recipe.cpp b/python/recipe.cpp
index af787726d017af98ee8a611acf5e683618d49b3a..0f50326996c34c5f7a8c126b95aad3d54214898a 100644
--- a/python/recipe.cpp
+++ b/python/recipe.cpp
@@ -8,10 +8,12 @@
 
 #include <arbor/cable_cell.hpp>
 #include <arbor/event_generator.hpp>
+#include <arbor/morph/primitives.hpp>
 #include <arbor/recipe.hpp>
 #include <arbor/spike_source_cell.hpp>
 
 #include "cells.hpp"
+#include "conversion.hpp"
 #include "error.hpp"
 #include "event_generator.hpp"
 #include "strprintf.hpp"
@@ -26,6 +28,22 @@ arb::util::unique_any py_recipe_shim::get_cell_description(arb::cell_gid_type gi
     return convert_cell(impl_->cell_description(gid));
 }
 
+arb::probe_info cable_probe(std::string kind, arb::cell_member_type id, arb::mlocation loc) {
+    arb::cell_probe_address::probe_kind pkind;
+    if (kind == "voltage") {
+        pkind = arb::cell_probe_address::probe_kind::membrane_voltage;
+    }
+    else if (kind == "current") {
+        pkind = arb::cell_probe_address::probe_kind::membrane_current;
+    }
+    else throw pyarb_error(
+                util::pprintf(
+                    "invalid probe kind: {}, neither voltage nor current", kind));
+
+    arb::cell_probe_address probe{loc, pkind};
+    return arb::probe_info{id, pkind, probe};
+};
+
 std::vector<arb::event_generator> py_recipe_shim::event_generators(arb::cell_gid_type gid) const {
     using namespace std::string_literals;
     using pybind11::isinstance;
@@ -135,7 +153,6 @@ void register_recipe(pybind11::module& m) {
         .def("num_targets", &py_recipe::num_targets,
             "gid"_a,
             "The number of post-synaptic sites on gid, 0 by default.")
-        // TODO: py_recipe::num_probes
         .def("num_gap_junction_sites", &py_recipe::num_gap_junction_sites,
             "gid"_a,
             "The number of gap junction sites on gid, 0 by default.")
@@ -148,9 +165,25 @@ void register_recipe(pybind11::module& m) {
         .def("gap_junctions_on", &py_recipe::gap_junctions_on,
             "gid"_a,
             "A list of the gap junctions connected to gid, [] by default.")
-        // TODO: py_recipe::get_probe
+        .def("num_probes", &py_recipe::num_probes,
+            "gid"_a,
+            "The number of probes on gid, 0 by default.")
+        .def("get_probe", &py_recipe::get_probe,
+            "id"_a,
+            "The probe(s) to allow monitoring, must be provided if num_probes() returns a non-zero value.")
         // TODO: py_recipe::global_properties
         .def("__str__",  [](const py_recipe&){return "<arbor.recipe>";})
         .def("__repr__", [](const py_recipe&){return "<arbor.recipe>";});
+
+    // Probes
+    m.def("cable_probe", &cable_probe,
+        "Description of a probe at a location on a cable cell with id available for monitoring data of kind "\
+        "where kind is one of 'voltage' or 'current'.",
+        "kind"_a, "id"_a, "location"_a);
+
+    pybind11::class_<arb::probe_info> probe(m, "probe");
+    probe
+        .def("__repr__", [](const arb::probe_info& p){return util::pprintf("<arbor.probe: cell {}, probe {}>", p.id.gid, p.id.index);})
+        .def("__str__",  [](const arb::probe_info& p){return util::pprintf("<arbor.probe: cell {}, probe {}>", p.id.gid, p.id.index);});
 }
 } // namespace pyarb
diff --git a/python/recipe.hpp b/python/recipe.hpp
index 3ee71adbb2004e4c374436643869cd5f97581416..3e3492424701795493df5780863b8320bd38538f 100644
--- a/python/recipe.hpp
+++ b/python/recipe.hpp
@@ -10,6 +10,9 @@
 #include <arbor/cable_cell_param.hpp>
 #include <arbor/recipe.hpp>
 
+#include "error.hpp"
+#include "strprintf.hpp"
+
 namespace pyarb {
 
 // pyarb::recipe is the recipe interface used by Python.
@@ -48,9 +51,12 @@ public:
     virtual std::vector<arb::gap_junction_connection> gap_junctions_on(arb::cell_gid_type) const {
         return {};
     }
-
-    //TODO: virtual arb::cell_size_type num_probes(arb::cell_gid_type) const { return 0; }
-    //TODO: virtual pybind11::object get_probe (arb::cell_member_type id) const {...}
+    virtual arb::cell_size_type num_probes(arb::cell_gid_type) const {
+        return 0;
+    }
+    virtual arb::probe_info get_probe (arb::cell_member_type id) const {
+        throw pyarb_error(util::pprintf("bad probe id {}", id));
+    }
     //TODO: virtual pybind11::object global_properties(arb::cell_kind kind) const {return pybind11::none();};
 };
 
@@ -92,8 +98,13 @@ public:
         PYBIND11_OVERLOAD(std::vector<arb::gap_junction_connection>, py_recipe, gap_junctions_on, gid);
     }
 
-    //TODO: arb::cell_size_type num_probes(arb::cell_gid_type)
-    //TODO: pybind11::object get_probe(arb::cell_member_type id)
+    arb::cell_size_type num_probes(arb::cell_gid_type gid) const override {
+        PYBIND11_OVERLOAD(arb::cell_size_type, py_recipe, num_probes, gid);
+    }
+
+    arb::probe_info get_probe(arb::cell_member_type id) const override {
+        PYBIND11_OVERLOAD(arb::probe_info, py_recipe, get_probe, id);
+    }
 };
 
 // A recipe shim that holds a pyarb::recipe implementation.
@@ -131,8 +142,6 @@ public:
         return impl_->num_targets(gid);
     }
 
-    //TODO: arb::cell_size_type num_probes(arb::cell_gid_type gid)
-
     arb::cell_size_type num_gap_junction_sites(arb::cell_gid_type gid) const override {
         return impl_->num_gap_junction_sites(gid);
     }
@@ -147,7 +156,13 @@ public:
         return impl_->gap_junctions_on(gid);
     }
 
-    //TODO: arb::probe_info get_probe(arb::cell_member_type id)
+    arb::cell_size_type num_probes(arb::cell_gid_type gid) const override {
+        return impl_->num_probes(gid);
+    }
+
+    arb::probe_info get_probe(arb::cell_member_type id) const override {
+        return impl_->get_probe(id);
+    }
 
     // TODO: wrap
     arb::util::any get_global_properties(arb::cell_kind kind) const override {
diff --git a/python/sampling.cpp b/python/sampling.cpp
new file mode 100644
index 0000000000000000000000000000000000000000..e281c2c2f5e534f245f69250c488ea25c5234677
--- /dev/null
+++ b/python/sampling.cpp
@@ -0,0 +1,157 @@
+#include <mutex>
+
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+#include <arbor/common_types.hpp>
+#include <arbor/sampling.hpp>
+#include <arbor/simulation.hpp>
+
+#include "error.hpp"
+#include "strprintf.hpp"
+
+namespace pyarb {
+
+// TODO: trace entry of different types/container (e.g. vector of doubles to get all samples of a cell)
+
+struct trace_entry {
+    arb::time_type t;
+    double v;
+};
+
+// A helper struct (state) ensuring that only one thread can write to the probe_buffers holding the trace entries (mapped by probe id)
+struct sampler_state {
+    std::mutex mutex;
+    std::unordered_map<arb::cell_member_type, std::vector<trace_entry>> probe_buffers;
+
+    std::vector<trace_entry>& probe_buffer(arb::cell_member_type pid) {
+        // lock the mutex, s.t. other threads cannot write
+        std::lock_guard<std::mutex> lock(mutex);
+        // return or create entry
+        return probe_buffers[pid];
+    }
+
+    // helper function to search probe id in probe_buffers
+    bool has_pid(arb::cell_member_type pid) {
+        return probe_buffers.count(pid);
+    }
+
+    // helper function to push back to locked vector
+    void push_back(arb::cell_member_type pid, trace_entry value) {
+        auto& v = probe_buffer(pid);
+        v.push_back(std::move(value));
+    }
+
+    // Access the probe buffers
+    const std::unordered_map<arb::cell_member_type, std::vector<trace_entry>>& samples() const {
+        return probe_buffers;
+    }
+};
+
+// A functor that models arb::sampler_function.
+// Holds a shared pointer to the trace_entry used to store the samples, so that if
+// the trace_entry in sampler is garbage collected in Python, stores will
+// not seg fault.
+
+struct sample_callback {
+    std::shared_ptr<sampler_state> sample_store;
+
+    sample_callback(const std::shared_ptr<sampler_state>& state):
+        sample_store(state)
+    {}
+
+    void operator() (arb::cell_member_type probe_id, arb::probe_tag tag, std::size_t n, const arb::sample_record* recs) {
+        auto& v = sample_store->probe_buffer(probe_id);
+        for (std::size_t i = 0; i<n; ++i) {
+            if (auto p = arb::util::any_cast<const double*>(recs[i].data)) {
+                v.push_back({recs[i].time, *p});
+            }
+            else {
+                throw std::runtime_error("unexpected sample type");
+            }
+        }
+    };
+};
+
+// Helper type for recording samples from a simulation.
+// This type is wrapped in Python, to expose sampler::sample_store.
+struct sampler {
+    std::shared_ptr<sampler_state> sample_store;
+
+    sample_callback callback() {
+        // initialize the sample_store
+        sample_store = std::make_shared<sampler_state>();
+
+        // The callback holds a copy of sample_store, i.e. the shared
+        // pointer is held by both the sampler and the callback, so if
+        // the sampler is destructed in the calling Python code, attempts
+        // to write to sample_store inside the callback will not seg fault.
+        return sample_callback(sample_store);
+    }
+
+    const std::vector<trace_entry>& samples(arb::cell_member_type pid) const {
+        if (!sample_store->has_pid(pid)) {
+            throw std::runtime_error(util::pprintf("probe id {} does not exist", pid));
+        }
+        return sample_store->probe_buffer(pid);
+    }
+
+    void clear() {
+        for (auto b: sample_store->probe_buffers) {
+            b.second.clear();
+        }
+    }
+};
+
+// Adds sampler to one probe with pid
+std::shared_ptr<sampler> attach_sampler(arb::simulation& sim, arb::time_type interval, arb::cell_member_type pid) {
+    auto r = std::make_shared<sampler>();
+    sim.add_sampler(arb::one_probe(pid), arb::regular_schedule(interval), r->callback());
+    return r;
+}
+
+// Adds sampler to all probes
+std::shared_ptr<sampler> attach_sampler(arb::simulation& sim, arb::time_type interval) {
+    auto r = std::make_shared<sampler>();
+    sim.add_sampler(arb::all_probes, arb::regular_schedule(interval), r->callback());
+    return r;
+}
+
+std::string sample_str(const trace_entry& s) {
+        return util::pprintf("<arbor.sample: time {} ms, \tvalue {}>", s.t, s.v);
+}
+
+void register_sampling(pybind11::module& m) {
+    using namespace pybind11::literals;
+
+    // Sample
+    pybind11::class_<trace_entry> sample(m, "sample");
+    sample
+        .def_readonly("time", &trace_entry::t, "The sample time [ms] at a specific probe.")
+        .def_readonly("value", &trace_entry::v, "The sample record at a specific probe.")
+        .def("__str__",  &sample_str)
+        .def("__repr__", &sample_str);
+
+    // Sampler
+    pybind11::class_<sampler, std::shared_ptr<sampler>> samplerec(m, "sampler");
+    samplerec
+        .def(pybind11::init<>())
+        .def("samples", &sampler::samples,
+            "A list of the recorded samples of a probe with probe id.",
+            "probe_id"_a)
+        .def("clear", &sampler::clear, "Clear all recorded samples.");
+
+    m.def("attach_sampler",
+        (std::shared_ptr<sampler> (*)(arb::simulation&, arb::time_type)) &attach_sampler,
+        "Attach a sample recorder to an arbor simulation.\n"
+        "The recorder will record all samples from a regular sampling interval [ms] matching all probe ids.",
+        "sim"_a, "dt"_a);
+
+    m.def("attach_sampler",
+        (std::shared_ptr<sampler> (*)(arb::simulation&, arb::time_type, arb::cell_member_type)) &attach_sampler,
+        "Attach a sample recorder to an arbor simulation.\n"
+        "The recorder will record all samples from a regular sampling interval [ms] matching one probe id.",
+        "sim"_a, "dt"_a, "probe_id"_a);
+}
+
+} // namespace pyarb
diff --git a/python/simulation.cpp b/python/simulation.cpp
index df6244bb399d5877f543a331f129999e1263333d..60137614ae81f1c3a5bc0fb0f15a39a46aeb48bf 100644
--- a/python/simulation.cpp
+++ b/python/simulation.cpp
@@ -1,8 +1,11 @@
 #include <pybind11/pybind11.h>
 
+#include <arbor/common_types.hpp>
+#include <arbor/sampling.hpp>
 #include <arbor/simulation.hpp>
 
 #include "context.hpp"
+#include "error.hpp"
 #include "recipe.hpp"
 
 namespace pyarb {
diff --git a/test/validation/validate_kinetic.cpp b/test/validation/validate_kinetic.cpp
index 57ddd135174af948de68699bccdfa0ff36808e60..e855d02187d5f203dc74a6108255112fe37b8f0b 100644
--- a/test/validation/validate_kinetic.cpp
+++ b/test/validation/validate_kinetic.cpp
@@ -91,7 +91,7 @@ void validate_kinetic_kinlva(const arb::context& ctx) {
     // 20 µm diameter soma with single mechanism, current probe
     cable_cell c;
     auto soma = c.add_soma(10);
-    c.add_stimulus({0,0.5}, {20., 130., -0.025});
+    c.place(mlocation{0,0.5}, {20., 130., -0.025});
     soma->add_mechanism("test_kinlva");
     cell_probe_address probe{{0, 0.5}, cell_probe_address::membrane_voltage};
 
diff --git a/test/validation/validate_synapses.cpp b/test/validation/validate_synapses.cpp
index 1fd4de8c9020bfeb96b391c26e74881036d2ef60..daa3a209ff9fb98a53b88884b58aa4f6d98ff885 100644
--- a/test/validation/validate_synapses.cpp
+++ b/test/validation/validate_synapses.cpp
@@ -40,7 +40,7 @@ void run_synapse_test(
 
     cable_cell c = make_cell_ball_and_stick(false); // no stimuli
     mechanism_desc syn_default(syn_type);
-    c.add_synapse({1, 0.5}, syn_default);
+    c.place(mlocation{1, 0.5}, syn_default);
 
     // injected spike events
     std::vector<spike_event> synthetic_events = {