diff --git a/doc/source/pre/whatsnew/1.5.rst b/doc/source/pre/whatsnew/1.5.rst
index 9a9c085c8d924c0cda45d84dc75a5b1a0b893016..9340cec96666ac0b9f02b7fa92ab1471ea63dc78 100644
--- a/doc/source/pre/whatsnew/1.5.rst
+++ b/doc/source/pre/whatsnew/1.5.rst
@@ -12,3 +12,41 @@ the code are inserted directly into the document thanks to the magic of
 `jupyter-sphinx <https://github.com/jupyter-widgets/jupyter-sphinx/>`_.
 It has never been easier to get started contributing to Kwant by
 helping us improve our documentation.
+
+Discretization onto a Landau level basis
+----------------------------------------
+The Hamiltonian for a system infinite in at least two dimensions and with
+a constant applied magnetic field may be expressed in a basis of Landau levels.
+The momenta in the plane perpendicular to the magnetic field direction are
+written in terms of the Landau level ladder operators:
+
+.. math::
+    k_x = \sqrt{\frac{B}{2}} (a + a^\dagger) \quad\quad
+    k_y = i \sqrt{\frac{B}{2}} (a - a^\dagger)
+
+The Hamiltonian is then expressed in terms of these ladder operators, which
+allows for a straight-forward discretization in the basis of Landau levels,
+provided that the basis is truncated after $N$ levels.
+`kwant.continuum.discretize_landau` makes this procedure simple::
+
+    syst = kwant.continuum.discretize_landau("k_x**2 + k_y**2", N)
+    syst.finalized().hamiltonian_submatrix(params=dict(B=0.5))
+
+`~kwant.continuum.discretize_landau` produces a regular Kwant Builder that
+can be inspected or finalized as usual. 3D Hamiltonians for systems that
+extend into the direction perpendicular to the magnetic field are also
+possible::
+
+    template = kwant.continuum.discretize_landau("k_x**2 + k_y**2 + k_z**2 + V(z)", N)
+
+This will produce a Builder with a single translational symmetry, which can be
+directly finalized, or can be used as a template for e.g. a heterostructure stack
+in the direction of the magnetic field::
+
+    def stack(site):
+        z, = site.pos
+        return 0 <= z < 10
+
+    template = kwant.continuum.discretize_landau("k_x**2 + k_y**2 + k_z**2 + V(z)", N)
+    syst = kwant.Builder()
+    syst.fill(template, stack, (0,))
diff --git a/doc/source/reference/kwant.continuum.rst b/doc/source/reference/kwant.continuum.rst
index d6799438a0b7d4c67fb7d620ce97bad04efe7ed8..6ad52d325dcb26d43a9c1e1f0cc0946739f39471 100644
--- a/doc/source/reference/kwant.continuum.rst
+++ b/doc/source/reference/kwant.continuum.rst
@@ -11,6 +11,7 @@ Discretizer
     discretize
     discretize_symbolic
     build_discretized
+    discretize_landau
 
 Symbolic helpers
 ----------------
@@ -19,3 +20,11 @@ Symbolic helpers
 
     sympify
     lambdify
+    to_landau_basis
+
+Other
+-----
+.. autosummary::
+    :toctree: generated/
+
+    LandauLattice
diff --git a/doc/source/tutorial/discretize.rst b/doc/source/tutorial/discretize.rst
index 9417d20b52e8e6448f32e64894928f3eefa56abc..51f94b790bd1dacdb6b79ab5080791e35d64449a 100644
--- a/doc/source/tutorial/discretize.rst
+++ b/doc/source/tutorial/discretize.rst
@@ -217,6 +217,8 @@ In addition, the function passed as ``V`` expects two input parameters ``x``
 and ``y``, the same as in the initial continuum Hamiltonian.
 
 
+.. _discretize-bhz-model:
+
 Models with more structure: Bernevig-Hughes-Zhang
 .................................................
 
diff --git a/doc/source/tutorial/index.rst b/doc/source/tutorial/index.rst
index 4fd4d188928b0716e23d3ac45d6e4aa429bf1f87..0c361fcc91fe24bc811dc603a1b9483348d30a35 100644
--- a/doc/source/tutorial/index.rst
+++ b/doc/source/tutorial/index.rst
@@ -12,4 +12,5 @@ Tutorial: learning Kwant through examples
     plotting
     kpm
     discretize
+    magnetic_field
     faq
