diff --git a/pystencils/fd/__init__.py b/pystencils/fd/__init__.py
index 0997634114077a5e105d717b1fbe570e34e9158a..5644c576d772969dee990598438b2858e68807d3 100644
--- a/pystencils/fd/__init__.py
+++ b/pystencils/fd/__init__.py
@@ -3,10 +3,11 @@ from .derivative import (
     expand_diff_full, expand_diff_linear, expand_diff_products, functional_derivative,
     normalize_diff_order, zero_diffs)
 from .finitedifferences import Discretization2ndOrder, advection, diffusion, transient
+from .finitevolumes import FVM1stOrder, VOF
 from .spatial import discretize_spatial, discretize_spatial_staggered
 
 __all__ = ['Diff', 'diff', 'DiffOperator', 'diff_terms', 'collect_diffs',
            'zero_diffs', 'evaluate_diffs', 'normalize_diff_order', 'expand_diff_full', 'expand_diff_linear',
            'expand_diff_products', 'combine_diff_products', 'functional_derivative',
            'advection', 'diffusion', 'transient', 'Discretization2ndOrder', 'discretize_spatial',
-           'discretize_spatial_staggered']
+           'discretize_spatial_staggered', 'FVM1stOrder', 'VOF']
diff --git a/pystencils/fd/finitevolumes.py b/pystencils/fd/finitevolumes.py
new file mode 100644
index 0000000000000000000000000000000000000000..4119ac845a35e5e93c747799aa569dadde9455a3
--- /dev/null
+++ b/pystencils/fd/finitevolumes.py
@@ -0,0 +1,238 @@
+import pystencils as ps
+import sympy as sp
+from pystencils.fd.derivation import FiniteDifferenceStaggeredStencilDerivation as FDS, \
+    FiniteDifferenceStencilDerivation as FD
+import itertools
+from collections import defaultdict
+from collections.abc import Iterable
+
+
+class FVM1stOrder:
+    """Finite-volume discretization
+
+    Args:
+        field: the field with the quantity to calculate, e.g. a concentration
+        flux: a list of sympy expressions that specify the flux, one for each cartesian direction
+        source: a list of sympy expressions that specify the source
+    """
+    def __init__(self, field: ps.field.Field, flux=0, source=0):
+        def normalize(f, shape):
+            shape = tuple(s for s in shape if s != 1)
+            if not shape:
+                shape = None
+
+            if isinstance(f, sp.Array) or isinstance(f, Iterable) or isinstance(f, sp.Matrix):
+                return sp.Array(f, shape)
+            else:
+                return sp.Array([f] * (sp.Mul(*shape) if shape else 1))
+
+        self.c = field
+        self.dim = self.c.spatial_dimensions
+        self.j = normalize(flux, (self.dim, ) + self.c.index_shape)
+        self.q = normalize(source, self.c.index_shape)
+
+    def discrete_flux(self, flux_field: ps.field.Field):
+        """Return a list of assignments for the discrete fluxes
+
+        Args:
+            flux_field: a staggered field to which the fluxes should be assigned
+        """
+
+        assert ps.FieldType.is_staggered(flux_field)
+
+        def discretize(term, neighbor):
+            if isinstance(term, sp.Matrix):
+                nw = term.applyfunc(lambda t: discretize(t, neighbor))
+                return nw
+            elif isinstance(term, ps.field.Field.Access):
+                avg = (term.get_shifted(*neighbor) + term) * sp.Rational(1, 2)
+                return avg
+            elif isinstance(term, ps.fd.Diff):
+                direction1 = term.args[1]
+                if isinstance(term.args[0], ps.Field.Access):  # first derivative
+                    access = term.args[0]
+                    direction = (direction1,)
+                elif isinstance(term.args[0], ps.fd.Diff):  # nested derivative
+                    if isinstance(term.args[0].args[0], ps.fd.Diff):  # third or higher derivative
+                        raise ValueError("can only handle first and second derivatives")
+                    elif not isinstance(term.args[0].args[0], ps.Field.Access):
+                        raise ValueError("can only handle derivatives of field accesses")
+
+                    access, direction2 = term.args[0].args[:2]
+                    direction = (direction1, direction2)
+                else:
+                    raise NotImplementedError("can only deal with derivatives of field accesses, "
+                                              "but not {}; expansion of derivatives probably failed"
+                                              .format(type(term.args[0])))
+
+                fds = FDS(neighbor, access.field.spatial_dimensions, direction)
+                return fds.apply(access)
+
+            if term.args:
+                new_args = [discretize(a, neighbor) for a in term.args]
+                return term.func(*new_args)
+            else:
+                return term
+
+        fluxes = self.j.applyfunc(ps.fd.derivative.expand_diff_full)
+        fluxes = [sp.Matrix(fluxes.tolist()[i]) if flux_field.index_dimensions > 1 else fluxes.tolist()[i] 
+                  for i in range(self.dim)]
+
+        discrete_fluxes = []
+        for neighbor in flux_field.staggered_stencil:
+            neighbor = ps.stencil.direction_string_to_offset(neighbor)
+            directional_flux = fluxes[0] * int(neighbor[0])
+            for i in range(1, self.dim):
+                directional_flux += fluxes[i] * int(neighbor[i])
+            discrete_flux = discretize(directional_flux, neighbor)
+            discrete_fluxes.append(discrete_flux)
+
+        if flux_field.index_dimensions > 1:
+            return [ps.Assignment(lhs, rhs)
+                    for i, d in enumerate(flux_field.staggered_stencil) if discrete_fluxes[i]
+                    for lhs, rhs in zip(flux_field.staggered_vector_access(d), sp.simplify(discrete_fluxes[i]))]
+        else:
+            return [ps.Assignment(flux_field.staggered_access(d), sp.simplify(discrete_fluxes[i]))
+                    for i, d in enumerate(flux_field.staggered_stencil)]
+
+    def discrete_source(self):
+        """Return a list of assignments for the discrete source term"""
+
+        def discretize(term):
+            if isinstance(term, ps.fd.Diff):
+                direction1 = term.args[1]
+                if isinstance(term.args[0], ps.Field.Access):  # first derivative
+                    access = term.args[0]
+                    direction = (direction1,)
+                elif isinstance(term.args[0], ps.fd.Diff):  # nested derivative
+                    if isinstance(term.args[0].args[0], ps.fd.Diff):  # third or higher derivative
+                        raise ValueError("can only handle first and second derivatives")
+                    elif not isinstance(term.args[0].args[0], ps.Field.Access):
+                        raise ValueError("can only handle derivatives of field accesses")
+
+                    access, direction2 = term.args[0].args[:2]
+                    direction = (direction1, direction2)
+                else:
+                    raise NotImplementedError("can only deal with derivatives of field accesses, "
+                                              "but not {}; expansion of derivatives probably failed"
+                                              .format(type(term.args[0])))
+
+                if self.dim == 2:
+                    stencil = ["".join(a).replace(" ", "") for a in itertools.product("NS ", "EW ")
+                               if "".join(a).strip()]
+                else:
+                    stencil = ["".join(a).replace(" ", "") for a in itertools.product("NS ", "EW ", "TB ")
+                               if "".join(a).strip()]
+                stencil = [tuple(ps.stencil.direction_string_to_offset(d, self.dim)) for d in stencil]
+
+                derivation = FD(direction, stencil).get_stencil()
+                if not derivation.accuracy:
+                    raise Exception('the requested derivative cannot be performed with the available neighbors')
+                weights = derivation.weights
+
+                # if the weights are underdefined, we can choose the free symbols to find the sparsest stencil
+                free_weights = set(itertools.chain(*[w.free_symbols for w in weights]))
+                if len(free_weights) > 0:
+                    zero_counts = defaultdict(list)
+                    for values in itertools.product([-1, -sp.Rational(1, 2), 0, 1, sp.Rational(1, 2)],
+                                                    repeat=len(free_weights)):
+                        subs = {free_weight: value for free_weight, value in zip(free_weights, values)}
+                        weights = [w.subs(subs) for w in derivation.weights]
+                        if not all(a == 0 for a in weights):
+                            zero_count = sum([1 for w in weights if w == 0])
+                            zero_counts[zero_count].append(weights)
+                    best = zero_counts[max(zero_counts.keys())]
+                    if len(best) > 1:
+                        raise NotImplementedError("more than one suitable set of weights found, "
+                                                  "don't know how to proceed")
+                    weights = best[0]
+                    assert weights
+
+                if access._field.index_dimensions == 0:
+                    return sum([access._field.__getitem__(point) * weight for point, weight in zip(stencil, weights)])
+                else:
+                    total = access.get_shifted(*stencil[0]).at_index(*access.index) * weights[0]
+                    for point, weight in zip(stencil[1:], weights[1:]):
+                        addl = access.get_shifted(*point).at_index(*access.index) * weight
+                        total += addl
+                    return total
+
+            if term.args:
+                new_args = [discretize(a) for a in term.args]
+                return term.func(*new_args)
+            else:
+                return term
+
+        source = self.q.applyfunc(ps.fd.derivative.expand_diff_full)
+        source = source.applyfunc(discretize)
+
+        return [ps.Assignment(lhs, rhs) for lhs, rhs in zip(self.c.center_vector, sp.flatten(source)) if rhs]
+
+    def discrete_continuity(self, flux_field: ps.field.Field):
+        """Return a list of assignments for the continuity equation, which includes the source term
+
+        Args:
+            flux_field: a staggered field from which the fluxes are taken
+        """
+
+        assert ps.FieldType.is_staggered(flux_field)
+
+        neighbors = flux_field.staggered_stencil + [ps.stencil.inverse_direction_string(d)
+                                                    for d in flux_field.staggered_stencil]
+        divergence = flux_field.staggered_vector_access(neighbors[0]) / \
+            sp.Matrix(ps.stencil.direction_string_to_offset(neighbors[0])).norm()
+        for d in neighbors[1:]:
+            divergence += flux_field.staggered_vector_access(d) / \
+                sp.Matrix(ps.stencil.direction_string_to_offset(d)).norm()
+        A0 = sum([sp.Matrix(ps.stencil.direction_string_to_offset(d)).norm()
+                  for d in flux_field.staggered_stencil]) / self.dim
+        divergence /= A0
+
+        source = self.discrete_source()
+        source = {s.lhs: s.rhs for s in source}
+
+        return [ps.Assignment(lhs, (lhs - rhs + source[lhs]) if lhs in source else (lhs - rhs))
+                for lhs, rhs in zip(self.c.center_vector, divergence)]
+
+
+def VOF(j: ps.field.Field, v: ps.field.Field, ρ: ps.field.Field):
+    """Volume-of-fluid discretization of advection
+
+    Args:
+        j: the staggeredfield to write the fluxes to
+        v: the flow velocity field
+        ρ: the quantity to advect
+    """
+    assert ps.FieldType.is_staggered(j)
+
+    fluxes = [[] for i in range(j.index_shape[0])]
+
+    v0 = v.center_vector
+    for d, neighbor in enumerate(j.staggered_stencil):
+        c = ps.stencil.direction_string_to_offset(neighbor)
+        v1 = v.neighbor_vector(c)
+
+        # going out
+        cond = sp.And(*[sp.Or(c[i] * v1[i] > 0, c[i] == 0) for i in range(len(v0))])
+        overlap1 = [1 - sp.Abs(v0[i]) for i in range(len(v0))]
+        overlap2 = [c[i] * v0[i] for i in range(len(v0))]
+        overlap = sp.Mul(*[(overlap1[i] if c[i] == 0 else overlap2[i]) for i in range(len(v0))])
+        fluxes[d].append(ρ.center_vector * overlap * sp.Piecewise((1, cond), (0, True)))
+
+        # coming in
+        cond = sp.And(*[sp.Or(c[i] * v1[i] < 0, c[i] == 0) for i in range(len(v1))])
+        overlap1 = [1 - sp.Abs(v1[i]) for i in range(len(v1))]
+        overlap2 = [c[i] * v1[i] for i in range(len(v1))]
+        overlap = sp.Mul(*[(overlap1[i] if c[i] == 0 else overlap2[i]) for i in range(len(v1))])
+        fluxes[d].append(ρ.neighbor_vector(c) * overlap * sp.Piecewise((1, cond), (0, True)))
+
+    for i, ff in enumerate(fluxes):
+        fluxes[i] = ff[0]
+        for f in ff:
+            fluxes[i] += f
+
+    assignments = []
+    for i, d in enumerate(j.staggered_stencil):
+        for lhs, rhs in zip(j.staggered_vector_access(d).values(), fluxes[i].values()):
+            assignments.append(ps.Assignment(lhs, rhs))
+    return assignments
diff --git a/pystencils_tests/test_fvm.py b/pystencils_tests/test_fvm.py
new file mode 100644
index 0000000000000000000000000000000000000000..580fdffe1b03e479d9e5312faee426e3bd21e5ce
--- /dev/null
+++ b/pystencils_tests/test_fvm.py
@@ -0,0 +1,61 @@
+import sympy as sp
+import pystencils as ps
+
+
+def test_ek():
+
+    # parameters
+
+    L = (40, 40)
+    D = sp.Symbol("D")
+    z = sp.Symbol("z")
+
+    # data structures
+
+    dh = ps.create_data_handling(L, periodicity=True, default_target='cpu')
+    c = dh.add_array('c', values_per_cell=1)
+    v = dh.add_array('v', values_per_cell=dh.dim)
+    j = dh.add_array('j', values_per_cell=dh.dim * 2, field_type=ps.FieldType.STAGGERED_FLUX)
+    Phi = dh.add_array('Φ', values_per_cell=1)
+
+    # perform automatic discretization
+
+    def Gradient(f):
+        return sp.Matrix([ps.fd.diff(f, i) for i in range(dh.dim)])
+
+    flux_eq = -D * Gradient(c) + D * z * c.center * Gradient(Phi)
+
+    disc = ps.fd.FVM1stOrder(c, flux_eq)
+    flux_assignments = disc.discrete_flux(j)
+    advection_assignments = ps.fd.VOF(j, v, c)
+    continuity_assignments = disc.discrete_continuity(j)
+
+    # manual discretization
+
+    x_staggered = - c[-1, 0] + c[0, 0] + z * (c[-1, 0] + c[0, 0]) / 2 * (Phi[-1, 0] - Phi[0, 0])
+    y_staggered = - c[0, -1] + c[0, 0] + z * (c[0, -1] + c[0, 0]) / 2 * (Phi[0, -1] - Phi[0, 0])
+    xy_staggered = - c[-1, -1] + c[0, 0] + z * (c[-1, -1] + c[0, 0]) / 2 * (Phi[-1, -1] - Phi[0, 0])
+    xY_staggered = - c[-1, 1] + c[0, 0] + z * (c[-1, 1] + c[0, 0]) / 2 * (Phi[-1, 1] - Phi[0, 0])
+
+    jj = j.staggered_access
+    divergence = -1 / (1 + sp.sqrt(2) if j.index_shape[0] == 4 else 1) * \
+        sum([jj(d) / sp.Matrix(ps.stencil.direction_string_to_offset(d)).norm() for d in j.staggered_stencil
+            + [ps.stencil.inverse_direction_string(d) for d in j.staggered_stencil]])
+
+    update = [ps.Assignment(c.center, c.center + divergence)]
+    flux = [ps.Assignment(j.staggered_access("W"), D * x_staggered),
+            ps.Assignment(j.staggered_access("S"), D * y_staggered)]
+    if j.index_shape[0] == 4:
+        flux += [ps.Assignment(j.staggered_access("SW"), D * xy_staggered),
+                 ps.Assignment(j.staggered_access("NW"), D * xY_staggered)]
+
+    # compare
+
+    for a, b in zip(flux, flux_assignments):
+        assert a.lhs == b.lhs
+        assert sp.simplify(a.rhs - b.rhs) == 0
+    for a, b in zip(update, continuity_assignments):
+        assert a.lhs == b.lhs
+        assert a.rhs == b.rhs
+
+    # TODO: test advection and source