diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml
index 945c1569689c0138391e14824d8fc1ad8859c0a4..fce271537d12a8be3b8cb7bef522bc6ee8f6d075 100644
--- a/.github/workflows/basic.yml
+++ b/.github/workflows/basic.yml
@@ -143,6 +143,7 @@ jobs:
             python python/example/network_ring.py
             python python/example/single_cell_model.py
             python python/example/single_cell_recipe.py
+            python python/example/single_cell_stdp.py
             python python/example/single_cell_swc.py test/unit/swc/pyramidal.swc
             python python/example/single_cell_detailed.py python/example/morph.swc
             python python/example/single_cell_detailed_recipe.py python/example/morph.swc
diff --git a/mechanisms/CMakeLists.txt b/mechanisms/CMakeLists.txt
index bb56bfc25c89e7cb5b15b8e03eb064341ffbbedb..5e8f3eab9a578804bce5570de79d93ab03819793 100644
--- a/mechanisms/CMakeLists.txt
+++ b/mechanisms/CMakeLists.txt
@@ -21,7 +21,7 @@ make_catalogue(
   NAME default
   SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/default"
   OUTPUT "CAT_DEFAULT_SOURCES"
-  MECHS exp2syn expsyn hh kamt kdrmt nax nernst pas
+  MECHS exp2syn expsyn expsyn_stdp hh kamt kdrmt nax nernst pas
   ARBOR "${PROJECT_SOURCE_DIR}"
   STANDALONE FALSE)
 