diff --git a/doc/source/tutorial/magnetic_field.rst b/doc/source/tutorial/magnetic_field.rst
new file mode 100644
index 0000000000000000000000000000000000000000..6bb6fdcdc91a10bc36a66b59c13e2cdee6fcc2a6
--- /dev/null
+++ b/doc/source/tutorial/magnetic_field.rst
@@ -0,0 +1,232 @@
+Adding magnetic field
+---------------------
+
+Computing Landau levels in a harmonic oscillator basis
+......................................................
+When electrons move in an external magnetic field, their motion perpendicular
+to the field direction is quantized into discrete Landau levels. Kwant implements
+an efficient scheme for computing the Landau levels of arbitrary continuum
+Hamiltonians. The general scheme revolves around rewriting the Hamiltonian in terms
+of a basis of harmonic oscillator states [#]_, and is commonly illustrated in textbooks
+for quadratic Hamiltonians.
+
+To demonstrate the general scheme, let us consider a magnetic field oriented along
+the :math:`z` direction :math:`\vec{B} = (0, 0, B)`, such that electron motion
+in the :math:`xy` plane is Landau quantized. The magnetic field enters the Hamiltonian
+through the kinetic momentum
+
+.. math:: \hbar \vec{k} = - i \hbar \nabla + e\vec{A}(x, y).
+
+In the symmetric gauge :math:`\vec{A}(x, y) = (B/2)[-y, x, 0]`, we introduce ladder
+operators with the substitution
+
+.. math::
+
+    k_x = \frac{1}{\sqrt{2} l_B} (a + a^\dagger), \quad \quad
+    k_y = \frac{1}{\sqrt{2} l_B} (a - a^\dagger),
+
+with the magnetic length :math:`l_B = \sqrt{\hbar/eB}`. The ladder operators obey the
+commutation relation
+
+.. math:: [a, a^\dagger] = 1,
+
+and define a quantum harmonic oscillator. We can thus write any electron continuum
+Hamiltonian in terms of :math:`a` and :math:`a^\dagger`. Such a Hamiltonian has a
+simple matrix representation in the eigenbasis of the number operator :math:`a^\dagger a`.
+The eigenstates satisfy :math:`a^\dagger a | n \rangle = n | n \rangle` with the integer
+Landau level index :math:`n \geq 0`, and in coordinate representation are proportional to
+
+.. math::
+    
+    \psi_n (x, y) = \left( \frac{\partial}{ \partial w} - \frac{w^*}{4 l_B^2} \right)
+    w^n e^{-|w|^2/4l_B^2},
+
+with :math:`w = x + i y`. The matrix elements of the ladder operators are
+
+.. math::
+
+    \langle n | a | m \rangle = \sqrt{m}~\delta_{n, m-1}, \quad \quad
+    \langle n | a^\dagger | m \rangle = \sqrt{m + 1}~\delta_{n, m+1}.
+    
+Truncating the basis to the first :math:`N` Landau levels allows us to approximate
+the Hamiltonian as a discrete, finite matrix.
+
+We can now formulate the algorithm that Kwant uses to compute Landau levels.
+
+    1. We take a generic continuum Hamiltonian, written in terms of the kinetic
+    momentum :math:`\vec{k}`. The Hamiltonian must be translationally
+    invariant along the directions perpendicular to the field direction.
+    
+    2. We substitute the momenta perpendicular to the magnetic field with the ladder
+    operators :math:`a` and :math:`a^\dagger`.
+    
+    3. We construct a `kwant.builder.Builder` using a special lattice which includes
+    the Landau level index :math:`n` as a degree of freedom on each site. The directions
+    normal to the field direction are not included in the builder, because they are
+    encoded in the Landau level index.
+    
+This procedure is automated with `kwant.continuum.discretize_landau`. 
+
+As an example, let us take the Bernevig-Hughes-Zhang model that we first considered in the
+discretizer tutorial ":ref:`discretize-bhz-model`":
+
+.. math::
+
+    C + M σ_0 \otimes σ_z + F(k_x^2 + k_y^2) σ_0 \otimes σ_z + D(k_x^2 + k_y^2) σ_0 \otimes σ_0
+    + A k_x σ_z \otimes σ_x + A k_y σ_0 \otimes σ_y.
+
+We can discretize this Hamiltonian in a basis of Landau levels as follows
+
+.. jupyter-execute::
+
+    import numpy as np
+    import scipy.linalg
+    from matplotlib import pyplot
+
+    import kwant
+    import kwant.continuum
+
+.. jupyter-execute:: boilerplate.py
+    :hide-code:
+
+.. jupyter-execute::
+
+    hamiltonian = """
+       + C * identity(4) + M * kron(sigma_0, sigma_z)
+       - F * (k_x**2 + k_y**2) * kron(sigma_0, sigma_z)
+       - D * (k_x**2 + k_y**2) * kron(sigma_0, sigma_0)
+       + A * k_x * kron(sigma_z, sigma_x)
+       - A * k_y * kron(sigma_0, sigma_y)
+    """
+
+    syst = kwant.continuum.discretize_landau(hamiltonian, N=10)
+    syst = syst.finalized()
+
+We can then plot the spectrum of the system as a function of magnetic field, and
+observe a crossing of Landau levels at finite magnetic field near zero energy,
+characteristic of a quantum spin Hall insulator with band inversion.
+
+.. jupyter-execute::
+
+    params = dict(A=3.645, F =-68.6, D=-51.2, M=-0.01, C=0)
+    b_values = np.linspace(0.0001, 0.0004, 200)
+
+    fig = kwant.plotter.spectrum(syst, ('B', b_values), params=params, show=False)
+    pyplot.ylim(-0.1, 0.2);
+
+
+Comparing with tight-binding
+============================
+In the limit where fewer than one quantum of flux is threaded through a plaquette of
+the discretization lattice we can compare the discretization in Landau levels with
+a discretization in realspace.
+
+.. jupyter-execute::
+
+    lat = kwant.lattice.square()
+    syst = kwant.Builder(kwant.TranslationalSymmetry((-1, 0)))
+
+    def peierls(to_site, from_site, B):
+        y = from_site.tag[1]
+        return -1 * np.exp(-1j * B * y)
+
+    syst[(lat(0, j) for j in range(-19, 20))] = 4
+    syst[lat.neighbors()] = -1
+    syst[kwant.HoppingKind((1, 0), lat)] = peierls
+    syst = syst.finalized()
+
+    landau_syst = kwant.continuum.discretize_landau("k_x**2 + k_y**2", N=5)
+    landau_syst = landau_syst.finalized()
+
+Here we plot the dispersion relation for the discretized ribbon and compare it
+with the Landau levels shown as dashed lines.
+
+.. jupyter-execute::
+
+    fig, ax = pyplot.subplots(1, 1)
+    ax.set_xlabel("momentum")
+    ax.set_ylabel("energy")
+    ax.set_ylim(0, 1)
+
+    params = dict(B=0.1)
+
+    kwant.plotter.bands(syst, ax=ax, params=params)
+
+    h = landau_syst.hamiltonian_submatrix(params=params)
+    for ev in scipy.linalg.eigvals(h):
+      ax.axhline(ev, linestyle='--')
+      
+The dispersion and the Landau levels diverge with increasing energy, because the real space
+discretization of the ribbon gives a worse approximation to the dispersion at higher energies.
+
+
+Discretizing 3D models
+======================
+Although the preceding examples have only included the plane perpendicular to the
+magnetic field, the Landau level quantization also works if the direction
+parallel to the field is included. In fact, although the system must be
+translationally invariant in the plane perpendicular to the field, the system may
+be arbitrary along the parallel direction. For example, it is therefore possible to
+model a heterostructure and/or set up a scattering problem along the field direction.
+
+Let's say that we wish to to model a heterostructure with a varying potential
+:math:`V` along the direction of a magnetic field, :math:`z`, that includes
+Zeeman splitting and Rashba spin-orbit coupling:
+
+.. math::
+
+    \frac{\hbar^2}{2m}\sigma_0(k_x^2 + k_y^2 + k_z^2)
+    + V(z)\sigma_0
+    + \frac{\mu_B B}{2}\sigma_z
+    + \hbar\alpha(\sigma_x k_y - \sigma_y k_x).
+
+We can discretize this Hamiltonian in a basis of Landau levels as before:
+
+.. jupyter-execute::
+
+    continuum_hamiltonian = """
+        (k_x**2 + k_y**2 + k_z**2) * sigma_0
+        + V(z) * sigma_0
+        + mu * B * sigma_z / 2
+        + alpha * (sigma_x * k_y - sigma_y * k_x)
+    """
+
+    template = kwant.continuum.discretize_landau(continuum_hamiltonian, N=10)
+
+This creates a system with a single translational symmetry, along
+the :math:`z` direction, which we can use as a template
+to construct our heterostructure:
+
+.. jupyter-execute::
+
+    def hetero_structure(site):
+        z, = site.pos
+        return 0 <= z < 10
+
+    def hetero_potential(z):
+        if z < 2:
+          return 0
+        elif z < 7:
+          return 0.5
+        else:
+          return 0.7
+
+    syst = kwant.Builder()
+    syst.fill(template, hetero_structure, (0,))
+
+    syst = syst.finalized()
+
+    params = dict(
+        B=0.5,
+        mu=0.2,
+        alpha=0.4,
+        V=hetero_potential,
+    )
+
+    syst.hamiltonian_submatrix(params=params);
+
+
+.. rubric:: Footnotes
+
+.. [#] `Wikipedia <https://en.wikipedia.org/wiki/Landau_quantization>`_ has
+    a nice introduction to Landau quantization.
\ No newline at end of file
diff --git a/kwant/continuum/__init__.py b/kwant/continuum/__init__.py
index fd08848f69cfae23f6e6612e74167427c675f8e3..bf6da67bdf8861443851148a4f3c25bf99546429 100644
--- a/kwant/continuum/__init__.py
+++ b/kwant/continuum/__init__.py
@@ -10,10 +10,12 @@ try:
     from .discretizer import discretize, discretize_symbolic, build_discretized
     from ._common import sympify, lambdify
     from ._common import momentum_operators, position_operators
+    from .landau_levels import to_landau_basis, discretize_landau, LandauLattice
 except ImportError as error:
     msg = ("'kwant.continuum' is not available because one or more of its "
            "dependencies is not installed.")
     raise ImportError(msg) from error
 
 __all__ = ['discretize', 'discretize_symbolic', 'build_discretized',
+           'to_landau_basis', 'discretize_landau', 'LandauLattice',
            'sympify', 'lambdify', 'momentum_operators', 'position_operators']
diff --git a/kwant/continuum/landau_levels.py b/kwant/continuum/landau_levels.py
new file mode 100644
index 0000000000000000000000000000000000000000..90544579724c1afa56cf061b6b94b96395e735a8
--- /dev/null
+++ b/kwant/continuum/landau_levels.py
@@ -0,0 +1,454 @@
+# Copyright 2011-2019 Kwant authors.
+#
+# This file is part of Kwant.  It is subject to the license terms in the file
+# LICENSE.rst found in the top-level directory of this distribution and at
+# http://kwant-project.org/license.  A list of Kwant authors can be found in
+# the file AUTHORS.rst at the top-level directory of this distribution and at
+# http://kwant-project.org/authors.
+
+from keyword import iskeyword
+import functools
+import operator
+import collections
+
+from math import sqrt
+import tinyarray as ta
+import numpy as np
+import sympy
+
+import kwant.lattice
+import kwant.builder
+
+import kwant.continuum
+import kwant.continuum._common
+import kwant.continuum.discretizer
+from kwant.continuum import momentum_operators, position_operators
+
+__all__ = ["to_landau_basis", "discretize_landau"]
+
+coordinate_vectors = dict(zip("xyz", np.eye(3)))
+
+ladder_lower, ladder_raise = sympy.symbols(r"a a^\dagger", commutative=False)
+
+
+def to_landau_basis(hamiltonian, momenta=None):
+    r"""Replace two momenta by Landau level ladder operators.
+
+    Replaces:
+
+        k_0 -> sqrt(B/2) * (a + a^\dagger)
+        k_1 -> 1j * sqrt(B/2) * (a - a^\dagger)
+
+    Parameters
+    ----------
+    hamiltonian : str or sympy expression
+        The Hamiltonian to which to apply the Landau level transformation.
+    momenta : sequence of str (optional)
+        The momenta to replace with Landau level ladder operators. If not
+        provided, 'k_x' and 'k_y' are used
+
+    Returns
+    -------
+    hamiltonian : sympy expression
+    momenta : sequence of sympy atoms
+        The momentum operators that have been replaced by ladder operators.
+    normal_coordinate : sympy atom
+        The remaining position coordinate. May or may not be present
+        in 'hamiltonian'.
+    """
+    hamiltonian = kwant.continuum.sympify(hamiltonian)
+    momenta = _normalize_momenta(momenta)
+    normal_coordinate = _find_normal_coordinate(hamiltonian, momenta)
+
+    # Substitute ladder operators for Landau level momenta
+    B = sympy.symbols("B")
+    hamiltonian = hamiltonian.subs(
+        {
+            momenta[0]: sympy.sqrt(abs(B) / 2) * (ladder_raise + ladder_lower),
+            momenta[1]: sympy.I
+            * sympy.sqrt(abs(B) / 2)
+            * (ladder_lower - ladder_raise),
+        }
+    )
+
+    return hamiltonian, momenta, normal_coordinate
+
+
+def discretize_landau(hamiltonian, N, momenta=None, grid_spacing=1):
+    """Discretize a Hamiltonian in a basis of Landau levels.
+
+    Parameters
+    ----------
+    hamiltonian : str or sympy expression
+    N : positive integer
+        The number of Landau levels in the basis.
+    momenta : sequence of str (optional)
+        The momenta defining the plane perpendicular to the magnetic field.
+        If not provided, "k_x" and "k_y" are used.
+    grid_spacing : float, default: 1
+        The grid spacing to use when discretizing the normal direction
+        (parallel to the magnetic field).
+
+    Returns
+    -------
+    builder : `~kwant.builder.Builder`
+        'hamiltonian' discretized in a basis of Landau levels in the plane
+        defined by 'momenta'. If a third coordinate is present in 'hamiltonian',
+        then this also has a translational symmetry in that coordinate direction.
+    """
+
+    if N <= 0:
+        raise ValueError("N must be positive")
+
+    hamiltonian, momenta, normal_coordinate = to_landau_basis(hamiltonian, momenta)
+
+    # Discretize in normal direction and split terms for onsites/hoppings into
+    # monomials in ladder operators.
+    tb_hamiltonian, _ = kwant.continuum.discretize_symbolic(
+        hamiltonian, coords=[normal_coordinate.name]
+    )
+    tb_hamiltonian = {
+        key: kwant.continuum._common.monomials(value, gens=(ladder_lower, ladder_raise))
+        for key, value in tb_hamiltonian.items()
+    }
+
+    # Replace ladder operator monomials by tuple of integers:
+    # e.g. a^\dagger a^2 -> (+1, -2).
+    tb_hamiltonian = {
+        outer_key: {
+            _ladder_term(inner_key): inner_value
+            for inner_key, inner_value in outer_value.items()
+        }
+        for outer_key, outer_value in tb_hamiltonian.items()
+    }
+
+    # Construct map from LandauLattice HoppingKinds to a sequence of pairs
+    # that encode the ladder operators and multiplying expression.
+    tb_hoppings = collections.defaultdict(list)
+    for outer_key, outer_value in tb_hamiltonian.items():
+        for inner_key, inner_value in outer_value.items():
+            tb_hoppings[(*outer_key, sum(inner_key))] += [(inner_key, inner_value)]
+    # Extract the number of orbitals on each site/hopping
+    random_element = next(iter(tb_hoppings.values()))[0][1]
+    norbs = 1 if isinstance(random_element, sympy.Expr) else random_element.shape[0]
+    tb_onsite = tb_hoppings.pop((0, 0), None)
+
+    # Construct Builder
+    if _has_coordinate(normal_coordinate, hamiltonian):
+        sym = kwant.lattice.TranslationalSymmetry([grid_spacing, 0])
+    else:
+        sym = kwant.builder.NoSymmetry()
+    lat = LandauLattice(grid_spacing, norbs=norbs)
+    syst = kwant.Builder(sym)
+
+    # Add onsites
+    landau_sites = (lat(0, j) for j in range(N))
+    if tb_onsite is None:
+        syst[landau_sites] = ta.zeros((norbs, norbs))
+    else:
+        syst[landau_sites] = _builder_value(
+            tb_onsite, normal_coordinate.name, grid_spacing, is_onsite=True
+        )
+
+    # Add zero hoppings between adjacent Landau levels.
+    # Necessary to be able to use the Landau level builder
+    # to populate another builder using builder.fill().
+    syst[kwant.builder.HoppingKind((0, 1), lat)] = ta.zeros((norbs, norbs))
+
+    # Add the hoppings from the Hamiltonian
+    for hopping, parts in tb_hoppings.items():
+        syst[kwant.builder.HoppingKind(hopping, lat)] = _builder_value(
+            parts, normal_coordinate.name, grid_spacing, is_onsite=False
+        )
+
+    return syst
+
+
+# This has to subclass lattice so that it will work with TranslationalSymmetry.
+class LandauLattice(kwant.lattice.Monatomic):
+    """
+    A `~kwant.lattice.Monatomic` lattice with a Landau level index per site.
+
+    Site tags (see `~kwant.builder.SiteFamily`) are pairs of integers, where
+    the first integer describes the real space position and the second the
+    Landau level index.
+
+    The real space Bravais lattice is one dimensional, oriented parallel
+    to the magnetic field. The Landau level index represents the harmonic
+    oscillator basis states used for the Landau quantization in the plane
+    normal to the field.
+
+    Parameters
+    ----------
+    grid_spacing : float
+        Real space lattice spacing (parallel to the magnetic field).
+    offset : float (optional)
+        Displacement of the lattice origin from the real space
+        coordinates origin.
+    """
+
+    def __init__(self, grid_spacing, offset=None, name="", norbs=None):
+        if offset is not None:
+            offset = [offset, 0]
+        # The second vector and second coordinate do not matter (they are
+        # not used in pos())
+        super().__init__([[grid_spacing, 0], [0, 1]], offset, name, norbs)
+
+    def pos(self, tag):
+        return ta.array((self.prim_vecs[0, 0] * tag[0] + self.offset[0],))
+
+    def landau_index(self, tag):
+        return tag[-1]
+
+
+def _builder_value(terms, normal_coordinate, grid_spacing, is_onsite):
+    """Construct an onsite/hopping value function from a list of terms
+
+    Parameters
+    ----------
+    terms : list
+        Each element is a pair (ladder_term, sympy expression/matrix).
+        ladder_term is a tuple of integers that encodes a string of
+        Landau raising/lowering operators and the sympy expression
+        is the rest
+    normal_coordinate : str
+    grid_spacing : float
+        The grid spacing in the normal direction
+    is_onsite : bool
+        True if we are constructing an onsite value function
+    """
+    ladder_term_symbols = [sympy.Symbol(_ladder_term_name(lt)) for lt, _ in terms]
+    # Construct a single expression from the terms, multiplying each part
+    # by the placeholder that represents the prefactor from the ladder operator term.
+    (ladder, (_, part)), *rest = zip(ladder_term_symbols, terms)
+    expr = ladder * part
+    for ladder, (_, part) in rest:
+        expr += ladder * part
+    expr = expr.subs(
+        {kwant.continuum.discretizer._displacements[normal_coordinate]: grid_spacing}
+    )
+    # Construct the return string and temporary variable names
+    # for function calls.
+    return_string, map_func_calls, const_symbols, _cache = kwant.continuum.discretizer._return_string(
+        expr, coords=[normal_coordinate]
+    )
+
+    # Remove the ladder term placeholders, as these are not parameters
+    const_symbols = set(const_symbols)
+    for ladder_term in ladder_term_symbols:
+        const_symbols.discard(ladder_term)
+
+    # Construct the argument part of signature. Arguments
+    # consist of any constants and functions in the return string.
+    arg_names = set.union(
+        {s.name for s in const_symbols}, {str(k.func) for k in map_func_calls}
+    )
+    arg_names.discard("Abs")  # Abs function is not a parameter
+    for arg_name in arg_names:
+        if not (arg_name.isidentifier() and not iskeyword(arg_name)):
+            raise ValueError(
+                "Invalid name in used symbols: {}\n"
+                "Names of symbols used in Hamiltonian "
+                "must be valid Python identifiers and "
+                "may not be keywords".format(arg_name)
+            )
+    arg_names = ", ".join(sorted(arg_names))
+    # Construct site part of the function signature
+    site_string = "from_site" if is_onsite else "to_site, from_site"
+    # Construct function signature
+    if arg_names:
+        function_header = "def _({}, {}):".format(site_string, arg_names)
+    else:
+        function_header = "def _({}):".format(site_string)
+    # Construct function body
+    function_body = []
+    if "B" not in arg_names:
+        # B is not a parameter for terms with no ladder operators but we still
+        # need something to pass to _evaluate_ladder_term
+        function_body.append("B = +1")
+    function_body.extend(
+        [
+            "{}, = from_site.pos".format(normal_coordinate),
+            "_ll_index = from_site.family.landau_index(from_site.tag)",
+        ]
+    )
+    # To get the correct hopping if B < 0, we need to set the Hermitian
+    # conjugate of the ladder operator matrix element, which swaps the
+    # from_site and to_site Landau level indices.
+    if not is_onsite:
+        function_body.extend(
+            ["if B < 0:",
+             "    _ll_index = to_site.family.landau_index(to_site.tag)"
+            ]
+        )
+    function_body.extend(
+        "{} = _evaluate_ladder_term({}, _ll_index, B)".format(symb.name, lt)
+        for symb, (lt, _) in zip(ladder_term_symbols, terms)
+    )
+    function_body.extend(
+        "{} = {}".format(v, kwant.continuum.discretizer._print_sympy(k))
+        for k, v in map_func_calls.items()
+    )
+    function_body.append(return_string)
+    func_code = "\n    ".join([function_header] + function_body)
+    # Add "I" to namespace just in case sympy again would miss to replace it
+    # with Python's 1j as it was the case with SymPy 1.2 when "I" was argument
+    # of some function.
+    namespace = {
+        "pi": np.pi,
+        "I": 1j,
+        "_evaluate_ladder_term": _evaluate_ladder_term,
+        "Abs": abs,
+    }
+    namespace.update(_cache)
+    # Construct full source, including cached arrays
+    source = []
+    for k, v in _cache.items():
+        source.append("{} = (\n{})\n".format(k, repr(np.array(v))))
+    source.append(func_code)
+    exec(func_code, namespace)
+    f = namespace["_"]
+    f._source = "".join(source)
+
+    return f
+
+
+def _ladder_term(operator_string):
+    r"""Return a tuple of integers representing a string of ladder operators
+
+    Parameters
+    ----------
+    operator_string : Sympy expression
+        Monomial in ladder operators, e.g. a^\dagger a^2 a^\dagger.
+
+    Returns
+    -------
+    ladder_term : tuple of int
+        e.g. a^\dagger a^2 -> (+1, -2)
+    """
+    ret = []
+    for factor in operator_string.as_ordered_factors():
+        ladder_op, exponent = factor.as_base_exp()
+        if ladder_op == ladder_lower:
+            sign = -1
+        elif ladder_op == ladder_raise:
+            sign = +1
+        else:
+            sign = 0
+        ret.append(sign * int(exponent))
+    return tuple(ret)
+
+
+def _ladder_term_name(ladder_term):
+    """
+    Parameters
+    ----------
+    ladder_term : tuple of int
+
+    Returns
+    -------
+    ladder_term_name : str
+    """
+
+    def ns(i):
+        if i >= 0:
+            return str(i)
+        else:
+            return "_" + str(-i)
+
+    return "_ladder_{}".format("_".join(map(ns, ladder_term)))
+
+
+def _evaluate_ladder_term(ladder_term, n, B):
+    r"""Evaluates the prefactor for a ladder operator on a landau level.
+
+    Example: a^\dagger a^2 -> (n - 1) * sqrt(n)
+
+    Parameters
+    ----------
+    ladder_term : tuple of int
+        Represents a string of ladder operators. Positive
+        integers represent powers of the raising operator,
+        negative integers powers of the lowering operator.
+    n : non-negative int
+        Landau level index on which to act with ladder_term.
+    B : float
+        Magnetic field with sign
+
+    Returns
+    -------
+    ladder_term_prefactor : float
+    """
+    assert n >= 0
+    # For negative B we swap a and a^\dagger.
+    if B < 0:
+        ladder_term = tuple(-i for i in ladder_term)
+    ret = 1
+    for m in reversed(ladder_term):
+        if m > 0:
+            factors = range(n + 1, n + m + 1)
+        elif m < 0:
+            factors = range(n + m + 1, n + 1)
+            if n == 0:
+                return 0  # a|0> = 0
+        else:
+            factors = (1,)
+        ret *= sqrt(functools.reduce(operator.mul, factors))
+        n += m
+    return ret
+
+
+def _normalize_momenta(momenta=None):
+    """Return Landau level momenta as Sympy atoms
+
+    Parameters
+    ----------
+    momenta : None or pair of int or pair of str
+        The momenta to choose. If None then 'k_x' and 'k_y'
+        are chosen. If integers, then these are the indices
+        of the momenta: 0 → k_x, 1 → k_y, 2 → k_z. If strings,
+        then these name the momenta.
+
+    Returns
+    -------
+    The specified momenta as sympy atoms.
+    """
+
+    # Specify which momenta to substitute for the Landau level basis.
+    if momenta is None:
+        # Use k_x and k_y by default
+        momenta = momentum_operators[:2]
+    else:
+        if len(momenta) != 2:
+            raise ValueError("Two momenta must be specified.")
+
+        k_names = [k.name for k in momentum_operators]
+
+        if all([type(i) is int for i in momenta]) and all(
+            [i >= 0 and i < 3 for i in momenta]
+        ):
+            momenta = [momentum_operators[i] for i in momenta]
+        elif all([isinstance(momentum, str) for momentum in momenta]) and all(
+            [momentum in k_names for momentum in momenta]
+        ):
+            momenta = [
+                momentum_operators[k_names.index(momentum)] for momentum in momenta
+            ]
+        else:
+            raise ValueError("Momenta must all be integers or strings.")
+
+    return tuple(momenta)
+
+
+def _find_normal_coordinate(hamiltonian, momenta):
+    discrete_momentum = next(
+        momentum for momentum in momentum_operators if momentum not in momenta
+    )
+    normal_coordinate = position_operators[momentum_operators.index(discrete_momentum)]
+    return normal_coordinate
+
+
+def _has_coordinate(coord, expr):
+    momentum = momentum_operators[position_operators.index(coord)]
+    atoms = set(expr.atoms())
+    return coord in atoms or momentum in atoms
diff --git a/kwant/continuum/tests/test_landau_levels.py b/kwant/continuum/tests/test_landau_levels.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a568e6f4a4a716f1daa2c82542f47ea237a0f9d
--- /dev/null
+++ b/kwant/continuum/tests/test_landau_levels.py
@@ -0,0 +1,252 @@
+# Copyright 2011-2019 Kwant authors.
+#
+# This file is part of Kwant.  It is subject to the license terms in the file
+# LICENSE.rst found in the top-level directory of this distribution and at
+# http://kwant-project.org/license.  A list of Kwant authors can be found in
+# the file AUTHORS.rst at the top-level directory of this distribution and at
+# http://kwant-project.org/authors.
+
+from math import sqrt
+
+import numpy as np
+import sympy
+import pytest
+import itertools
+
+import kwant.builder
+import kwant.lattice
+
+from .._common import position_operators, momentum_operators, sympify
+from ..landau_levels import (
+    to_landau_basis,
+    discretize_landau,
+    _ladder_term,
+    _evaluate_ladder_term,
+    ladder_lower,
+    ladder_raise,
+    LandauLattice,
+)
+
+x, y, z = position_operators
+k_x, k_y, k_z = momentum_operators
+B = sympy.symbols("B")
+V = sympy.symbols("V", cls=sympy.Function)
+a, a_dag = ladder_lower, ladder_raise
+
+
+def test_ladder_term():
+    assert _ladder_term(a ** 2 * a_dag) == (-2, 1)
+    assert _ladder_term(a_dag ** 5 * a ** 3 * a_dag) == (5, -3, 1)
+    # non-ladder operators give a shift of 0
+    assert _ladder_term(B * a ** 2) == (0, -2)
+
+
+def test_evaluate_ladder_term():
+    assert np.isclose(_evaluate_ladder_term((+1, -1), 1, +1), 1)
+    assert np.isclose(_evaluate_ladder_term((+1, -1), 2, +1), 2)
+    assert np.isclose(_evaluate_ladder_term((-2, +3, -2), 5, +1), 4 * 5 * 6 * sqrt(5))
+    # annihilating |0> is always 0
+    assert _evaluate_ladder_term((-1,), 0, +1) == 0
+    assert _evaluate_ladder_term((-2,), 1, +1) == 0
+    assert _evaluate_ladder_term((-3,), 1, +1) == 0
+    assert _evaluate_ladder_term((+3, -2), 1, +1) == 0
+    assert _evaluate_ladder_term((-3, -2, +3), 1, +1) == 0
+    # negative B swaps creation and annihilation operators
+    assert _evaluate_ladder_term((+1, -1), 2, +1) == _evaluate_ladder_term(
+        (-1, +1), 2, -1
+    )
+    assert _evaluate_ladder_term((-2, +3, -2), 5, +1) == _evaluate_ladder_term(
+        (+2, -3, +2), 5, -1
+    )
+
+
+def test_to_landau_basis():
+    # test basic usage
+    ham, momenta, normal_coord = to_landau_basis("k_x**2 + k_y**2")
+    assert sympy.expand(ham) == abs(B) * a * a_dag + abs(B) * a_dag * a
+    assert momenta == (k_x, k_y)
+    assert normal_coord == z
+
+    # test that hamiltonian can be specified as a sympy expression
+    ham, momenta, normal_coord = to_landau_basis(sympify("k_x**2 + k_y**2"))
+    assert sympy.expand(ham) == abs(B) * a * a_dag + abs(B) * a_dag * a
+    assert momenta == (k_x, k_y)
+    assert normal_coord == z
+
+    # test that
+    ham, momenta, normal_coord = to_landau_basis("k_x**2 + k_y**2 + k_z**2 + V(z)")
+    assert sympy.expand(ham) == (
+        abs(B) * a * a_dag + abs(B) * a_dag * a + k_z ** 2 + V(z)
+    )
+    assert momenta == (k_x, k_y)
+    assert normal_coord == z
+
+    # test for momenta explicitly specified
+    ham, momenta, normal_coord = to_landau_basis(
+        "k_x**2 + k_y**2 + k_z**2 + k_x*k_y", momenta=("k_z", "k_y")
+    )
+    assert sympy.expand(ham) == (
+        abs(B) * a * a_dag
+        + abs(B) * a_dag * a
+        + k_x ** 2
+        + sympy.I * sympy.sqrt(abs(B) / 2) * k_x * a
+        - sympy.I * sympy.sqrt(abs(B) / 2) * k_x * a_dag
+    )
+    assert momenta == (k_z, k_y)
+    assert normal_coord == x
+
+
+def test_discretize_landau():
+    n_levels = 10
+    magnetic_field = 1 / 3  # a suitably arbitrary value
+    # Ensure that we can handle products of ladder operators by truncating
+    # several levels higher than the number of levels we actually want.
+    a = np.diag(np.sqrt(np.arange(1, n_levels + 5)), k=1)
+    a_dag = a.conjugate().transpose()
+    k_x = sqrt(magnetic_field / 2) * (a + a_dag)
+    k_y = 1j * sqrt(magnetic_field / 2) * (a - a_dag)
+    sigma_0 = np.eye(2)
+    sigma_x = np.array([[0, 1], [1, 0]])
+    sigma_y = np.array([[0, -1j], [1j, 0]])
+
+    # test that errors are raised on invalid input
+    with pytest.raises(ValueError):
+        discretize_landau("k_x**2 + k_y**2", N=0)
+    with pytest.raises(ValueError):
+        discretize_landau("k_x**2 + k_y**2", N=-1)
+
+    # test a basic Hamiltonian with no normal coordinate dependence
+    syst = discretize_landau("k_x**2 + k_y**2", N=n_levels)
+    lat = LandauLattice(1, norbs=1)
+    assert isinstance(syst.symmetry, kwant.builder.NoSymmetry)
+    syst = syst.finalized()
+    assert set(syst.sites) == {lat(0, j) for j in range(n_levels)}
+    assert np.allclose(
+        syst.hamiltonian_submatrix(params=dict(B=0)), np.zeros((n_levels, n_levels))
+    )
+    should_be = k_x @ k_x + k_y @ k_y
+    assert np.allclose(
+        syst.hamiltonian_submatrix(params=dict(B=magnetic_field)),
+        should_be[:n_levels, :n_levels],
+    )
+
+    # test negative magnetic field
+    assert np.allclose(
+        syst.hamiltonian_submatrix(params=dict(B=-magnetic_field)),
+        should_be[:n_levels, :n_levels],
+    )
+
+    # test hamiltonian with no onsite elements
+    syst = discretize_landau("k_x", N=n_levels)
+    syst = syst.finalized()
+    assert np.allclose(
+        syst.hamiltonian_submatrix(params=dict(B=magnetic_field)),
+        k_x[:n_levels, :n_levels],
+    )
+
+    # test a basic Hamiltonian with normal coordinate dependence
+    grid = 1 / 7  # a suitably arbitrary value
+    syst = discretize_landau(
+        "k_x**2 + k_y**2 + k_z**2 + V(z)", N=n_levels, grid_spacing=grid
+    )
+    assert isinstance(syst.symmetry, kwant.lattice.TranslationalSymmetry)
+    syst = syst.finalized()
+    zero_potential = syst.cell_hamiltonian(params=dict(B=magnetic_field, V=lambda z: 0))
+    with_potential = syst.cell_hamiltonian(params=dict(B=magnetic_field, V=lambda z: 1))
+    # extra +2/grid**2 from onsite kinetic energy
+    assert np.allclose(
+        zero_potential,
+        should_be[:n_levels, :n_levels] + (2 / grid ** 2) * np.eye(n_levels),
+    )
+    # V(z) just adds the same energy to each onsite
+    assert np.allclose(with_potential - zero_potential, np.eye(n_levels))
+    # hopping matrix does not exchange landau levels
+    assert np.allclose(
+        syst.inter_cell_hopping(params=dict(B=magnetic_field, V=lambda z: 0)),
+        -np.eye(n_levels) / grid ** 2,
+    )
+
+    # test a Hamiltonian with mixing between Landau levels
+    # and spatial degrees of freedom.
+    syst = discretize_landau("k_x**2 + k_y**2 + k_x*k_z", N=n_levels)
+    syst = syst.finalized()
+    assert np.allclose(
+        syst.inter_cell_hopping(params=dict(B=magnetic_field)),
+        -1j * k_x[:n_levels, :n_levels] / 2,
+    )
+
+    # test a Hamiltonian with extra degrees of freedom
+    syst = discretize_landau("sigma_0 * k_x**2 + sigma_x * k_y**2", N=n_levels)
+    syst = syst.finalized()
+    assert syst.sites[0].family.norbs == 2
+    should_be = np.kron(k_x @ k_x, sigma_0) + np.kron(k_y @ k_y, sigma_x)
+    assert np.allclose(
+        syst.hamiltonian_submatrix(params=dict(B=magnetic_field)),
+        should_be[: 2 * n_levels, : 2 * n_levels],
+    )
+
+    # test a linear Hamiltonian
+    syst = discretize_landau("sigma_y * k_x - sigma_x * k_y", N=n_levels)
+    syst = syst.finalized()
+    should_be = np.kron(k_x, sigma_y) - np.kron(k_y, sigma_x)
+    assert np.allclose(
+        syst.hamiltonian_submatrix(params=dict(B=magnetic_field)),
+        should_be[: 2 * n_levels, : 2 * n_levels],
+    )
+    assert np.allclose(
+        syst.hamiltonian_submatrix(params=dict(B=magnetic_field)),
+        syst.hamiltonian_submatrix(params=dict(B=-magnetic_field)),
+    )
+
+
+def test_analytical_spectrum():
+    hamiltonian = """(k_x**2 + k_y**2) * sigma_0 +
+                    alpha * (k_x * sigma_y - k_y * sigma_x) +
+                    g * B * sigma_z"""
+
+    def exact_Es(n, B, alpha, g):
+        # See e.g. R. Winkler (2003), section 8.4.1
+        sign_B = np.sign(B)
+        B = np.abs(B)
+        Ep = 2*B*(n+1) - 0.5*np.sqrt((2*B - sign_B*2*g*B)**2 + 8*B*alpha**2*(n+1))
+        Em = 2*B*n + 0.5*np.sqrt((2*B - sign_B*2*g*B)**2 + 8*B*alpha**2*n)
+        return Ep, Em
+
+    N = 20
+    syst = discretize_landau(hamiltonian, N)
+    syst = syst.finalized()
+    params = dict(alpha=0.07, g=0.04)
+    for _ in range(5):
+        B = 0.01 + np.random.rand()*3
+        params['B'] = B
+        exact = [exact_Es(n, B, params['alpha'], params['g']) for n in range(N)]
+        # We only check the first N levels - the SOI couples adjacent levels,
+        # so the higher ones are not necessarily determined accurately in the
+        # discretization
+        exact = np.sort([energy for energies in exact for energy in energies])[:N]
+        ll_spect = np.linalg.eigvalsh(syst.hamiltonian_submatrix(params=params))[:len(exact)]
+        assert np.allclose(ll_spect, exact)
+
+
+
+def test_fill():
+
+    def shape(site, lower, upper):
+        (z, )= site.pos
+        return lower <= z < upper
+
+    hamiltonian = "k_x**2 + k_y**2 + k_z**2"
+    N = 6
+    template = discretize_landau(hamiltonian, N)
+
+    syst = kwant.Builder()
+    width = 4
+    syst.fill(template, lambda site: shape(site, 0, width), (0, ));
+
+    correct_tags = [(coordinate, ll_index) for coordinate, ll_index
+                    in itertools.product(range(width), range(N))]
+
+    syst_tags = [site.tag for site in syst.sites()]
+
+    assert len(syst_tags) == len(correct_tags)
+    assert all(tag in correct_tags for tag in syst_tags)