diff --git a/MAINTAINERS.md b/MAINTAINERS.md
index a57e4bfbbbff4526527819a3f2e4840c58a9b799..f57f0702dacf98e84965cc10569c3748dca2fc31 100644
--- a/MAINTAINERS.md
+++ b/MAINTAINERS.md
@@ -23,6 +23,9 @@ Hadron models:
 - Felix Riehn <friehn@lip.pt>, Santiago/Lisbon
 - Anatoli Fedynitch <anatoli.fedynitch@icecube.wisc.edu> ICRR Tokyo
 
+Python library:
+- Remy Prechelt <prechelt@hawaii.edu>, UHM
+
 Radio:
 - Remy Prechelt <prechelt@hawaii.edu> 
 - Tim Huege <tim.huege@kit.edu>, KIT
diff --git a/python/.gitignore b/python/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..7b9e44cc96089e3d033d74eb46965d968bd28d52
--- /dev/null
+++ b/python/.gitignore
@@ -0,0 +1,147 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# ignore any generated output files
+*.npz
+*.dat
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+pip-wheel-metadata/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+#  Usually these files are written by a python script from a template
+#  before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+#   For a library or package, you might want to ignore these files since the code is
+#   intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+#   According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+#   However, in case of collaboration, if having platform-specific dependencies or dependencies
+#   having no cross-platform support, pipenv may install dependencies that don't work, or not
+#   install all needed dependencies.
+#Pipfile.lock
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# static files generated from Django application using `collectstatic`
+media
+static
diff --git a/python/Makefile b/python/Makefile
new file mode 100644
index 0000000000000000000000000000000000000000..ccc13257fe572b456857657929c41d8199ad6529
--- /dev/null
+++ b/python/Makefile
@@ -0,0 +1,30 @@
+##
+# ##
+# corsika
+#
+# @file
+
+# find python3
+PYTHON=`which python3`
+
+# our testing targets
+.PHONY: tests flake black isort all
+
+all: mypy isort black flake tests
+
+tests:
+	${PYTHON} -m pytest --cov=corsika tests
+
+flake:
+	${PYTHON} -m flake8 corsika tests
+
+black:
+	${PYTHON} -m black -t py37 corsika tests
+
+isort:
+	${PYTHON} -m isort --atomic corsika tests
+
+mypy:
+	${PYTHON} -m mypy corsika
+
+# end
diff --git a/python/README.md b/python/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..7e35dfe389dde4d372b1e38301c41c42a45c6fa8
--- /dev/null
+++ b/python/README.md
@@ -0,0 +1,8 @@
+# CORSIKA 8 - Python Library
+
+To install this into your global environment using `pip` (not recommended), run
+
+``` shell
+pip install --user git+https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika/-/tree/master/python 
+```
+
diff --git a/python/corsika/__init__.py b/python/corsika/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..078d688098095f06e2a14d2db4c203370993d578
--- /dev/null
+++ b/python/corsika/__init__.py
@@ -0,0 +1,17 @@
+"""
+ A Python interface to CORSIKA 8.
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+from . import io
+from .io.library import Library
+
+# all imported objects
+__all__ = ["io", "Library"]
+
+__version__: str = "8.0.0-alpha"
diff --git a/python/corsika/io/__init__.py b/python/corsika/io/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..296572d6dee46a4b95a61b8ef18c549f120a1205
--- /dev/null
+++ b/python/corsika/io/__init__.py
@@ -0,0 +1,15 @@
+"""
+ The 'io' module provides for reading CORSIKA8 output files.
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+from .hist import read_hist
+from .library import Library
+
+# all exported objects
+__all__ = ["read_hist", "Library"]
diff --git a/python/corsika/io/hist.py b/python/corsika/io/hist.py
new file mode 100644
index 0000000000000000000000000000000000000000..615db0faec0ec63cff8fe00fc5ebe1d8ce08f2ff
--- /dev/null
+++ b/python/corsika/io/hist.py
@@ -0,0 +1,70 @@
+"""
+ This file supports reading boost_histograms from CORSIKA8.
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+import boost_histogram as bh
+import numpy as np
+
+
+def read_hist(filename: str) -> bh.Histogram:
+    """
+    Read a histogram produced with CORSIKA8's `SaveBoostHistogram()` function.
+
+    Parameters
+    ----------
+    filename: str
+        The filename of the .npy file containing the histogram.
+
+    Returns
+    -------
+    hist: bh.Histogram
+        An initialized bh.Histogram instance.
+
+    Throws
+    ------
+    ValueError:
+        If the histogram type is not supported.
+    """
+
+    # load the filenames
+    d = np.load(filename)
+
+    # extract the axis and overflows information
+    axistypes = d["axistypes"].view("c")
+    overflow = d["overflow"]
+    underflow = d["underflow"]
+
+    # this is where we store the axes that we extract from the file.
+    axes = []
+
+    # we now loop over the axes
+    for i, (at, has_overflow, has_underflow) in enumerate(
+        zip(axistypes, overflow, underflow)
+    ):
+
+        # continuous histogram
+        if at == b"c":
+            axes.append(
+                bh.axis.Variable(
+                    d[f"binedges_{i}"], overflow=has_overflow, underflow=has_underflow
+                )
+            )
+        # discrete histogram
+        elif at == b"d":
+            axes.append(bh.axis.IntCategory(d[f"bins_{i}"], growth=(not has_overflow)))
+
+        # and unknown histogram type
+        else:
+            raise ValueError(f"'{at}' is not a valid C8 histogram axistype.")
+
+    # create the histogram and fill it in
+    h = bh.Histogram(*axes)
+    h.view(flow=True)[:] = d["data"]
+
+    return h
diff --git a/python/corsika/io/library.py b/python/corsika/io/library.py
new file mode 100644
index 0000000000000000000000000000000000000000..4f0bb685ca4fea9f8e8a0508dcecbceb4f041ecc
--- /dev/null
+++ b/python/corsika/io/library.py
@@ -0,0 +1,196 @@
+"""
+ This file allows for reading/working with C8 libraries.
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import logging
+import os
+import os.path as op
+import re
+from typing import Any, Dict, Optional
+
+import yaml
+
+from . import outputs
+
+
+class Library(object):
+    """
+    Represents a library ("run") of showers produced by C8.
+    """
+
+    def __init__(self, path: str):
+        """
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+
+        Raises
+        ------
+        ValueError
+            If `path` does not contain a valid CORSIKA8 library.
+        """
+
+        # check that this is a valid library
+        if not self.__valid_library(path):
+            raise ValueError(f"'{path}' does not contain a valid CORSIKA8 library.")
+
+        # store the top-level path
+        self.path = path
+
+        # load the config file
+        self.config = self.load_config(path)
+
+        # build the list of outputs
+        self.__outputs = self.__build_outputs(path)
+
+    def get(self, name: str) -> Optional[outputs.Output]:
+        """
+        Return the output with a given name.
+        """
+        if name in self.__outputs:
+            return self.__outputs[name]
+        else:
+            msg = f"Output with name '{name}' not available in this library."
+            logging.getLogger("corsika").warn(msg)
+            return None
+
+    @staticmethod
+    def load_config(path: str) -> Dict[str, Any]:
+        """
+        Load the top-level config from a given library path.
+
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        dict:
+            The config as a python dictionary.
+
+        Raises
+        ------
+        FileNotFoundError
+            If the config file cannot be found
+
+        """
+        with open(op.join(path, "config.yaml"), "r") as f:
+            return yaml.load(f, Loader=yaml.Loader)
+
+    @staticmethod
+    def __valid_library(path: str) -> bool:
+        """
+        Check if the library pointed to by 'path' is a valid C8 library.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        bool:
+            True if this is a valid C8 library.
+
+        """
+
+        # check that the config file exists
+        if not op.exists(op.join(path, "config.yaml")):
+            return False
+
+        # the config file exists, we load it
+        config = Library.load_config(path)
+
+        # and check that the config's "writer" key is correct
+        return config["creator"] == "CORSIKA8"
+
+    @staticmethod
+    def __build_outputs(path: str) -> Dict[str, outputs.Output]:
+        """
+        Build the outputs contained in this library.
+
+        This will print a warning message if a particular
+        output is invalid but will continue to load additional
+        outputs afterwards.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this library.
+
+        Returns
+        -------
+        Dict[str, Output]:
+            A dictionary mapping names to initialized outputs.
+        """
+
+        # get a list of the subdirectories in the library
+        _, dirs, _ = next(os.walk(path))
+
+        # this is the dictionary where we store our components
+        outputs: Dict[str, Any] = {}
+
+        # loop over the subdirectories
+        for subdir in dirs:
+
+            # read the config file for this output
+            config = Library.load_config(op.join(path, subdir))
+
+            # the name keyword is our unique identifier
+            name = config.get("name")
+
+            # get the "type" keyword to identify this component
+            out_type = config.get("type")
+
+            # if `out_type` was None, this is an invalid output
+            if out_type is None or name is None:
+                msg = (
+                    f"'{subdir}' does not contain a valid config."
+                    "Missing 'type' or 'name' keyword."
+                )
+                logging.getLogger("corsika").warn(msg)
+                continue  # skip to the next output, don't error
+
+            # we now have a valid component type, get the corresponding
+            # type from the proccesses subdirectory
+            try:
+
+                # create the name of the module containing this output class
+                module_name = re.sub(r"(?<!^)(?=[A-Z])", "_", out_type).lower()
+
+                # instantiate the output and store it in our dict
+                # we use a regex to go from CamelCase to snake_case
+                component = getattr(getattr(outputs, module_name), out_type)(
+                    op.join(path, subdir)
+                )
+
+                # check if the read failed
+                if not component.is_good():
+                    msg = (
+                        f"'{name}' encountered an error while reading. "
+                        "This process will be not be loaded."
+                    )
+                    logging.getLogger("corsika").warn(msg)
+                else:
+                    outputs[name] = component
+
+            except AttributeError:
+                msg = (
+                    f"Unable to instantiate an instance of '{out_type}' "
+                    "for a process called '{name}'"
+                )
+                logging.getLogger("corsika").warn(msg)
+                continue  # skip to the next output, don't error
+
+        # and we are done building - return the constructed outputs
+        return outputs
diff --git a/python/corsika/io/outputs/__init__.py b/python/corsika/io/outputs/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..67fb35a7bb269697d246e3db2db33aeb0d2c78ac
--- /dev/null
+++ b/python/corsika/io/outputs/__init__.py
@@ -0,0 +1,13 @@
+"""
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+from .observation_plane import ObservationPlane
+from .output import Output
+
+__all__ = ["Output", "ObservationPlane"]
diff --git a/python/corsika/io/outputs/observation_plane.py b/python/corsika/io/outputs/observation_plane.py
new file mode 100644
index 0000000000000000000000000000000000000000..00ac90dd43b0daf6c27f379da1d1eccb609e7a11
--- /dev/null
+++ b/python/corsika/io/outputs/observation_plane.py
@@ -0,0 +1,99 @@
+"""
+ Read data written by ObservationPlane.
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import logging
+import os.path as op
+from typing import Any, Dict
+
+import pyarrow.parquet as pq
+
+from .output import Output
+
+
+class ObservationPlane(Output):
+    """
+    Read particle data from an ObservationPlane.
+    """
+
+    def __init__(self, path: str):
+        """
+        Load the particle data into a parquet table.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this output.
+        """
+
+        # load and store our path and config
+        self.path = path
+        self.__config = self.load_config(path)
+
+        # try and load our data
+        try:
+            self.__data = pq.read_table(op.join(path, "particles.parquet"))
+        except Exception as e:
+            logging.getLogger("corsika").warn(
+                f"An error occured loading an ObservationPlane: {e}"
+            )
+
+    def is_good(self) -> bool:
+        """
+        Returns true if this output has been read successfully
+        and has the correct files/state/etc.
+
+        Returns
+        -------
+        bool:
+            True if this is a good output.
+        """
+        return self.__data is not None and self.__config is not None
+
+    def astype(self, dtype: str = "parquet", **kwargs: Any) -> Any:
+        """
+        Load the particle data from this observation plane.
+
+        All additional keyword arguments are passed to `parquet.read_table`
+
+        Parameters
+        ----------
+        dtype: str
+            The data format to return the data in (i.e. numpy, pandas, etc.)
+
+        Returns
+        -------
+        Any:
+            The return type of this method is determined by `dtype`.
+        """
+        if dtype == "parquet":
+            return self.__data
+        elif dtype == "pandas":
+            return self.__data.to_pandas()
+        else:
+            raise ValueError(
+                (
+                    f"Unknown format '{dtype}' for ObservationPlane. "
+                    "We currently only support ['parquet', 'pandas']."
+                )
+            )
+
+    @property
+    def config(self) -> Dict[str, Any]:
+        """
+        Return the config file for this output.
+
+        Parameters
+        ----------
+
+        Returns
+        -------
+        Dict[str, any]
+            The configuration file for this output.
+        """
+        return self.__config
diff --git a/python/corsika/io/outputs/output.py b/python/corsika/io/outputs/output.py
new file mode 100644
index 0000000000000000000000000000000000000000..7d7e27e82085b2017ab624425df487ac6734686b
--- /dev/null
+++ b/python/corsika/io/outputs/output.py
@@ -0,0 +1,108 @@
+"""
+ This file defines the API for all output readers.
+
+ (c) Copyright 2020 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import os.path as op
+from abc import ABC, abstractmethod
+from typing import Any, Dict
+
+import yaml
+
+
+class Output(ABC):
+    """
+    This class defines the abstract interface for all classes
+    that wish to provide reading support for CORSIKA8 outputs.
+    """
+
+    @abstractmethod
+    def __init__(self, path: str):
+        """
+        __init__ must load the output files and check
+        that it is valid.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing this output.
+        """
+        pass
+
+    @abstractmethod
+    def is_good(self) -> bool:
+        """
+        Returns true if this output has been read successfully
+        and has the correct files/state/etc.
+
+        Returns
+        -------
+        bool:
+            True if this is a good output.
+        """
+        pass
+
+    @abstractmethod
+    def astype(self, dtype: str, **kwargs: Any) -> Any:
+        """
+        Return the data for this output in the data format given by 'dtype'
+
+        Parameters
+        ----------
+        dtype: str
+            The data format to return the data in (i.e. numpy, pandas, etc.)
+        *args: Any
+            Additional arguments can be accepted by the output.
+        **kwargs: Any
+            Additional keyword arguments can be accepted by the output.
+
+        Returns
+        -------
+        Any:
+            The return type of this method is determined by `dtype`.
+        """
+        pass
+
+    @property
+    @abstractmethod
+    def config(self) -> Dict[str, Any]:
+        """
+        Return the config file for this output.
+
+        Parameters
+        ----------
+
+        Returns
+        -------
+        Dict[str, any]
+            The configuration file for this output.
+        """
+        pass
+
+    @staticmethod
+    def load_config(path: str) -> Dict[str, Any]:
+        """
+        Load the top-level config from a given library path.
+
+        Parameters
+        ----------
+        path: str
+            The path to the directory containing the library.
+
+        Returns
+        -------
+        dict:
+            The config as a python dictionary.
+
+        Raises
+        ------
+        FileNotFoundError
+            If the config file cannot be found
+
+        """
+        with open(op.join(path, "config.yaml"), "r") as f:
+            return yaml.load(f, Loader=yaml.Loader)
diff --git a/python/setup.cfg b/python/setup.cfg
new file mode 100644
index 0000000000000000000000000000000000000000..f58d04eeb061f000b2d890aa26f542f70f3aa0c0
--- /dev/null
+++ b/python/setup.cfg
@@ -0,0 +1,69 @@
+[flake8]
+# use a slightly longer line to be consistent with black
+max-line-length = 88
+
+# E231 is missing whitespace after a comma
+# this contradicts the black formatting rules
+# and therefore creates spurious errors
+ignore = E231
+
+# we set various directories we want to exclude
+exclude =
+    # Don't bother checking in cache directories
+    __pycache__
+
+
+[isort]
+# use parenthesis for multi-line imports
+use_parentheses = true
+
+
+[mypy]
+# the primary Python version
+python_version = 3.7
+
+# allow returning Any
+# this creates excessive errors when using libraries
+# that don't have MyPy typing support
+warn_return_any = False
+
+# don't allow untyped functions
+disallow_untyped_defs = True
+
+# warn if any part of this config is mispelled
+warn_unused_configs = True
+
+# warn for missing type information
+warn_incomplete_stub = True
+
+# warn us if we don't return from a function explicitly
+warn_no_return = True
+
+# use incremental typing to speed things up
+incremental = True
+
+# show error contexts
+show_error_context = True
+
+# and show the column numbers for errors
+show_column_numbers = True
+
+# ignore missing types for setuptools
+[mypy-setuptools.*]
+ignore_missing_imports = True
+
+# ignore missing types for numpy
+[mypy-numpy.*]
+ignore_missing_imports = True
+
+# ignore missing types for matplotlib
+[mypy-matplotlib.*]
+ignore_missing_imports = True
+
+# ignore missing types for boost_histogram
+[mypy-boost_histogram.*]
+ignore_missing_imports = True
+
+# ignore missing types for pyarow
+[mypy-pyarrow.*]
+ignore_missing_imports = True
diff --git a/python/setup.py b/python/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..196c2ecbbccbc7e5caafee93be0af1c41746c94f
--- /dev/null
+++ b/python/setup.py
@@ -0,0 +1,51 @@
+from os import path
+from setuptools import setup
+
+# the stereo version
+__version__ = "8.0.0-alpha"
+
+# get the absolute path of this project
+here = path.abspath(path.dirname(__file__))
+
+# Get the long description from the README file
+with open(path.join(here, "README.md"), encoding="utf-8") as f:
+    long_description = f.read()
+
+# the standard setup info
+setup(
+    name="corsika",
+    version=__version__,
+    description="A Python package for working with CORSIKA 8.",
+    long_description=long_description,
+    long_description_content_type="text/markdown",
+    url="https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika",
+    author="CORSIKA 8 Collaboration",
+    author_email="corsika-devel@lists.kit.edu",
+    classifiers=[
+        "Development Status :: 3 - Alpha",
+        "Intended Audience :: Science/Research",
+        "Topic :: Scientific/Engineering :: Physics",
+        "Programming Language :: Python :: 3.6",
+        "Programming Language :: Python :: 3.7",
+        "Programming Language :: Python :: 3.8",
+    ],
+    keywords=["cosmic ray", "physics", "air shower", "simulation"],
+    packages=["corsika"],
+    python_requires=">=3.6*, <4",
+    install_requires=["numpy", "pyyaml", "pyarrow", "boost_histogram"],
+    extras_require={
+        "test": [
+            "pytest",
+            "black",
+            "mypy",
+            "isort",
+            "coverage",
+            "pytest-cov",
+            "flake8",
+        ],
+        "pandas": ["pandas"],
+    },
+    scripts=[],
+    project_urls={"code": "https://gitlab.ikp.kit.edu/AirShowerPhysics/corsika"},
+    include_package_data=False,
+)
diff --git a/python/tests/__init__.py b/python/tests/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c7d9b672b4c1bde7cc7725c619c0390a79e682c7
--- /dev/null
+++ b/python/tests/__init__.py
@@ -0,0 +1,56 @@
+"""
+ Tests for `corsika`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+
+import os
+import os.path as op
+
+__all__ = ["build_directory"]
+
+
+def find_build_directory() -> str:
+    """
+    Return the absolute path to the CORSIKA8 build directory.
+
+    Returns
+    -------
+    str
+        The absolute path to the build directory.
+
+    Raises
+    ------
+    RuntimeError
+        If the build directory cannot be found or it is empty.
+    """
+
+    # check if we are running on Gitlab
+    gitlab_build_dir = os.getenv("CI_BUILDS_DIR")
+
+    # if we are running on Gitlab
+    if gitlab_build_dir is not None:
+        build_dir = op.abspath(gitlab_build_dir)
+    else:  # otherwise, we are running locally
+        build_dir = op.abspath(
+            op.join(
+                op.dirname(__file__),
+                op.pardir,
+                op.pardir,
+                "build",
+            )
+        )
+
+    # check that the build directory contains 'CMakeCache.txt'
+    if not op.exists(op.join(build_dir, "CMakeCache.txt")):
+        raise RuntimeError("Python tests cannot find C8 build directory.")
+
+    return build_dir
+
+
+# find the build_directory once
+build_directory = find_build_directory()
diff --git a/python/tests/io/__init__.py b/python/tests/io/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..0d08be944644953fd08405a4502456e76c1203b9
--- /dev/null
+++ b/python/tests/io/__init__.py
@@ -0,0 +1,9 @@
+"""
+ Tests for `corsika.io`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
diff --git a/python/tests/io/test_hist.py b/python/tests/io/test_hist.py
new file mode 100644
index 0000000000000000000000000000000000000000..7cf5b38fb7798c81680cf14d108e3b31dd3dcaf1
--- /dev/null
+++ b/python/tests/io/test_hist.py
@@ -0,0 +1,70 @@
+"""
+ Tests for `corsika.io.hist`
+
+ (c) Copyright 2018 CORSIKA Project, corsika-project@lists.kit.edu
+
+ This software is distributed under the terms of the GNU General Public
+ Licence version 3 (GPL Version 3). See file LICENSE for a full version of
+ the license.
+"""
+import os
+import os.path as op
+import subprocess
+
+import corsika
+
+from .. import build_directory
+
+# the directory containing 'testSaveBoostHistogram'
+bindir = op.join(build_directory, "Framework", "Utilities")
+
+
+def generate_hist() -> str:
+    """
+    Generate a test with `testSaveBoostHistogram`.
+
+    Returns
+    -------
+    str
+        The path to the generated histogram.
+    bool
+        If True, this file was regenerated.
+    """
+
+    # we construct the name of the bin
+    bin = op.join(bindir, "testSaveBoostHistogram")
+
+    # check if a histogram already exists
+    if op.exists(op.join(bin, "hist.npz")):
+        return op.join(bin, "hist.npz"), False
+    else:
+        # run the program - this generates "hist.npz" in the CWD
+        subprocess.call(bin)
+
+        return op.join(os.getcwd(), "hist.npz"), True
+
+
+def test_corsika_io() -> None:
+    """
+    Test I can corsika.io without a further import.
+    """
+    corsika.io.read_hist
+
+
+def test_corsika_read_hist() -> None:
+    """
+    Check that I can read in the test histograms with `read_hist`.
+    """
+
+    # generate a test histogram
+    filename, delete = generate_hist()
+
+    # and try and read in a histogram
+    h = corsika.io.read_hist(filename)
+
+    # and delete the generated histogram
+    if delete:
+        os.remove(filename)
+
+    # and check that it isn't empty
+    assert not h.empty()