diff --git a/mechanisms/default/expsyn_stdp.mod b/mechanisms/default/expsyn_stdp.mod
new file mode 100644
index 0000000000000000000000000000000000000000..011c1129fb5ee18715b00cbb147075b682c4e06a
--- /dev/null
+++ b/mechanisms/default/expsyn_stdp.mod
@@ -0,0 +1,58 @@
+: Exponential synapse with online STDP
+: cf. https://brian2.readthedocs.io/en/stable/resources/tutorials/2-intro-to-brian-synapses.html#more-complex-synapse-models-stdp
+
+NEURON {
+    POINT_PROCESS expsyn_stdp
+    RANGE tau, taupre, taupost, e, Apost, Apre, max_weight
+    NONSPECIFIC_CURRENT i
+}
+
+UNITS {
+    (mV) = (millivolt)
+}
+
+PARAMETER {
+    tau = 2.0 (ms) : synaptic time constant
+    taupre = 10 (ms) : time constant of the pre-synaptic eligibility trace
+    taupost = 10 (ms) : time constant of the post-synaptic eligibility trace
+    Apre = 0.01 : pre-synaptic contribution
+    Apost = -0.01  : post-synaptic contribution
+    e = 0   (mV) : reversal potential
+    max_weight = 10 (nS) : maximum synaptic conductance
+}
+
+STATE {
+    g
+    apre
+    apost
+    weight_plastic
+}
+
+INITIAL {
+    g=0
+    apre=0
+    apost=0
+    weight_plastic=0
+}
+
+BREAKPOINT {
+    SOLVE state METHOD cnexp
+    i = g*(v-e)
+}
+
+DERIVATIVE state {
+    g' = -g/tau
+    apre' = -apre/taupre
+    apost' = -apost/taupost
+}
+
+NET_RECEIVE(weight) {
+    g = max(0, min(g + weight + weight_plastic, max_weight))
+    apre = apre + Apre
+    weight_plastic = weight_plastic + apost
+}
+
+POST_EVENT(time) {
+    apost = apost + Apost
+    weight_plastic = weight_plastic + apre
+}
diff --git a/python/example/single_cell_stdp.py b/python/example/single_cell_stdp.py
new file mode 100755
index 0000000000000000000000000000000000000000..92816a210d68548cb237a6f5bb4524fb49533ffc
--- /dev/null
+++ b/python/example/single_cell_stdp.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+
+import arbor
+import numpy
+import pandas
+import seaborn  # You may have to pip install these.
+
+
+class single_recipe(arbor.recipe):
+    def __init__(self, dT, n_pairs):
+        arbor.recipe.__init__(self)
+        self.dT = dT
+        self.n_pairs = n_pairs
+
+        self.the_props = arbor.neuron_cable_properties()
+        self.the_cat = arbor.default_catalogue()
+        self.the_props.register(self.the_cat)
+
+    def num_cells(self):
+        return 1
+
+    def num_sources(self, gid):
+        return 1
+
+    def cell_kind(self, gid):
+        return arbor.cell_kind.cable
+
+    def cell_description(self, gid):
+        tree = arbor.segment_tree()
+        tree.append(arbor.mnpos, arbor.mpoint(-3, 0, 0, 3),
+                    arbor.mpoint(3, 0, 0, 3), tag=1)
+
+        labels = arbor.label_dict({'soma':   '(tag 1)',
+                                   'center': '(location 0 0.5)'})
+
+        decor = arbor.decor()
+        decor.set_property(Vm=-40)
+        decor.paint('(all)', 'hh')
+
+        decor.place('"center"', arbor.spike_detector(-10))
+        decor.place('"center"', 'expsyn')
+
+        mech_syn = arbor.mechanism('expsyn_stdp')
+        mech_syn.set("max_weight", 1.)
+
+        decor.place('"center"', mech_syn)
+
+        cell = arbor.cable_cell(tree, labels, decor)
+
+        return cell
+
+    def event_generators(self, gid):
+        """two stimuli: one that makes the cell spike, the other to monitor STDP
+        """
+
+        stimulus_times = numpy.linspace(50, 500, self.n_pairs)
+
+        # strong enough stimulus
+        spike = arbor.event_generator(arbor.cell_member(
+            0, 0), 1., arbor.explicit_schedule(stimulus_times))
+
+        # zero weight -> just modify synaptic weight via stdp
+        stdp = arbor.event_generator(arbor.cell_member(
+            0, 1), 0., arbor.explicit_schedule(stimulus_times - self.dT))
+
+        return [spike, stdp]
+
+    def probes(self, gid):
+        return [arbor.cable_probe_membrane_voltage('"center"'),
+                arbor.cable_probe_point_state(1, "expsyn_stdp", "g"),
+                arbor.cable_probe_point_state(1, "expsyn_stdp", "apost"),
+                arbor.cable_probe_point_state(1, "expsyn_stdp", "apre"),
+                arbor.cable_probe_point_state(
+                    1, "expsyn_stdp", "weight_plastic")
+                ]
+
+    def global_properties(self, kind):
+        return self.the_props
+
+
+def run(dT, n_pairs=1, do_plots=False):
+    recipe = single_recipe(dT, n_pairs)
+
+    context = arbor.context()
+    domains = arbor.partition_load_balance(recipe, context)
+    sim = arbor.simulation(recipe, domains, context)
+
+    sim.record(arbor.spike_recording.all)
+
+    reg_sched = arbor.regular_schedule(0.1)
+    handle_mem = sim.sample((0, 0), reg_sched)
+    handle_g = sim.sample((0, 1), reg_sched)
+    handle_apost = sim.sample((0, 2), reg_sched)
+    handle_apre = sim.sample((0, 3), reg_sched)
+    handle_weight_plastic = sim.sample((0, 4), reg_sched)
+
+    sim.run(tfinal=600)
+
+    if do_plots:
+        print("Plotting detailed results ...")
+
+        for (handle, var) in [(handle_mem, 'U'),
+                              (handle_g, "g"),
+                              (handle_apost, "apost"),
+                              (handle_apre, "apre"),
+                              (handle_weight_plastic, "weight_plastic")]:
+
+            data, meta = sim.samples(handle)[0]
+
+            df = pandas.DataFrame({'t/ms': data[:, 0], var: data[:, 1]})
+            seaborn.relplot(data=df, kind="line", x="t/ms", y=var,
+                            ci=None).savefig('single_cell_stdp_result_{}.svg'.format(var))
+
+    weight_plastic, meta = sim.samples(handle_weight_plastic)[0]
+
+    return weight_plastic[:, 1][-1]
+
+
+data = numpy.array([(dT, run(dT)) for dT in numpy.arange(-20, 20, 0.5)])
+df = pandas.DataFrame({'t/ms': data[:, 0], 'dw': data[:, 1]})
+print("Plotting results ...")
+seaborn.relplot(data=df, x="t/ms", y="dw", kind="line",
+                ci=None).savefig('single_cell_stdp.svg')